Compare commits

..

27 Commits

Author SHA1 Message Date
7880672737
ticket: close multi-pod layout polish 2026-05-29 01:49:26 +09:00
c6d9b7f405
merge: multi-pod view section layout 2026-05-29 01:49:06 +09:00
2bb69ae7f6
tui: section multi-pod list layout 2026-05-29 01:46:48 +09:00
cbb4c4dec4
ticket: close nix packaging 2026-05-29 01:42:09 +09:00
420e83bea3
merge: nix packaging 2026-05-29 01:41:54 +09:00
7ecd58814c
nix: exclude local worktrees from package source 2026-05-29 01:41:09 +09:00
a8e9a091f8
ticket: add multi-pod layout polish 2026-05-29 01:33:35 +09:00
9df9dc1863
nix: add installable package 2026-05-29 01:32:04 +09:00
3c086b7497
ticket: close multi-pod TUI view 2026-05-29 01:09:02 +09:00
20f55a3c61
merge: multi-pod TUI view 2026-05-29 01:08:42 +09:00
7cfa5503df
feat: add multi pod tui dashboard 2026-05-29 01:04:56 +09:00
32be379f54
ticket: specify nix package file 2026-05-29 00:56:04 +09:00
8b2f16e009
ticket: specify multi-pod TUI entrypoint 2026-05-29 00:53:33 +09:00
3784cc8bbf
ticket: close TUI pod list abstraction 2026-05-29 00:40:32 +09:00
3457167931
merge: TUI pod list abstraction 2026-05-29 00:39:57 +09:00
d9984f33c2
tui: drain initial pod status events 2026-05-29 00:39:00 +09:00
35b13a98df
tui: add pod list model 2026-05-29 00:33:57 +09:00
74f792da1a
ticket: add web tools and nix packaging 2026-05-29 00:31:09 +09:00
26d8a5d9be
ticket: refine TUI pod list abstraction 2026-05-29 00:25:03 +09:00
41ce27f038
ticket: define multi-pod TUI view 2026-05-28 23:48:39 +09:00
f7d4b12e7f
ticket: add TUI pod list abstraction 2026-05-28 23:17:16 +09:00
6083121574
audit: record crate boundary findings 2026-05-28 22:25:54 +09:00
92a9c1416c
ticket: close spawnpod initial run confirmation 2026-05-28 22:25:28 +09:00
3cb1138e84
merge: spawnpod initial run confirmation 2026-05-28 22:24:14 +09:00
2b4bdda89c
fix: confirm initial SpawnPod run delivery 2026-05-28 22:14:28 +09:00
834d21723b
ticket: add crate boundary audit 2026-05-28 22:13:45 +09:00
3658242bbc
ticket: refine spawnpod socket delivery 2026-05-28 22:06:47 +09:00
48 changed files with 7072 additions and 598 deletions

View File

@ -548,10 +548,20 @@ async fn probe_socket(socket_path: &Path) -> LiveInfo {
Ok(Ok(stream)) => {
let (r, _w) = stream.into_split();
let mut reader = JsonLineReader::new(r);
let status = match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await {
Ok(Ok(Some(Event::Snapshot { status, .. }))) => Some(status),
_ => None,
};
let mut status = None;
loop {
match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await {
Ok(Ok(Some(Event::Snapshot {
status: snapshot_status,
..
}))) => {
status = Some(snapshot_status);
break;
}
Ok(Ok(Some(Event::Alert(_)))) => continue,
Ok(Ok(Some(_))) | Ok(Ok(None)) | Ok(Err(_)) | Err(_) => break,
}
}
LiveInfo {
socket_path: socket_path.to_path_buf(),
reachable: true,
@ -755,8 +765,8 @@ mod tests {
use std::sync::Mutex;
use manifest::{Permission, ScopeRule};
use protocol::Greeting;
use protocol::stream::JsonLineWriter;
use protocol::{Alert, AlertLevel, AlertSource, Greeting};
use session_store::{
FsStore, PodSpawnedChild, PodSpawnedScopeRule, new_segment_id, new_session_id,
};
@ -931,6 +941,48 @@ mod tests {
live_listener.abort();
}
#[tokio::test(flavor = "current_thread")]
async fn probe_socket_reads_status_after_replayed_alert() {
let root = TempDir::new().unwrap();
let socket = root.path().join("pod.sock");
let listener = UnixListener::bind(&socket).unwrap();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut writer = JsonLineWriter::new(stream);
writer
.write(&Event::Alert(Alert {
level: AlertLevel::Warn,
source: AlertSource::Pod,
message: "replayed alert".into(),
timestamp_ms: 0,
}))
.await
.unwrap();
writer
.write(&Event::Snapshot {
entries: Vec::new(),
greeting: Greeting {
pod_name: "alerted".into(),
cwd: "/tmp".into(),
provider: "test".into(),
model: "test".into(),
scope_summary: String::new(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
},
status: PodStatus::Paused,
})
.await
.unwrap();
});
let info = probe_socket(&socket).await;
assert!(info.reachable);
assert!(matches!(info.status, Some(PodStatus::Paused)));
handle.await.unwrap();
}
fn child(name: &str, socket_path: &Path) -> PodSpawnedChild {
PodSpawnedChild {
pod_name: name.to_string(),

View File

@ -79,6 +79,10 @@ impl Tool for SendToPodTool {
"pod `{}` is already running a turn; wait for it to finish and retry",
input.name
)),
SendRunError::Rejected { code, message } => ToolError::ExecutionFailed(format!(
"pod `{}` rejected the run with {code:?}: {message}",
input.name
)),
SendRunError::Io(msg) => {
ToolError::ExecutionFailed(format!("send to `{}`: {msg}", input.name))
}
@ -370,16 +374,20 @@ pub(crate) enum SendRunError {
/// Target Pod responded with `Error { AlreadyRunning }` — the
/// caller can retry once the current turn ends.
AlreadyRunning,
/// Any other failure (connect / write / read / unexpected EOF).
/// Target Pod explicitly rejected the run after delivery reached the
/// controller.
Rejected { code: ErrorCode, message: String },
/// Transport, protocol, timeout, or unexpected EOF before acceptance
/// evidence was observed.
Io(String),
}
/// Write `Method::Run` to the target and read back events until we see
/// evidence that the controller accepted the run (`UserMessage`,
/// `TurnStart`, or a user-send `InvokeStart`) or rejected it with
/// `Error { AlreadyRunning }`. Any connect-time Snapshot or replayed alerts
/// that precede the response are skipped. Times out per-read so a stuck Pod
/// doesn't hang the tool.
/// `TurnStart`, or a user-send `InvokeStart`) or rejected it. The connect-time
/// event prelude is drained before sending the method so large Snapshots and
/// large Run payloads cannot block each other on the same socket. Times out
/// per operation so a stuck Pod doesn't hang the tool.
pub(crate) async fn send_run_and_confirm(socket: &Path, input: String) -> Result<(), SendRunError> {
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket))
.await
@ -388,6 +396,31 @@ pub(crate) async fn send_run_and_confirm(socket: &Path, input: String) -> Result
let (r, w) = stream.into_split();
let mut writer = JsonLineWriter::new(w);
let mut reader = JsonLineReader::new(r);
loop {
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
.await
.map_err(|_| SendRunError::Io("read initial Snapshot timed out".into()))?
.map_err(|e| SendRunError::Io(format!("read initial Snapshot: {e}")))?;
match event {
Some(Event::Snapshot { .. }) => break,
Some(Event::Alert(_)) => continue,
Some(Event::Error {
code: ErrorCode::AlreadyRunning,
..
}) => return Err(SendRunError::AlreadyRunning),
Some(Event::Error { code, message }) => {
return Err(SendRunError::Rejected { code, message });
}
Some(_) => continue,
None => {
return Err(SendRunError::Io(
"connection closed before initial Snapshot".into(),
));
}
}
}
tokio::time::timeout(
SOCKET_OP_TIMEOUT,
writer.write(&Method::Run {
@ -400,26 +433,23 @@ pub(crate) async fn send_run_and_confirm(socket: &Path, input: String) -> Result
loop {
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
.await
.map_err(|_| SendRunError::Io("read timed out".into()))?
.map_err(|e| SendRunError::Io(format!("read: {e}")))?;
.map_err(|_| SendRunError::Io("read response timed out".into()))?
.map_err(|e| SendRunError::Io(format!("read response: {e}")))?;
match event {
Some(Event::Error {
code: ErrorCode::AlreadyRunning,
..
}) => return Err(SendRunError::AlreadyRunning),
Some(Event::Error { code, message }) => {
return Err(SendRunError::Io(format!(
"pod returned {code:?}: {message}"
)));
return Err(SendRunError::Rejected { code, message });
}
Some(Event::InvokeStart {
kind: InvokeKind::UserSend,
})
| Some(Event::UserMessage { .. })
| Some(Event::TurnStart { .. }) => return Ok(()),
// Alerts, Snapshot, and other pre-turn events can precede the
// controller's response; keep reading until the Run is accepted
// or rejected.
// Other post-Snapshot events can race with the controller's
// response; keep reading until the Run is accepted or rejected.
Some(_) => continue,
None => return Err(SendRunError::Io("connection closed before response".into())),
}
@ -618,6 +648,47 @@ mod tests {
}
}
#[tokio::test]
async fn send_run_and_confirm_drains_alert_and_large_snapshot_before_large_run() {
let tmp = TempDir::new().unwrap();
let socket = tmp.path().join("pod.sock");
let listener = UnixListener::bind(&socket).unwrap();
let large_snapshot_payload = "s".repeat(2 * 1024 * 1024);
let large_run_payload = "r".repeat(2 * 1024 * 1024);
let received = serve_initial_events_then_run_ack(
listener,
vec![
Event::Alert(Alert {
level: AlertLevel::Warn,
source: AlertSource::Pod,
message: "replayed alert".into(),
timestamp_ms: 0,
}),
snapshot(vec![
serde_json::json!({ "payload": large_snapshot_payload }),
]),
],
Event::InvokeStart {
kind: InvokeKind::UserSend,
},
);
send_run_and_confirm(&socket, large_run_payload.clone())
.await
.unwrap();
let method = received.await.unwrap().expect("expected method");
match method {
Method::Run { input } => {
assert_eq!(
protocol::Segment::flatten_to_text(&input),
large_run_payload
);
}
other => panic!("expected Run, got {other:?}"),
}
}
#[tokio::test]
async fn send_run_and_confirm_reports_already_running() {
let tmp = TempDir::new().unwrap();

View File

@ -464,6 +464,9 @@ fn spawn_delivery_error(pod_name: &str, err: SendRunError) -> ToolError {
SendRunError::AlreadyRunning => ToolError::ExecutionFailed(format!(
"spawned pod `{pod_name}` rejected its initial task as already running; the pod remains registered and can be inspected or stopped"
)),
SendRunError::Rejected { code, message } => ToolError::ExecutionFailed(format!(
"spawned pod `{pod_name}` rejected its initial task with {code:?}: {message}; the pod remains registered and can be inspected or stopped"
)),
SendRunError::Io(msg) => ToolError::ExecutionFailed(format!(
"spawned pod `{pod_name}` did not confirm initial task delivery: {msg}; the pod remains registered and can be inspected or stopped"
)),

View File

@ -4,7 +4,9 @@ mod cache;
mod command;
mod input;
mod markdown;
mod multi_pod;
mod picker;
mod pod_list;
mod scroll;
mod spawn;
mod task;
@ -70,6 +72,10 @@ enum Mode {
/// `tui --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in.
ResumeWithSession(SegmentId),
/// `tui --multi`: open the multi-Pod dashboard. This is intentionally
/// separate from `-r`/`--resume`, which keeps its single-Pod picker
/// meaning.
Multi,
}
#[derive(Debug)]
@ -100,9 +106,11 @@ where
{
let args: Vec<String> = args.into_iter().map(Into::into).collect();
let mut resume = false;
let mut multi = false;
let mut session: Option<SegmentId> = None;
let mut pod: Option<String> = None;
let mut socket_override: Option<PathBuf> = None;
let mut socket_seen = false;
let mut positional: Option<String> = None;
let mut i = 0;
@ -112,6 +120,10 @@ where
resume = true;
i += 1;
}
"--multi" => {
multi = true;
i += 1;
}
"--session" => {
let raw = args
.get(i + 1)
@ -128,6 +140,7 @@ where
i += 2;
}
"--socket" => {
socket_seen = true;
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--socket"))?;
@ -146,6 +159,35 @@ where
}
}
if multi {
if resume {
return Err(ParseError::Conflict(
"--multi and --resume are mutually exclusive",
));
}
if session.is_some() {
return Err(ParseError::Conflict(
"--multi and --session are mutually exclusive",
));
}
if pod.is_some() {
return Err(ParseError::Conflict(
"--multi and --pod are mutually exclusive",
));
}
if positional.is_some() {
return Err(ParseError::Conflict(
"--multi cannot be used with a positional Pod name",
));
}
if socket_seen {
return Err(ParseError::Conflict(
"--multi and --socket are mutually exclusive",
));
}
return Ok(Mode::Multi);
}
if resume && session.is_some() {
return Err(ParseError::Conflict(
"--resume and --session are mutually exclusive",
@ -211,6 +253,7 @@ async fn main() -> ExitCode {
} => run_pod_name(pod_name, socket_override).await,
Mode::Resume => run_resume().await,
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
Mode::Multi => run_multi().await,
};
// Always restore the terminal first so any pending eprintln below
@ -310,6 +353,25 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
run_pod_name(pod_name, socket_override).await
}
async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
let mut terminal = enter_fullscreen()?;
let outcome = multi_pod::run(&mut terminal).await;
let _ = execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
);
match outcome? {
multi_pod::MultiPodOutcome::Quit => Ok(()),
multi_pod::MultiPodOutcome::Open {
pod_name,
socket_override,
} => run_pod_name(pod_name, socket_override).await,
}
}
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from).await? {
SpawnOutcome::Ready(r) => r,
@ -947,6 +1009,54 @@ mod tests {
);
}
#[test]
fn parse_multi_mode() {
match parse_args_from(["--multi"]).unwrap() {
Mode::Multi => {}
_ => panic!("expected Multi mode"),
}
}
#[test]
fn parse_multi_conflicts_are_clear() {
let segment_id = session_store::new_segment_id().to_string();
let cases = [
(
vec!["--multi".to_string(), "--resume".to_string()],
"--multi and --resume are mutually exclusive",
),
(
vec!["--multi".to_string(), "--session".to_string(), segment_id],
"--multi and --session are mutually exclusive",
),
(
vec![
"--multi".to_string(),
"--pod".to_string(),
"agent".to_string(),
],
"--multi and --pod are mutually exclusive",
),
(
vec!["--multi".to_string(), "agent".to_string()],
"--multi cannot be used with a positional Pod name",
),
(
vec![
"--multi".to_string(),
"--socket".to_string(),
"/tmp/a.sock".to_string(),
],
"--multi and --socket are mutually exclusive",
),
];
for (args, message) in cases {
let err = parse_args_from(args).unwrap_err();
assert_eq!(err.to_string(), message);
}
}
#[tokio::test]
async fn terminal_event_is_selected_before_ready_pod_event() {
let (tx, mut rx) = mpsc::unbounded_channel();

1209
crates/tui/src/multi_pod.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,11 @@
//! from the session store's name-keyed metadata. Picking a live row attaches to
//! its socket; picking a stopped row restores via `pod --pod <name>`.
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::time::Duration;
use client::PodClient;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use pod_registry::{LockFileGuard, default_registry_path};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
@ -20,8 +16,12 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::{
FsStore, LogEntry, LoggedContentPart, LoggedItem, PodMetadata, SegmentId, SessionId, Store,
use session_store::FsStore;
use crate::pod_list::{
PodList, PodListEntry, PodVisibilitySource, StoredMetadataState,
live_socket_for_pod as pod_list_live_socket_for_pod, read_reachable_live_pod_infos,
read_stored_pod_infos,
};
const MAX_ROWS: usize = 10;
@ -99,62 +99,45 @@ impl PodRowState {
}
}
/// One row in the Pod picker. The primary key is the Pod name; Session/Segment
/// IDs are included only as debug context.
#[derive(Debug, Clone)]
struct Row {
pod_name: String,
state: PodRowState,
updated_at: u64,
active_session_id: Option<SessionId>,
active_segment_id: Option<SegmentId>,
preview: Option<String>,
socket_path: Option<PathBuf>,
}
#[derive(Debug)]
struct PodStateRecord {
pod_name: String,
state: Result<PodMetadata, String>,
}
#[derive(Debug, Clone)]
pub(crate) struct LivePodRecord {
pub pod_name: String,
pub socket_path: PathBuf,
pub segment_id: Option<SegmentId>,
}
pub async fn run() -> Result<PickerOutcome, PickerError> {
let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?;
let pod_states = read_pod_state_records(&store_dir)?;
let live_pods = read_reachable_live_pod_records().await.unwrap_or_default();
let rows = build_rows(&store, pod_states, live_pods)?;
if rows.is_empty() {
let stored_pods = read_stored_pod_infos(&store_dir, &store)?;
let live_pods = read_reachable_live_pod_infos(&store)
.await
.unwrap_or_default();
let mut list = PodList::from_sources(
PodVisibilitySource::ResumePicker,
stored_pods,
live_pods,
None,
MAX_ROWS,
);
if list.entries.is_empty() {
return Err(PickerError::NoPods);
}
let mut selected = 0usize;
let mut terminal = make_inline_terminal()?;
loop {
terminal.draw(|f| draw(f, &rows, selected))?;
terminal.draw(|f| draw(f, &list))?;
match poll_event()? {
None => continue,
Some(Action::Up) => {
selected = selected.saturating_sub(1);
let selected = list.selected_index().saturating_sub(1);
list.select_index(selected);
}
Some(Action::Down) => {
if selected + 1 < rows.len() {
selected += 1;
let selected = list.selected_index();
if selected + 1 < list.entries.len() {
list.select_index(selected + 1);
}
}
Some(Action::Submit) => {
close_viewport(&mut terminal)?;
let row = &rows[selected];
let entry = list.selected_entry().expect("non-empty pod list");
return Ok(PickerOutcome::Picked {
pod_name: row.pod_name.clone(),
socket_override: row.socket_path.clone(),
pod_name: entry.name.clone(),
socket_override: entry.attach_socket_path().map(PathBuf::from),
});
}
Some(Action::Cancel) => {
@ -189,288 +172,8 @@ fn default_store_dir() -> Result<PathBuf, PickerError> {
})
}
fn read_pod_state_records(store_dir: &Path) -> Result<Vec<PodStateRecord>, PickerError> {
let pods_dir = store_dir.join("pods");
let mut records = Vec::new();
if !pods_dir.exists() {
return Ok(records);
}
for entry in fs::read_dir(pods_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let pod_name = entry.file_name().to_string_lossy().to_string();
let path = entry.path().join("metadata.json");
let state = match fs::read_to_string(&path) {
Ok(content) => serde_json::from_str::<PodMetadata>(&content).map_err(|e| e.to_string()),
Err(e) => Err(e.to_string()),
};
records.push(PodStateRecord { pod_name, state });
}
Ok(records)
}
fn read_live_pod_records() -> Result<Vec<LivePodRecord>, io::Error> {
let path = default_registry_path()?;
let guard = LockFileGuard::open(&path)?;
Ok(guard
.data()
.allocations
.iter()
.map(|allocation| LivePodRecord {
pod_name: allocation.pod_name.clone(),
socket_path: allocation.socket.clone(),
segment_id: allocation.segment_id,
})
.collect())
}
async fn read_reachable_live_pod_records() -> Result<Vec<LivePodRecord>, io::Error> {
let records = read_live_pod_records()?;
let mut reachable = Vec::new();
for record in records {
if PodClient::connect(&record.socket_path).await.is_ok() {
reachable.push(record);
}
}
Ok(reachable)
}
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
read_live_pod_records()
.ok()?
.into_iter()
.find(|pod| pod.pod_name == pod_name)
.map(|pod| pod.socket_path)
}
fn build_rows(
store: &FsStore,
pod_states: Vec<PodStateRecord>,
live_pods: Vec<LivePodRecord>,
) -> Result<Vec<Row>, PickerError> {
let mut rows_by_name: BTreeMap<String, Row> = BTreeMap::new();
let mut live_by_name: HashMap<String, LivePodRecord> = HashMap::new();
for live in live_pods {
let (active_session_id, active_segment_id, updated_at, preview) =
summarize_live_pod(store, &live);
rows_by_name.insert(
live.pod_name.clone(),
Row {
pod_name: live.pod_name.clone(),
state: PodRowState::Live,
updated_at,
active_session_id,
active_segment_id,
preview,
socket_path: Some(live.socket_path.clone()),
},
);
live_by_name.insert(live.pod_name.clone(), live);
}
for record in pod_states {
match record.state {
Ok(metadata) => {
let summary = summarize_metadata(store, &metadata);
let state = if live_by_name.contains_key(&record.pod_name) {
PodRowState::Live
} else {
PodRowState::Stopped
};
upsert_metadata_row(&mut rows_by_name, record.pod_name, metadata, summary, state);
}
Err(message) => {
rows_by_name.entry(record.pod_name.clone()).or_insert(Row {
pod_name: record.pod_name,
state: PodRowState::Corrupt,
updated_at: 0,
active_session_id: None,
active_segment_id: None,
preview: Some(format!("metadata: {}", trim_one_line(&message, 48))),
socket_path: None,
});
}
}
}
let mut rows: Vec<Row> = rows_by_name.into_values().collect();
rows.sort_by(|a, b| {
b.updated_at
.cmp(&a.updated_at)
.then_with(|| a.pod_name.cmp(&b.pod_name))
});
rows.truncate(MAX_ROWS);
Ok(rows)
}
fn upsert_metadata_row(
rows_by_name: &mut BTreeMap<String, Row>,
pod_name: String,
metadata: PodMetadata,
summary: SegmentSummary,
state: PodRowState,
) {
let active = metadata.active;
let active_session_id = active.as_ref().map(|a| a.session_id);
let active_segment_id = active.as_ref().and_then(|a| a.segment_id);
match rows_by_name.get_mut(&pod_name) {
Some(existing) => {
existing.state = state;
if summary.updated_at > existing.updated_at {
existing.updated_at = summary.updated_at;
}
if existing.active_session_id.is_none() {
existing.active_session_id = active_session_id;
}
if existing.active_segment_id.is_none() {
existing.active_segment_id = active_segment_id;
}
if existing.preview.is_none() {
existing.preview = summary.preview;
}
}
None => {
rows_by_name.insert(
pod_name.clone(),
Row {
pod_name,
state,
updated_at: summary.updated_at,
active_session_id,
active_segment_id,
preview: summary.preview,
socket_path: None,
},
);
}
}
}
#[derive(Debug, Clone)]
struct SegmentSummary {
updated_at: u64,
preview: Option<String>,
}
fn summarize_live_pod(
store: &FsStore,
live: &LivePodRecord,
) -> (Option<SessionId>, Option<SegmentId>, u64, Option<String>) {
let Some(segment_id) = live.segment_id else {
return (None, None, 0, None);
};
let session_id = store.lookup_session_of(segment_id).ok().flatten();
let Some(session_id) = session_id else {
return (None, Some(segment_id), 0, None);
};
let summary = summarize_segment(store, session_id, segment_id);
(
Some(session_id),
Some(segment_id),
summary.updated_at,
summary.preview,
)
}
fn summarize_metadata(store: &FsStore, metadata: &PodMetadata) -> SegmentSummary {
let Some(active) = metadata.active.as_ref() else {
return SegmentSummary {
updated_at: 0,
preview: None,
};
};
let Some(segment_id) = active.segment_id else {
return SegmentSummary {
updated_at: 0,
preview: Some("[pending segment]".to_string()),
};
};
summarize_segment(store, active.session_id, segment_id)
}
fn summarize_segment(
store: &FsStore,
session_id: SessionId,
segment_id: SegmentId,
) -> SegmentSummary {
match store.read_all(session_id, segment_id) {
Ok(entries) => SegmentSummary {
updated_at: last_entry_ts(&entries).unwrap_or(0),
preview: last_message_preview(&entries).or_else(|| Some("[empty]".to_string())),
},
Err(_) => SegmentSummary {
updated_at: 0,
preview: Some("[corrupt segment]".to_string()),
},
}
}
fn last_entry_ts(entries: &[LogEntry]) -> Option<u64> {
entries.iter().map(log_entry_ts).max()
}
fn log_entry_ts(entry: &LogEntry) -> u64 {
match entry {
LogEntry::SegmentStart { ts, .. }
| LogEntry::Invoke { ts, .. }
| LogEntry::UserInput { ts, .. }
| LogEntry::AssistantItem { ts, .. }
| LogEntry::ToolResult { ts, .. }
| LogEntry::SystemItem { ts, .. }
| LogEntry::TurnEnd { ts, .. }
| LogEntry::RunCompleted { ts, .. }
| LogEntry::RunErrored { ts, .. }
| LogEntry::ConfigChanged { ts, .. }
| LogEntry::LlmUsage { ts, .. }
| LogEntry::Extension { ts, .. } => *ts,
}
}
/// Walk the log from the tail looking for the most recent user-message or
/// assistant-message entry, then render its first text fragment in a single line.
fn last_message_preview(entries: &[LogEntry]) -> Option<String> {
for entry in entries.iter().rev() {
match entry {
LogEntry::UserInput { segments, .. } => {
let text = protocol::Segment::flatten_to_text(segments);
if !text.is_empty() {
return Some(format!("user: {}", trim_one_line(&text, 60)));
}
}
LogEntry::AssistantItem { item, .. } => {
if let Some(text) = first_text_logged(item) {
return Some(format!("assistant: {}", trim_one_line(&text, 60)));
}
}
_ => {}
}
}
None
}
fn first_text_logged(item: &LoggedItem) -> Option<String> {
match item {
LoggedItem::Message { content, .. } => content.iter().find_map(|p| match p {
LoggedContentPart::Text { text } => Some(text.clone()),
_ => None,
}),
_ => None,
}
}
fn trim_one_line(s: &str, max_chars: usize) -> String {
let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
if collapsed.chars().count() <= max_chars {
collapsed
} else {
let truncated: String = collapsed.chars().take(max_chars - 1).collect();
format!("{truncated}")
}
pod_list_live_socket_for_pod(pod_name)
}
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
@ -512,11 +215,11 @@ fn poll_event() -> io::Result<Option<Action>> {
}
}
fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
fn draw(f: &mut Frame<'_>, list: &PodList) {
let area = f.area();
let mut constraints: Vec<Constraint> = Vec::with_capacity(rows.len() + 3);
let mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3);
constraints.push(Constraint::Length(1)); // title
for _ in rows {
for _ in &list.entries {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // hint
@ -531,8 +234,12 @@ fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
layout[0],
);
for (i, row) in rows.iter().enumerate() {
f.render_widget(Paragraph::new(row_line(row, i == selected)), layout[i + 1]);
let selected = list.selected_index();
for (i, entry) in list.entries.iter().enumerate() {
f.render_widget(
Paragraph::new(row_line(entry, i == selected)),
layout[i + 1],
);
}
f.render_widget(
@ -545,7 +252,7 @@ fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
Span::raw(" cancel"),
])),
layout[rows.len() + 1],
layout[list.entries.len() + 1],
);
}
@ -553,7 +260,7 @@ fn picker_title() -> &'static str {
"resume pod pick a pod"
}
fn row_line(row: &Row, selected: bool) -> Line<'_> {
fn row_line(entry: &PodListEntry, selected: bool) -> Line<'_> {
let marker = if selected { "" } else { " " };
let name_style = if selected {
Style::default()
@ -567,27 +274,44 @@ fn row_line(row: &Row, selected: bool) -> Line<'_> {
} else {
Style::default().fg(Color::DarkGray)
};
let state = row_state(entry);
let _visibility = entry.visibility;
let _source_kinds = &entry.source_kinds;
let mut spans = vec![
Span::raw(marker),
Span::styled(row.pod_name.as_str(), name_style),
Span::styled(entry.name.as_str(), name_style),
Span::raw(" "),
Span::styled(format!("[{}]", row.state.label()), row.state.style()),
Span::styled(format!("[{}]", state.label()), state.style()),
Span::raw(" "),
Span::styled(
format_updated_at(row.updated_at),
format_updated_at(entry.summary.updated_at),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(debug_ids(row), Style::default().fg(Color::DarkGray)),
Span::styled(debug_ids(entry), Style::default().fg(Color::DarkGray)),
];
if let Some(preview) = row.preview.as_ref() {
if let Some(preview) = entry.summary.preview.as_ref() {
spans.push(Span::raw(" "));
spans.push(Span::styled(preview.as_str(), preview_style));
}
Line::from(spans)
}
fn row_state(entry: &PodListEntry) -> PodRowState {
if entry.live.as_ref().is_some_and(|live| live.reachable) {
return PodRowState::Live;
}
if entry
.stored
.as_ref()
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
{
return PodRowState::Corrupt;
}
PodRowState::Stopped
}
fn format_updated_at(updated_at: u64) -> String {
if updated_at == 0 {
"updated: —".to_string()
@ -596,12 +320,14 @@ fn format_updated_at(updated_at: u64) -> String {
}
}
fn debug_ids(row: &Row) -> String {
let session = row
fn debug_ids(entry: &PodListEntry) -> String {
let session = entry
.summary
.active_session_id
.map(short_id)
.unwrap_or_else(|| "--------".to_string());
let segment = row
let segment = entry
.summary
.active_segment_id
.map(short_id)
.unwrap_or_else(|| "--------".to_string());
@ -615,198 +341,9 @@ fn short_id<T: ToString>(id: T) -> String {
#[cfg(test)]
mod tests {
use super::*;
use llm_worker::llm_client::types::RequestConfig;
use session_store::{PodActiveSegmentRef, PodMetadataStore, new_segment_id, new_session_id};
use tempfile::tempdir;
#[test]
fn pod_rows_are_sorted_by_active_segment_timestamp() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let earlier_session = new_session_id();
let later_session = new_session_id();
let earlier_segment = new_segment_id();
let later_segment = new_segment_id();
append_start(&store, earlier_session, earlier_segment, 10);
append_user(
&store,
earlier_session,
earlier_segment,
100,
"old pod update",
);
append_start(&store, later_session, later_segment, 20);
append_user(&store, later_session, later_segment, 200, "new pod update");
let records = vec![
metadata_record("older", earlier_session, earlier_segment),
metadata_record("newer", later_session, later_segment),
];
let rows = build_rows(&store, records, vec![]).unwrap();
assert_eq!(rows[0].pod_name, "newer");
assert_eq!(rows[0].state, PodRowState::Stopped);
assert_eq!(rows[0].updated_at, 200);
assert_eq!(rows[0].preview.as_deref(), Some("user: new pod update"));
assert_eq!(rows[1].pod_name, "older");
}
#[test]
fn pod_rows_include_live_and_stopped_pods() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let stopped_session = new_session_id();
let stopped_segment = new_segment_id();
let live_session = new_session_id();
let live_segment = new_segment_id();
append_start(&store, stopped_session, stopped_segment, 10);
append_user(
&store,
stopped_session,
stopped_segment,
50,
"stopped preview",
);
append_start(&store, live_session, live_segment, 20);
append_user(&store, live_session, live_segment, 70, "live preview");
let rows = build_rows(
&store,
vec![metadata_record("stopped", stopped_session, stopped_segment)],
vec![LivePodRecord {
pod_name: "live".to_string(),
socket_path: PathBuf::from("/tmp/live.sock"),
segment_id: Some(live_segment),
}],
)
.unwrap();
let live = rows.iter().find(|row| row.pod_name == "live").unwrap();
assert_eq!(live.state, PodRowState::Live);
assert_eq!(live.active_session_id, Some(live_session));
assert_eq!(
live.socket_path.as_deref(),
Some(Path::new("/tmp/live.sock"))
);
let stopped = rows.iter().find(|row| row.pod_name == "stopped").unwrap();
assert_eq!(stopped.state, PodRowState::Stopped);
assert_eq!(stopped.socket_path, None);
}
#[test]
fn corrupt_pod_state_is_rendered_as_corrupt_row() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let rows = build_rows(
&store,
vec![PodStateRecord {
pod_name: "broken".to_string(),
state: Err("expected value".to_string()),
}],
vec![],
)
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].pod_name, "broken");
assert_eq!(rows[0].state, PodRowState::Corrupt);
assert!(
rows[0]
.preview
.as_deref()
.unwrap()
.contains("expected value")
);
}
#[test]
fn picker_title_names_pods_not_sessions() {
assert_eq!(picker_title(), "resume pod pick a pod");
}
fn metadata_record(
pod_name: &str,
session_id: SessionId,
segment_id: SegmentId,
) -> PodStateRecord {
PodStateRecord {
pod_name: pod_name.to_string(),
state: Ok(PodMetadata::new(
pod_name,
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
)),
}
}
fn append_start(store: &FsStore, session_id: SessionId, segment_id: SegmentId, ts: u64) {
store
.append(
session_id,
segment_id,
&LogEntry::SegmentStart {
ts,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
)
.unwrap();
}
fn append_user(
store: &FsStore,
session_id: SessionId,
segment_id: SegmentId,
ts: u64,
text: &str,
) {
store
.append(
session_id,
segment_id,
&LogEntry::UserInput {
ts,
segments: vec![protocol::Segment::text(text)],
},
)
.unwrap();
}
#[test]
fn read_pod_state_records_reports_corrupt_metadata() {
let dir = tempdir().unwrap();
let pod_dir = dir.path().join("pods").join("broken");
fs::create_dir_all(&pod_dir).unwrap();
fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
let records = read_pod_state_records(dir.path()).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "broken");
assert!(records[0].state.is_err());
}
#[test]
fn read_pod_state_records_reads_metadata() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
store
.write(&PodMetadata::new(
"agent",
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
))
.unwrap();
let records = read_pod_state_records(dir.path()).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "agent");
assert!(records[0].state.is_ok());
}
}

905
crates/tui/src/pod_list.rs Normal file
View File

@ -0,0 +1,905 @@
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use client::PodClient;
use pod_registry::{LockFileGuard, default_registry_path};
use protocol::{Event, PodStatus};
use session_store::{
FsStore, LogEntry, LoggedContentPart, LoggedItem, PodMetadata, SegmentId, SessionId, Store,
};
#[derive(Debug, Clone)]
pub(crate) struct PodList {
pub entries: Vec<PodListEntry>,
pub selected_name: Option<String>,
}
impl PodList {
pub(crate) fn from_sources(
source: PodVisibilitySource,
stored: Vec<StoredPodInfo>,
live: Vec<LivePodInfo>,
selected_name: Option<String>,
max_entries: usize,
) -> Self {
let mut entries_by_name: BTreeMap<String, PodListEntry> = BTreeMap::new();
for live_info in live {
let name = live_info.pod_name.clone();
entries_by_name
.entry(name.clone())
.or_insert_with(|| PodListEntry::new(name, source))
.merge_live(live_info);
}
for stored_info in stored {
let name = stored_info.pod_name.clone();
entries_by_name
.entry(name.clone())
.or_insert_with(|| PodListEntry::new(name, source))
.merge_stored(stored_info);
}
let mut entries: Vec<PodListEntry> = entries_by_name.into_values().collect();
for entry in &mut entries {
entry.finalize();
}
entries.sort_by(|a, b| {
b.summary
.updated_at
.cmp(&a.summary.updated_at)
.then_with(|| a.name.cmp(&b.name))
});
entries.truncate(max_entries);
let selected_name = selected_name
.filter(|name| entries.iter().any(|entry| entry.name == *name))
.or_else(|| entries.first().map(|entry| entry.name.clone()));
Self {
entries,
selected_name,
}
}
pub(crate) fn selected_index(&self) -> usize {
self.selected_name
.as_ref()
.and_then(|name| self.entries.iter().position(|entry| entry.name == *name))
.unwrap_or(0)
}
pub(crate) fn select_index(&mut self, index: usize) {
self.selected_name = self.entries.get(index).map(|entry| entry.name.clone());
}
pub(crate) fn selected_entry(&self) -> Option<&PodListEntry> {
let index = self.selected_index();
self.entries.get(index)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PodVisibilitySource {
ResumePicker,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PodListSourceKind {
RuntimeRegistry,
StoredMetadata,
}
#[derive(Debug, Clone)]
pub(crate) struct PodListEntry {
pub name: String,
pub visibility: PodVisibilitySource,
pub source_kinds: Vec<PodListSourceKind>,
pub live: Option<LivePodInfo>,
pub stored: Option<StoredPodInfo>,
pub summary: PodEntrySummary,
pub actions: PodEntryActions,
pub diagnostics: Vec<PodEntryDiagnostic>,
}
impl PodListEntry {
fn new(name: String, visibility: PodVisibilitySource) -> Self {
Self {
name,
visibility,
source_kinds: Vec::new(),
live: None,
stored: None,
summary: PodEntrySummary::default(),
actions: PodEntryActions::default(),
diagnostics: Vec::new(),
}
}
fn merge_live(&mut self, live: LivePodInfo) {
if !self
.source_kinds
.contains(&PodListSourceKind::RuntimeRegistry)
{
self.source_kinds.push(PodListSourceKind::RuntimeRegistry);
}
if live.summary.updated_at > self.summary.updated_at {
self.summary.updated_at = live.summary.updated_at;
}
if self.summary.active_session_id.is_none() {
self.summary.active_session_id = live.summary.active_session_id;
}
if self.summary.active_segment_id.is_none() {
self.summary.active_segment_id = live.summary.active_segment_id.or(live.segment_id);
}
if self.summary.preview.is_none() {
self.summary.preview = live.summary.preview.clone();
}
self.live = Some(live);
}
fn merge_stored(&mut self, stored: StoredPodInfo) {
if !self
.source_kinds
.contains(&PodListSourceKind::StoredMetadata)
{
self.source_kinds.push(PodListSourceKind::StoredMetadata);
}
if stored.updated_at > self.summary.updated_at {
self.summary.updated_at = stored.updated_at;
}
if self.summary.active_session_id.is_none() {
self.summary.active_session_id = stored.active_session_id;
}
if self.summary.active_segment_id.is_none() {
self.summary.active_segment_id = stored.active_segment_id;
}
if self.summary.preview.is_none() {
self.summary.preview = stored.preview.clone();
}
self.stored = Some(stored);
}
fn finalize(&mut self) {
self.diagnostics = build_diagnostics(self);
self.actions = build_actions(self);
}
pub(crate) fn attach_socket_path(&self) -> Option<&Path> {
self.live
.as_ref()
.filter(|live| live.reachable)
.map(|live| live.socket_path.as_path())
}
}
#[derive(Debug, Clone)]
pub(crate) struct LivePodInfo {
pub pod_name: String,
pub socket_path: PathBuf,
pub status: Option<PodStatus>,
pub reachable: bool,
pub segment_id: Option<SegmentId>,
pub summary: PodEntrySummary,
}
#[derive(Debug, Clone)]
pub(crate) struct StoredPodInfo {
pub pod_name: String,
pub metadata_state: StoredMetadataState,
pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>,
pub updated_at: u64,
pub preview: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StoredMetadataState {
Present,
Corrupt(String),
}
#[derive(Debug, Clone, Default)]
pub(crate) struct PodEntrySummary {
pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>,
pub updated_at: u64,
pub preview: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct PodEntryActions {
pub can_open: bool,
pub can_restore: bool,
pub can_send_now: bool,
pub can_queue_send: bool,
pub disabled_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PodEntryDiagnostic {
pub kind: PodEntryDiagnosticKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PodEntryDiagnosticKind {
StoredMetadataCorrupt,
LiveUnreachable,
MissingStoredMetadata,
MissingLiveStatus,
}
pub(crate) fn read_stored_pod_infos(
store_dir: &Path,
store: &FsStore,
) -> Result<Vec<StoredPodInfo>, io::Error> {
let pods_dir = store_dir.join("pods");
let mut records = Vec::new();
if !pods_dir.exists() {
return Ok(records);
}
for entry in fs::read_dir(pods_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let pod_name = entry.file_name().to_string_lossy().to_string();
let path = entry.path().join("metadata.json");
let info = match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<PodMetadata>(&content) {
Ok(metadata) => stored_info_from_metadata(store, pod_name, metadata),
Err(e) => corrupt_stored_info(pod_name, e.to_string()),
},
Err(e) => corrupt_stored_info(pod_name, e.to_string()),
};
records.push(info);
}
Ok(records)
}
pub(crate) fn read_live_pod_infos() -> Result<Vec<LivePodInfo>, io::Error> {
let path = default_registry_path()?;
let guard = LockFileGuard::open(&path)?;
Ok(guard
.data()
.allocations
.iter()
.map(|allocation| LivePodInfo {
pod_name: allocation.pod_name.clone(),
socket_path: allocation.socket.clone(),
status: None,
reachable: false,
segment_id: allocation.segment_id,
summary: PodEntrySummary::default(),
})
.collect())
}
pub(crate) async fn read_reachable_live_pod_infos(
store: &FsStore,
) -> Result<Vec<LivePodInfo>, io::Error> {
let records = read_live_pod_infos()?;
let mut reachable = Vec::new();
for mut record in records {
let Ok(status) = probe_live_status(&record.socket_path).await else {
continue;
};
record.reachable = true;
record.status = status;
record.summary = summarize_live_pod(store, &record);
reachable.push(record);
}
Ok(reachable)
}
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
read_live_pod_infos()
.ok()?
.into_iter()
.find(|pod| pod.pod_name == pod_name)
.map(|pod| pod.socket_path)
}
fn stored_info_from_metadata(
store: &FsStore,
pod_name: String,
metadata: PodMetadata,
) -> StoredPodInfo {
let active = metadata.active;
let active_session_id = active.as_ref().map(|a| a.session_id);
let active_segment_id = active.as_ref().and_then(|a| a.segment_id);
let summary = summarize_metadata(store, active.as_ref());
StoredPodInfo {
pod_name,
metadata_state: StoredMetadataState::Present,
active_session_id,
active_segment_id,
updated_at: summary.updated_at,
preview: summary.preview,
}
}
fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo {
StoredPodInfo {
pod_name,
metadata_state: StoredMetadataState::Corrupt(message.clone()),
active_session_id: None,
active_segment_id: None,
updated_at: 0,
preview: Some(format!("metadata: {}", trim_one_line(&message, 48))),
}
}
const LIVE_STATUS_PROBE_TIMEOUT: Duration = Duration::from_millis(25);
async fn probe_live_status(socket_path: &Path) -> Result<Option<PodStatus>, io::Error> {
let mut client = PodClient::connect(socket_path).await?;
let deadline = tokio::time::Instant::now() + LIVE_STATUS_PROBE_TIMEOUT;
loop {
if tokio::time::Instant::now() >= deadline {
return Ok(None);
}
match tokio::time::timeout_at(deadline, client.next_event()).await {
Ok(Some(event)) => {
if let Some(status) = status_from_event(&event) {
return Ok(Some(status));
}
}
Ok(None) | Err(_) => return Ok(None),
}
}
}
fn status_from_event(event: &Event) -> Option<PodStatus> {
match event {
Event::Snapshot { status, .. } | Event::Status { status } => Some(*status),
_ => None,
}
}
#[derive(Debug, Clone)]
struct SegmentSummary {
updated_at: u64,
preview: Option<String>,
}
fn summarize_live_pod(store: &FsStore, live: &LivePodInfo) -> PodEntrySummary {
let Some(segment_id) = live.segment_id else {
return PodEntrySummary::default();
};
let session_id = store.lookup_session_of(segment_id).ok().flatten();
let Some(session_id) = session_id else {
return PodEntrySummary {
active_session_id: None,
active_segment_id: Some(segment_id),
updated_at: 0,
preview: None,
};
};
let summary = summarize_segment(store, session_id, segment_id);
PodEntrySummary {
active_session_id: Some(session_id),
active_segment_id: Some(segment_id),
updated_at: summary.updated_at,
preview: summary.preview,
}
}
fn summarize_metadata(
store: &FsStore,
active: Option<&session_store::PodActiveSegmentRef>,
) -> SegmentSummary {
let Some(active) = active else {
return SegmentSummary {
updated_at: 0,
preview: None,
};
};
let Some(segment_id) = active.segment_id else {
return SegmentSummary {
updated_at: 0,
preview: Some("[pending segment]".to_string()),
};
};
summarize_segment(store, active.session_id, segment_id)
}
fn summarize_segment(
store: &FsStore,
session_id: SessionId,
segment_id: SegmentId,
) -> SegmentSummary {
match store.read_all(session_id, segment_id) {
Ok(entries) => SegmentSummary {
updated_at: last_entry_ts(&entries).unwrap_or(0),
preview: last_message_preview(&entries).or_else(|| Some("[empty]".to_string())),
},
Err(_) => SegmentSummary {
updated_at: 0,
preview: Some("[corrupt segment]".to_string()),
},
}
}
fn last_entry_ts(entries: &[LogEntry]) -> Option<u64> {
entries.iter().map(log_entry_ts).max()
}
fn log_entry_ts(entry: &LogEntry) -> u64 {
match entry {
LogEntry::SegmentStart { ts, .. }
| LogEntry::Invoke { ts, .. }
| LogEntry::UserInput { ts, .. }
| LogEntry::AssistantItem { ts, .. }
| LogEntry::ToolResult { ts, .. }
| LogEntry::SystemItem { ts, .. }
| LogEntry::TurnEnd { ts, .. }
| LogEntry::RunCompleted { ts, .. }
| LogEntry::RunErrored { ts, .. }
| LogEntry::ConfigChanged { ts, .. }
| LogEntry::LlmUsage { ts, .. }
| LogEntry::Extension { ts, .. } => *ts,
}
}
fn last_message_preview(entries: &[LogEntry]) -> Option<String> {
for entry in entries.iter().rev() {
match entry {
LogEntry::UserInput { segments, .. } => {
let text = protocol::Segment::flatten_to_text(segments);
if !text.is_empty() {
return Some(format!("user: {}", trim_one_line(&text, 60)));
}
}
LogEntry::AssistantItem { item, .. } => {
if let Some(text) = first_text_logged(item) {
return Some(format!("assistant: {}", trim_one_line(&text, 60)));
}
}
_ => {}
}
}
None
}
fn first_text_logged(item: &LoggedItem) -> Option<String> {
match item {
LoggedItem::Message { content, .. } => content.iter().find_map(|p| match p {
LoggedContentPart::Text { text } => Some(text.clone()),
_ => None,
}),
_ => None,
}
}
fn build_diagnostics(entry: &PodListEntry) -> Vec<PodEntryDiagnostic> {
let mut diagnostics = Vec::new();
if let Some(stored) = entry.stored.as_ref() {
if let StoredMetadataState::Corrupt(message) = &stored.metadata_state {
diagnostics.push(PodEntryDiagnostic {
kind: PodEntryDiagnosticKind::StoredMetadataCorrupt,
message: format!("metadata: {}", trim_one_line(message, 80)),
});
}
} else if entry.live.is_some() {
diagnostics.push(PodEntryDiagnostic {
kind: PodEntryDiagnosticKind::MissingStoredMetadata,
message: "no stored pod metadata".to_string(),
});
}
if let Some(live) = entry.live.as_ref() {
if !live.reachable {
diagnostics.push(PodEntryDiagnostic {
kind: PodEntryDiagnosticKind::LiveUnreachable,
message: format!("socket unreachable: {}", live.socket_path.display()),
});
} else if live.status.is_none() {
diagnostics.push(PodEntryDiagnostic {
kind: PodEntryDiagnosticKind::MissingLiveStatus,
message: "live pod status was not reported".to_string(),
});
}
}
diagnostics
}
fn build_actions(entry: &PodListEntry) -> PodEntryActions {
let live_reachable = entry.live.as_ref().is_some_and(|live| live.reachable);
let stored_restorable = entry
.stored
.as_ref()
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Present));
let live_status = entry.live.as_ref().and_then(|live| live.status);
let can_restore = stored_restorable && !live_reachable;
let can_open = live_reachable || stored_restorable;
let can_send_now = live_reachable && live_status == Some(PodStatus::Idle);
let can_queue_send = live_reachable && live_status == Some(PodStatus::Running);
let disabled_reason = if can_open {
None
} else if entry.live.is_some() {
Some("live pod is unreachable".to_string())
} else if entry.stored.is_some() {
Some("stored pod metadata is corrupt".to_string())
} else {
Some("no live or stored pod state".to_string())
};
PodEntryActions {
can_open,
can_restore,
can_send_now,
can_queue_send,
disabled_reason,
}
}
fn trim_one_line(s: &str, max_chars: usize) -> String {
let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
if collapsed.chars().count() <= max_chars {
collapsed
} else {
let truncated: String = collapsed.chars().take(max_chars - 1).collect();
format!("{truncated}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use llm_worker::llm_client::types::RequestConfig;
use session_store::{PodActiveSegmentRef, PodMetadataStore, new_segment_id, new_session_id};
use tempfile::tempdir;
const SOURCE: PodVisibilitySource = PodVisibilitySource::ResumePicker;
#[test]
fn pod_list_rows_are_sorted_by_active_segment_timestamp() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let earlier_session = new_session_id();
let later_session = new_session_id();
let earlier_segment = new_segment_id();
let later_segment = new_segment_id();
append_start(&store, earlier_session, earlier_segment, 10);
append_user(
&store,
earlier_session,
earlier_segment,
100,
"old pod update",
);
append_start(&store, later_session, later_segment, 20);
append_user(&store, later_session, later_segment, 200, "new pod update");
let entries = PodList::from_sources(
SOURCE,
vec![
metadata_info(&store, "older", earlier_session, earlier_segment),
metadata_info(&store, "newer", later_session, later_segment),
],
vec![],
None,
10,
)
.entries;
assert_eq!(entries[0].name, "newer");
assert_eq!(entries[0].summary.updated_at, 200);
assert_eq!(
entries[0].summary.preview.as_deref(),
Some("user: new pod update")
);
assert_eq!(entries[1].name, "older");
}
#[test]
fn stored_only_row_can_restore_and_open_but_not_direct_send() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
append_start(&store, session_id, segment_id, 10);
let entry = single_entry(PodList::from_sources(
SOURCE,
vec![metadata_info(&store, "stored", session_id, segment_id)],
vec![],
None,
10,
));
assert_eq!(entry.name, "stored");
assert_eq!(entry.visibility, SOURCE);
assert_eq!(entry.source_kinds, vec![PodListSourceKind::StoredMetadata]);
assert!(entry.live.is_none());
assert!(entry.stored.is_some());
assert!(entry.actions.can_open);
assert!(entry.actions.can_restore);
assert!(!entry.actions.can_send_now);
assert!(!entry.actions.can_queue_send);
}
#[test]
fn live_idle_reachable_row_can_open_and_send_now() {
let entry = single_entry(PodList::from_sources(
SOURCE,
vec![],
vec![live_info("live", PodStatus::Idle)],
None,
10,
));
assert_eq!(entry.name, "live");
assert_eq!(entry.visibility, SOURCE);
assert_eq!(entry.source_kinds, vec![PodListSourceKind::RuntimeRegistry]);
assert!(entry.actions.can_open);
assert!(!entry.actions.can_restore);
assert!(entry.actions.can_send_now);
assert!(!entry.actions.can_queue_send);
assert_eq!(
entry.attach_socket_path(),
Some(Path::new("/tmp/live.sock"))
);
}
#[test]
fn live_running_reachable_row_can_open_but_not_send_now() {
let entry = single_entry(PodList::from_sources(
SOURCE,
vec![],
vec![live_info("live", PodStatus::Running)],
None,
10,
));
assert!(entry.actions.can_open);
assert!(!entry.actions.can_restore);
assert!(!entry.actions.can_send_now);
assert!(entry.actions.can_queue_send);
}
#[test]
fn live_unreachable_row_has_diagnostic_and_cannot_open() {
let mut live = live_info("live", PodStatus::Idle);
live.reachable = false;
live.status = None;
let entry = single_entry(PodList::from_sources(SOURCE, vec![], vec![live], None, 10));
assert!(!entry.actions.can_open);
assert!(!entry.actions.can_restore);
assert!(!entry.actions.can_send_now);
assert!(!entry.actions.can_queue_send);
assert_eq!(
entry.actions.disabled_reason.as_deref(),
Some("live pod is unreachable")
);
assert_eq!(entry.attach_socket_path(), None);
assert!(entry.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PodEntryDiagnosticKind::LiveUnreachable
&& diagnostic.message.contains("/tmp/live.sock")
}));
}
#[test]
fn status_extraction_skips_alert_before_snapshot() {
let events = [
Event::Alert(protocol::Alert {
level: protocol::AlertLevel::Warn,
source: protocol::AlertSource::Pod,
message: "warming up".to_string(),
timestamp_ms: 0,
}),
Event::Snapshot {
entries: vec![],
greeting: test_greeting(),
status: PodStatus::Idle,
},
];
let status = events.iter().find_map(status_from_event);
assert_eq!(status, Some(PodStatus::Idle));
}
#[test]
fn corrupt_stored_metadata_has_diagnostic() {
let entry = single_entry(PodList::from_sources(
SOURCE,
vec![corrupt_stored_info(
"broken".to_string(),
"expected value".to_string(),
)],
vec![],
None,
10,
));
assert_eq!(entry.name, "broken");
assert!(!entry.actions.can_open);
assert!(entry.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PodEntryDiagnosticKind::StoredMetadataCorrupt
&& diagnostic.message.contains("expected value")
}));
assert!(
entry
.summary
.preview
.as_deref()
.unwrap()
.contains("expected value")
);
}
#[test]
fn selected_pod_name_is_kept_after_rebuild() {
let first = PodList::from_sources(
SOURCE,
vec![],
vec![
live_info("alpha", PodStatus::Idle),
live_info("beta", PodStatus::Idle),
],
Some("alpha".to_string()),
10,
);
assert_eq!(first.selected_entry().unwrap().name, "alpha");
let rebuilt = PodList::from_sources(
SOURCE,
vec![],
vec![
live_info_with_updated_at("beta", PodStatus::Idle, 20),
live_info_with_updated_at("alpha", PodStatus::Idle, 10),
],
first.selected_name.clone(),
10,
);
assert_eq!(rebuilt.entries[0].name, "beta");
assert_eq!(rebuilt.selected_entry().unwrap().name, "alpha");
assert_eq!(rebuilt.selected_index(), 1);
}
#[test]
fn read_stored_pod_infos_reports_corrupt_metadata() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let pod_dir = dir.path().join("pods").join("broken");
fs::create_dir_all(&pod_dir).unwrap();
fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
let records = read_stored_pod_infos(dir.path(), &store).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "broken");
assert!(matches!(
records[0].metadata_state,
StoredMetadataState::Corrupt(_)
));
}
#[test]
fn read_stored_pod_infos_reads_metadata() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
store
.write(&PodMetadata::new(
"agent",
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
))
.unwrap();
let records = read_stored_pod_infos(dir.path(), &store).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "agent");
assert_eq!(records[0].metadata_state, StoredMetadataState::Present);
}
fn single_entry(list: PodList) -> PodListEntry {
assert_eq!(list.entries.len(), 1);
list.entries.into_iter().next().unwrap()
}
fn metadata_info(
store: &FsStore,
pod_name: &str,
session_id: SessionId,
segment_id: SegmentId,
) -> StoredPodInfo {
stored_info_from_metadata(
store,
pod_name.to_string(),
PodMetadata::new(
pod_name,
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
),
)
}
fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo {
live_info_with_updated_at(pod_name, status, 0)
}
fn live_info_with_updated_at(
pod_name: &str,
status: PodStatus,
updated_at: u64,
) -> LivePodInfo {
LivePodInfo {
pod_name: pod_name.to_string(),
socket_path: PathBuf::from(format!("/tmp/{pod_name}.sock")),
status: Some(status),
reachable: true,
segment_id: None,
summary: PodEntrySummary {
active_session_id: None,
active_segment_id: None,
updated_at,
preview: None,
},
}
}
fn test_greeting() -> protocol::Greeting {
protocol::Greeting {
pod_name: "live".to_string(),
cwd: "/tmp".to_string(),
provider: "test".to_string(),
model: "test".to_string(),
scope_summary: "test".to_string(),
tools: vec![],
context_window: 0,
context_tokens: 0,
}
}
fn append_start(store: &FsStore, session_id: SessionId, segment_id: SegmentId, ts: u64) {
store
.append(
session_id,
segment_id,
&LogEntry::SegmentStart {
ts,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
)
.unwrap();
}
fn append_user(
store: &FsStore,
session_id: SessionId,
segment_id: SegmentId,
ts: u64,
text: &str,
) {
store
.append(
session_id,
segment_id,
&LogEntry::UserInput {
ts,
segments: vec![protocol::Segment::text(text)],
},
)
.unwrap();
}
}

74
docs/nix.md Normal file
View File

@ -0,0 +1,74 @@
# Nix package
INSOMNIA provides a flake package for installing the user-facing Pod CLI and TUI binaries without relying on a source checkout at runtime.
## Build
From the repository root:
```sh
nix build .#
```
The default package is implemented by `package.nix` and builds the Cargo workspace binaries `pod` and `tui`. The derivation uses the checked-in `Cargo.lock`, so Cargo dependencies are fetched by the normal Nix Rust packaging path instead of by network access during the build.
The package output contains:
- `bin/pod` — Pod CLI / runtime process.
- `bin/tui` — terminal UI.
- `share/insomnia/resources/` — bundled runtime resources, including `resources/prompts/`.
- `share/doc/insomnia/nix.md` — this document.
## Run
After `nix build`:
```sh
./result/bin/pod --help
./result/bin/tui
```
With flakes:
```sh
nix run .#tui
nix run .#pod -- --help
```
`nix run .#` defaults to the TUI.
## Configuration discovery
The Nix package does not put user configuration, sessions, sockets, or other mutable state in the Nix store. The installed binaries keep the same path semantics as non-Nix builds:
| Purpose | Override | `INSOMNIA_HOME` fallback | XDG / default fallback |
| --- | --- | --- | --- |
| User config (`manifest.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` |
| Persistent data (`sessions/`, Pod metadata) | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME` | `$HOME/.insomnia` |
| Runtime state (sockets, lock files, live registry) | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run` | `$XDG_RUNTIME_DIR/insomnia`, then `$HOME/.insomnia/run` |
`INSOMNIA_USER_MANIFEST=<path>` can still be used to select an explicit user manifest for the Pod CLI cascade path. Project manifests are still discovered from `.insomnia/manifest.toml` under the current workspace unless a CLI mode documents otherwise.
## Validation
The package derivation has a credential-free install check that verifies:
- `pod --help` starts successfully.
- `tui` is installed and reaches argument parsing.
- bundled prompt resources and this Nix usage document are present in the output.
For full validation before handing changes to review, run:
```sh
nix build .#
nix flake check
cargo fmt --check
```
This packaging change does not require provider credentials. A Rust `cargo check` is only needed if Rust source or runtime path semantics are changed.
## Known limitations
- The package currently installs the TUI and Pod CLI only; development-only wrappers from `devshell.nix` are not part of the installable package.
- The TUI does not currently expose a conventional `--help` / `--version` CLI path, so the package smoke check uses an argument-parse failure path for the TUI rather than launching an interactive session.
- Bundled resources are installed under `share/insomnia/resources/` for packaging completeness and inspection. Built-in prompt/resource loading remains governed by the existing application code and user/project override rules.

View File

@ -1,5 +1,5 @@
{
description = "A very basic flake";
description = "INSOMNIA agent runtime";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
@ -16,9 +16,22 @@
system:
let
pkgs = nixpkgs.legacyPackages.${system};
insomnia = pkgs.callPackage ./package.nix { };
mkApp = name: description: {
type = "app";
program = "${insomnia}/bin/${name}";
meta.description = description;
};
in
{
packages.default = pkgs.callPackage ./package.nix { };
packages.default = insomnia;
packages.insomnia = insomnia;
apps.default = mkApp "tui" "Run the INSOMNIA terminal UI";
apps.tui = mkApp "tui" "Run the INSOMNIA terminal UI";
apps.pod = mkApp "pod" "Run the INSOMNIA Pod CLI";
checks.default = insomnia;
devShells.default = import ./devshell.nix { inherit pkgs; };
}

View File

@ -0,0 +1,103 @@
{
lib,
stdenv,
rustPlatform,
pkg-config,
openssl,
darwin,
}:
let
srcRoot = ./.;
srcRootString = toString srcRoot;
sourceFilter =
path: type:
let
pathString = toString path;
relPath = lib.removePrefix "${srcRootString}/" pathString;
baseName = baseNameOf pathString;
isExcludedTree = dir: relPath == dir || lib.hasPrefix "${dir}/" relPath;
in
# Keep the package source closure focused on build inputs: exclude VCS/build
# outputs plus local coordination state, generated reports, and child
# worktrees that may live under the repository root during development.
!(
baseName == ".git"
|| baseName == "target"
|| baseName == "result"
|| isExcludedTree ".insomnia"
|| isExcludedTree ".worktree"
|| isExcludedTree "work-items"
|| isExcludedTree "docs/report"
);
in
rustPlatform.buildRustPackage rec {
pname = "insomnia";
version = "0.1.0";
src = lib.cleanSourceWith {
src = srcRoot;
filter = sourceFilter;
};
cargoLock.lockFile = ./Cargo.lock;
strictDeps = true;
nativeBuildInputs = [ pkg-config ];
buildInputs = [
openssl
]
++ lib.optionals stdenv.hostPlatform.isDarwin (
with darwin.apple_sdk.frameworks;
[
CoreFoundation
Security
SystemConfiguration
]
);
cargoBuildFlags = [
"-p"
"pod"
"-p"
"tui"
];
# The package check is a credential-free install smoke check below. Running the
# workspace test suite is intentionally left to cargo-based CI because this
# derivation is scoped to packaging the user-facing binaries.
doCheck = false;
postInstall = ''
install -Dm644 docs/nix.md "$out/share/doc/insomnia/nix.md"
mkdir -p "$out/share/insomnia"
cp -R resources "$out/share/insomnia/resources"
'';
doInstallCheck = true;
installCheckPhase = ''
runHook preInstallCheck
"$out/bin/pod" --help >/dev/null
test -x "$out/bin/tui"
if "$out/bin/tui" --session not-a-uuid 2>tui.err; then
echo "tui unexpectedly accepted an invalid --session value" >&2
exit 1
fi
grep -q "invalid --session UUID" tui.err
test -d "$out/share/insomnia/resources/prompts"
test -f "$out/share/doc/insomnia/nix.md"
runHook postInstallCheck
'';
meta = {
description = "Agentic coding Pod runtime and terminal UI";
license = lib.licenses.mit;
mainProgram = "tui";
platforms = lib.platforms.unix;
};
}

View File

@ -2,12 +2,12 @@
id: 20260527-000012-spawnpod-initial-run-confirmation
slug: spawnpod-initial-run-confirmation
title: SpawnPod: initial Run delivery confirmation
status: open
status: closed
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:12Z
updated_at: 2026-05-27T00:00:12Z
updated_at: 2026-05-28T13:24:48Z
assignee: null
legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
---
@ -37,11 +37,18 @@ legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
同種の問題は child Pod の通知経路でも既に踏んでおり、送信側が write 後にすぐ切断せず、receiver 側の acknowledgement / observable event を待つ形にして解消している。`SpawnPod` の初回 task delivery も同じ性質の race と見なす。
追加確認として、Pod socket server は接続直後に replayed `Alert` と connect-time `Snapshot` を送ってから client `Method` を読む。したがって one-shot / send-only client は初期 event を消化してから Method を送る必要がある。
- `send_run_and_confirm``Method::Run` を送った後に event を読む実装になっており、Snapshot が大きい場合や Run payload が大きい場合に双方向で詰まる余地がある。
- `connect_and_send` / `fetch_history` は既に Snapshot まで drain / read しており、この系統の問題は対策済み。
- `probe_socket` は最初の event だけを見て `Snapshot` でなければ status を取らないため、replayed `Alert` が先に来る live Pod で reachable だが status unknown になる可能性がある。
- `PodClient::connect` は background reader を起動するため、通常の TUI attach / interactive client では初期 Snapshot を詰まらせにくい。
## 方針
`SpawnPod` は child process / socket の起動だけでなく、初回 task が controller に受理され、少なくとも `UserMessage` または `TurnStart` が観測できるまで確認してから成功を返す。
既存の `SendToPod` が使う `send_run_and_confirm` と同等の acknowledgement を `SpawnPod` の初回 task 送信にも適用する。
既存の `SendToPod` / `SpawnPod` が使う run delivery confirmation ロジックを、接続直後の `Alert` / `Snapshot` drain を含む形へ共通化・安全化する。
## 要件
@ -51,8 +58,11 @@ legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
- 初回 task delivery に失敗した場合、process / registry / delegated scope の扱いを明確にする。
- cleanup するか、attach 可能な idle Pod として残すかを実装で決める。
- 少なくとも成功扱いで返さない。
- Server が connection 開始時に `Snapshot` を書く設計と競合しない。
- client 側が snapshot/event を読みながら `Method::Run` ack を待つ形にする。
- Server が connection 開始時に `Alert` / `Snapshot` を書く設計と競合しない。
- client 側が `Alert` / `Snapshot` を読みながら `Method::Run` ack を待つ形にする。
- `send_run_and_confirm` は connect-time `Snapshot` を消化してから `Method::Run` を送る。
- live Pod status probe は replayed `Alert` によって status 取得を落とさない。
- `probe_socket` は first event だけで判断せず、`Snapshot` まで初期 event を読む。
- `SpawnPod` 成功後は、child Pod の metadata が pending でも、初回 run が開始済みであることを確認できる。
- session log materialization のタイミングそのものは別設計でもよい。
- `SendToPod``SpawnPod` の run delivery confirmation ロジックを可能な範囲で共通化する。
@ -61,6 +71,8 @@ legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
- `SpawnPod` が初回 task の受理確認を待つ。
- 初回 task が実行されない race を再現する test または regression test がある。
- connect-time `Alert` / `Snapshot` がある状態でも `send_run_and_confirm` が詰まらず、受理 event を観測する regression test がある。
- `probe_socket` が replayed `Alert` の後の `Snapshot` から status を取得できる regression test がある。
- `SpawnPod` が success を返した後、child Pod が idle pending のまま task 未実行になる状態が起きない。
- delivery timeout / failure 時の error message が人間に分かる。
- `cargo fmt --check` と関連 crate の test が通る。

View File

@ -0,0 +1,84 @@
---
id: 20260527-000012-spawnpod-initial-run-confirmation
slug: spawnpod-initial-run-confirmation
title: SpawnPod: initial Run delivery confirmation
status: closed
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:12Z
updated_at: 2026-05-28T13:24:48Z
assignee: null
legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
---
## Migration reference
- legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
# SpawnPod: initial Run delivery confirmation
## 背景
`SpawnPod` は child Pod を起動し、初回 task を `Method::Run` として送る。しかし、実例として `impl-llm-worker-stream-continuation` を再作成した際、runtime registry / socket / process は生きている一方で、初回 task の session log が materialize されず、Pod は `idle` のままだった。
確認された状態:
- `<runtime-dir>/pods.json` に live allocation がある
- `<runtime-dir>/<pod>/status.json``state: "idle"` と runtime `segment_id` を持つ
- `<insomnia-sessions>/pods/<pod>/metadata.json` は pending segment のまま
- 対応する session / segment `.jsonl` が存在しない
- `ReadPodOutput` は no new assistant text
`SpawnPod` の送信側は `send_run``Method::Run` を write してすぐ切断し、`TurnStart` 等の ack を待っていない。一方 server 側は接続直後に `Snapshot` を書いてから method を読むため、client がすぐ close すると server が snapshot write で失敗し、method を読む前に connection handler が終了する race があり得る。
この場合 `SpawnPod` は成功を返すが、child Pod は初回 task を実行していない。
同種の問題は child Pod の通知経路でも既に踏んでおり、送信側が write 後にすぐ切断せず、receiver 側の acknowledgement / observable event を待つ形にして解消している。`SpawnPod` の初回 task delivery も同じ性質の race と見なす。
追加確認として、Pod socket server は接続直後に replayed `Alert` と connect-time `Snapshot` を送ってから client `Method` を読む。したがって one-shot / send-only client は初期 event を消化してから Method を送る必要がある。
- `send_run_and_confirm``Method::Run` を送った後に event を読む実装になっており、Snapshot が大きい場合や Run payload が大きい場合に双方向で詰まる余地がある。
- `connect_and_send` / `fetch_history` は既に Snapshot まで drain / read しており、この系統の問題は対策済み。
- `probe_socket` は最初の event だけを見て `Snapshot` でなければ status を取らないため、replayed `Alert` が先に来る live Pod で reachable だが status unknown になる可能性がある。
- `PodClient::connect` は background reader を起動するため、通常の TUI attach / interactive client では初期 Snapshot を詰まらせにくい。
## 方針
`SpawnPod` は child process / socket の起動だけでなく、初回 task が controller に受理され、少なくとも `UserMessage` または `TurnStart` が観測できるまで確認してから成功を返す。
既存の `SendToPod` / `SpawnPod` が使う run delivery confirmation ロジックを、接続直後の `Alert` / `Snapshot` drain を含む形へ共通化・安全化する。
## 要件
- `SpawnPod` の初回 task 送信は fire-and-forget にしない。
- `Method::Run` 送信後、`UserMessage` / `TurnStart` / `InvokeStart` など、run が受理されたことを示す event を待つ。
- timeout 時は `SpawnPod` を失敗扱いにする。
- 初回 task delivery に失敗した場合、process / registry / delegated scope の扱いを明確にする。
- cleanup するか、attach 可能な idle Pod として残すかを実装で決める。
- 少なくとも成功扱いで返さない。
- Server が connection 開始時に `Alert` / `Snapshot` を書く設計と競合しない。
- client 側が `Alert` / `Snapshot` を読みながら `Method::Run` ack を待つ形にする。
- `send_run_and_confirm` は connect-time `Snapshot` を消化してから `Method::Run` を送る。
- live Pod status probe は replayed `Alert` によって status 取得を落とさない。
- `probe_socket` は first event だけで判断せず、`Snapshot` まで初期 event を読む。
- `SpawnPod` 成功後は、child Pod の metadata が pending でも、初回 run が開始済みであることを確認できる。
- session log materialization のタイミングそのものは別設計でもよい。
- `SendToPod``SpawnPod` の run delivery confirmation ロジックを可能な範囲で共通化する。
## 完了条件
- `SpawnPod` が初回 task の受理確認を待つ。
- 初回 task が実行されない race を再現する test または regression test がある。
- connect-time `Alert` / `Snapshot` がある状態でも `send_run_and_confirm` が詰まらず、受理 event を観測する regression test がある。
- `probe_socket` が replayed `Alert` の後の `Snapshot` から status を取得できる regression test がある。
- `SpawnPod` が success を返した後、child Pod が idle pending のまま task 未実行になる状態が起きない。
- delivery timeout / failure 時の error message が人間に分かる。
- `cargo fmt --check` と関連 crate の test が通る。
## 範囲外
- `tui -r` picker に live pending Pod を表示する修正。
- session log の SegmentStart materialization 方針変更。
- spawned child Pod panel UI。

View File

@ -0,0 +1,99 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:12Z -->
## Migrated
Migrated from tickets/spawnpod-initial-run-confirmation.md. No legacy review file was present at migration time.
---
<!-- event: close author: hare at: 2026-05-28T13:24:48Z status: closed -->
## Closed
---
id: 20260527-000012-spawnpod-initial-run-confirmation
slug: spawnpod-initial-run-confirmation
title: SpawnPod: initial Run delivery confirmation
status: closed
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:12Z
updated_at: 2026-05-28T13:24:48Z
assignee: null
legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
---
## Migration reference
- legacy_ticket: tickets/spawnpod-initial-run-confirmation.md
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
# SpawnPod: initial Run delivery confirmation
## 背景
`SpawnPod` は child Pod を起動し、初回 task を `Method::Run` として送る。しかし、実例として `impl-llm-worker-stream-continuation` を再作成した際、runtime registry / socket / process は生きている一方で、初回 task の session log が materialize されず、Pod は `idle` のままだった。
確認された状態:
- `<runtime-dir>/pods.json` に live allocation がある
- `<runtime-dir>/<pod>/status.json``state: "idle"` と runtime `segment_id` を持つ
- `<insomnia-sessions>/pods/<pod>/metadata.json` は pending segment のまま
- 対応する session / segment `.jsonl` が存在しない
- `ReadPodOutput` は no new assistant text
`SpawnPod` の送信側は `send_run``Method::Run` を write してすぐ切断し、`TurnStart` 等の ack を待っていない。一方 server 側は接続直後に `Snapshot` を書いてから method を読むため、client がすぐ close すると server が snapshot write で失敗し、method を読む前に connection handler が終了する race があり得る。
この場合 `SpawnPod` は成功を返すが、child Pod は初回 task を実行していない。
同種の問題は child Pod の通知経路でも既に踏んでおり、送信側が write 後にすぐ切断せず、receiver 側の acknowledgement / observable event を待つ形にして解消している。`SpawnPod` の初回 task delivery も同じ性質の race と見なす。
追加確認として、Pod socket server は接続直後に replayed `Alert` と connect-time `Snapshot` を送ってから client `Method` を読む。したがって one-shot / send-only client は初期 event を消化してから Method を送る必要がある。
- `send_run_and_confirm``Method::Run` を送った後に event を読む実装になっており、Snapshot が大きい場合や Run payload が大きい場合に双方向で詰まる余地がある。
- `connect_and_send` / `fetch_history` は既に Snapshot まで drain / read しており、この系統の問題は対策済み。
- `probe_socket` は最初の event だけを見て `Snapshot` でなければ status を取らないため、replayed `Alert` が先に来る live Pod で reachable だが status unknown になる可能性がある。
- `PodClient::connect` は background reader を起動するため、通常の TUI attach / interactive client では初期 Snapshot を詰まらせにくい。
## 方針
`SpawnPod` は child process / socket の起動だけでなく、初回 task が controller に受理され、少なくとも `UserMessage` または `TurnStart` が観測できるまで確認してから成功を返す。
既存の `SendToPod` / `SpawnPod` が使う run delivery confirmation ロジックを、接続直後の `Alert` / `Snapshot` drain を含む形へ共通化・安全化する。
## 要件
- `SpawnPod` の初回 task 送信は fire-and-forget にしない。
- `Method::Run` 送信後、`UserMessage` / `TurnStart` / `InvokeStart` など、run が受理されたことを示す event を待つ。
- timeout 時は `SpawnPod` を失敗扱いにする。
- 初回 task delivery に失敗した場合、process / registry / delegated scope の扱いを明確にする。
- cleanup するか、attach 可能な idle Pod として残すかを実装で決める。
- 少なくとも成功扱いで返さない。
- Server が connection 開始時に `Alert` / `Snapshot` を書く設計と競合しない。
- client 側が `Alert` / `Snapshot` を読みながら `Method::Run` ack を待つ形にする。
- `send_run_and_confirm` は connect-time `Snapshot` を消化してから `Method::Run` を送る。
- live Pod status probe は replayed `Alert` によって status 取得を落とさない。
- `probe_socket` は first event だけで判断せず、`Snapshot` まで初期 event を読む。
- `SpawnPod` 成功後は、child Pod の metadata が pending でも、初回 run が開始済みであることを確認できる。
- session log materialization のタイミングそのものは別設計でもよい。
- `SendToPod``SpawnPod` の run delivery confirmation ロジックを可能な範囲で共通化する。
## 完了条件
- `SpawnPod` が初回 task の受理確認を待つ。
- 初回 task が実行されない race を再現する test または regression test がある。
- connect-time `Alert` / `Snapshot` がある状態でも `send_run_and_confirm` が詰まらず、受理 event を観測する regression test がある。
- `probe_socket` が replayed `Alert` の後の `Snapshot` から status を取得できる regression test がある。
- `SpawnPod` が success を返した後、child Pod が idle pending のまま task 未実行になる状態が起きない。
- delivery timeout / failure 時の error message が人間に分かる。
- `cargo fmt --check` と関連 crate の test が通る。
## 範囲外
- `tui -r` picker に live pending Pod を表示する修正。
- session log の SegmentStart materialization 方針変更。
- spawned child Pod panel UI。
---

View File

@ -0,0 +1,118 @@
---
id: 20260527-000023-multi-pod-view-ui
slug: multi-pod-view-ui
title: Multi-Pod view UI
status: closed
kind: task
priority: P2
labels: [tui, pod]
created_at: 2026-05-27T00:00:23Z
updated_at: 2026-05-28T16:09:01Z
assignee: null
legacy_ticket: null
---
## Background
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
The direction is to make TUI capable of treating multiple Pods as first-class targets instead of forcing the operator to attach/open one Pod at a time before sending input. The main view should be able to show live Pods by status, show stopped Pod history entries, and keep an editable composer available while the user moves selection across Pods.
This ticket is downstream of the shared TUI Pod list/view abstraction. The concrete multi-Pod view requirements should be defined after the common list/view model exists, so this ticket can focus on view switching and interaction policy rather than inventing another Pod list representation.
## Prerequisite
- `20260528-141602-tui-pod-list-view-abstraction`
## CLI entrypoint
- Add `tui --multi` as the explicit entrypoint for the multi-Pod dashboard.
- Do not change `tui -r` / `tui --resume` semantics; those remain the resume picker.
- Do not add a short `-m` alias yet.
- `--multi` conflicts with direct single-Pod/session selectors for this ticket:
- positional pod name
- `--pod <name>`
- `--session <UUID>`
- `-r` / `--resume`
- `--socket`
- Initial selected Pod for `--multi --pod <name>` is out of scope; add it later if the UX needs it.
## Current implementation notes
Current TUI is essentially single-Pod oriented:
- `crates/tui/src/main.rs` starts one `PodClient` and the event loop sends composer input to that attached Pod.
- `App` owns one conversation/history view, one composer, and one local queued-input state for the currently attached Pod.
- The existing picker can list/restore/attach Pods, but choosing an entry transitions the TUI into that Pod rather than keeping a multi-Pod dashboard active.
- Live/stopped Pod discovery already exists around picker/discovery code, and should be reused through the prerequisite abstraction rather than duplicated in this ticket.
Because of this, multi-Pod view should be designed as a new TUI mode/state over the shared Pod list abstraction, not as a small tweak to the current single attached-Pod event loop.
## Desired UX direction
The multi-Pod view should center on a Pod list and a persistent composer:
- Live Pods are grouped or visibly categorized by status.
- waiting / idle Pods: ready to receive input.
- working / running Pods: currently processing; input should not be sent as another immediate `Method::Run` unless the protocol can accept it.
- paused Pods: distinguish from both idle and working.
- Stopped Pods are shown as history/restorable entries.
- They are visible for review/restore/open actions.
- Direct message send is disabled until an explicit restore/attach/create flow exists for that entry.
- The text area/composer remains visible and retains its contents while the selected Pod changes.
- The selected Pod is the current send target.
- The UI must show the target Pod name/status near the composer so a message cannot be sent to the wrong Pod silently.
- Sending to an idle live Pod should be possible without opening/attaching that Pod as the main conversation view.
- Sending should clear the composer only after delivery is accepted or otherwise reported as queued according to the rule below.
- For a working/running Pod, the initial behavior should be conservative.
- Do not blindly issue `Method::Run` and surface `AlreadyRunning` as normal UX.
- Either disable direct send with an actionbar diagnostic, or implement target-specific local queueing that sends when that Pod becomes idle.
- If queueing is implemented, queues must be per-Pod, visibly attached to the target, and should not reuse the current single-Pod composer queue implicitly.
## Requirements
- Add the `tui --multi` CLI entrypoint and reject conflicting single-Pod/session selectors.
- Build on the completed `tui-pod-list-view-abstraction` for row/state/source modeling.
- Add or design a TUI mode for multi-Pod view that can show:
- live idle/waiting Pods.
- live working/running Pods.
- paused Pods.
- stopped/restorable Pod history entries.
- Preserve a composer/text area while the selection changes.
- Support direct send to the selected idle live Pod without switching the whole TUI into that Pod view.
- Delivery must use the same safety expectations as other socket send paths: no fire-and-forget success, and no connect-time `Alert` / `Snapshot` deadlock.
- Failed delivery must leave the text in the composer or an explicit per-target queue.
- Define interaction for non-idle targets.
- running: disabled or per-target queued.
- paused: resume/continue action is separate from normal send unless protocol semantics are explicitly defined.
- stopped: restore/open action is separate from send.
- Keep the single-Pod conversation view available.
- Opening/attaching a selected Pod remains an explicit action.
- Direct send from multi-Pod view must not imply that the selected Pod's full history is now loaded as the main conversation view.
- Avoid host-wide visibility expansion.
- The list source must be explicit and must respect the visibility model decided by the prerequisite ticket.
## Acceptance criteria
- `tui --multi` starts the multi-Pod view, and conflicting CLI argument combinations are rejected with clear errors.
- Multi-Pod view requirements are implemented against the shared Pod list/view abstraction, not a separate list model.
- The view can render live Pods with idle/running/paused distinctions and stopped/restorable history entries.
- A persistent composer remains available while moving selection.
- Sending from the composer targets the selected idle live Pod without opening it as the main conversation view.
- Non-idle and stopped targets have explicit, safe UX behavior.
- Delivery failure does not lose user input.
- The UI clearly indicates the selected send target and status.
- Existing single-Pod TUI attach/resume behavior continues to work.
- Tests cover selection-to-target mapping, disabled/queued non-idle behavior, and composer preservation across selection changes.
- `cargo fmt --check`
- `cargo check -p tui -p client -p pod`
- Relevant focused tests for TUI state/model behavior.
## Out of scope
- Implementing the prerequisite Pod list/view abstraction itself.
- Child Pod panel completion (`20260527-000017-tui-spawned-pod-panel`).
- Host-wide Pod browser.
- Changing Pod visibility, permission, registry, or discovery authority.
- Protocol changes for accepting concurrent user messages while a Pod is already running.
- Native GUI.

View File

@ -0,0 +1,118 @@
---
id: 20260527-000023-multi-pod-view-ui
slug: multi-pod-view-ui
title: Multi-Pod view UI
status: closed
kind: task
priority: P2
labels: [tui, pod]
created_at: 2026-05-27T00:00:23Z
updated_at: 2026-05-28T16:09:01Z
assignee: null
legacy_ticket: null
---
## Background
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
The direction is to make TUI capable of treating multiple Pods as first-class targets instead of forcing the operator to attach/open one Pod at a time before sending input. The main view should be able to show live Pods by status, show stopped Pod history entries, and keep an editable composer available while the user moves selection across Pods.
This ticket is downstream of the shared TUI Pod list/view abstraction. The concrete multi-Pod view requirements should be defined after the common list/view model exists, so this ticket can focus on view switching and interaction policy rather than inventing another Pod list representation.
## Prerequisite
- `20260528-141602-tui-pod-list-view-abstraction`
## CLI entrypoint
- Add `tui --multi` as the explicit entrypoint for the multi-Pod dashboard.
- Do not change `tui -r` / `tui --resume` semantics; those remain the resume picker.
- Do not add a short `-m` alias yet.
- `--multi` conflicts with direct single-Pod/session selectors for this ticket:
- positional pod name
- `--pod <name>`
- `--session <UUID>`
- `-r` / `--resume`
- `--socket`
- Initial selected Pod for `--multi --pod <name>` is out of scope; add it later if the UX needs it.
## Current implementation notes
Current TUI is essentially single-Pod oriented:
- `crates/tui/src/main.rs` starts one `PodClient` and the event loop sends composer input to that attached Pod.
- `App` owns one conversation/history view, one composer, and one local queued-input state for the currently attached Pod.
- The existing picker can list/restore/attach Pods, but choosing an entry transitions the TUI into that Pod rather than keeping a multi-Pod dashboard active.
- Live/stopped Pod discovery already exists around picker/discovery code, and should be reused through the prerequisite abstraction rather than duplicated in this ticket.
Because of this, multi-Pod view should be designed as a new TUI mode/state over the shared Pod list abstraction, not as a small tweak to the current single attached-Pod event loop.
## Desired UX direction
The multi-Pod view should center on a Pod list and a persistent composer:
- Live Pods are grouped or visibly categorized by status.
- waiting / idle Pods: ready to receive input.
- working / running Pods: currently processing; input should not be sent as another immediate `Method::Run` unless the protocol can accept it.
- paused Pods: distinguish from both idle and working.
- Stopped Pods are shown as history/restorable entries.
- They are visible for review/restore/open actions.
- Direct message send is disabled until an explicit restore/attach/create flow exists for that entry.
- The text area/composer remains visible and retains its contents while the selected Pod changes.
- The selected Pod is the current send target.
- The UI must show the target Pod name/status near the composer so a message cannot be sent to the wrong Pod silently.
- Sending to an idle live Pod should be possible without opening/attaching that Pod as the main conversation view.
- Sending should clear the composer only after delivery is accepted or otherwise reported as queued according to the rule below.
- For a working/running Pod, the initial behavior should be conservative.
- Do not blindly issue `Method::Run` and surface `AlreadyRunning` as normal UX.
- Either disable direct send with an actionbar diagnostic, or implement target-specific local queueing that sends when that Pod becomes idle.
- If queueing is implemented, queues must be per-Pod, visibly attached to the target, and should not reuse the current single-Pod composer queue implicitly.
## Requirements
- Add the `tui --multi` CLI entrypoint and reject conflicting single-Pod/session selectors.
- Build on the completed `tui-pod-list-view-abstraction` for row/state/source modeling.
- Add or design a TUI mode for multi-Pod view that can show:
- live idle/waiting Pods.
- live working/running Pods.
- paused Pods.
- stopped/restorable Pod history entries.
- Preserve a composer/text area while the selection changes.
- Support direct send to the selected idle live Pod without switching the whole TUI into that Pod view.
- Delivery must use the same safety expectations as other socket send paths: no fire-and-forget success, and no connect-time `Alert` / `Snapshot` deadlock.
- Failed delivery must leave the text in the composer or an explicit per-target queue.
- Define interaction for non-idle targets.
- running: disabled or per-target queued.
- paused: resume/continue action is separate from normal send unless protocol semantics are explicitly defined.
- stopped: restore/open action is separate from send.
- Keep the single-Pod conversation view available.
- Opening/attaching a selected Pod remains an explicit action.
- Direct send from multi-Pod view must not imply that the selected Pod's full history is now loaded as the main conversation view.
- Avoid host-wide visibility expansion.
- The list source must be explicit and must respect the visibility model decided by the prerequisite ticket.
## Acceptance criteria
- `tui --multi` starts the multi-Pod view, and conflicting CLI argument combinations are rejected with clear errors.
- Multi-Pod view requirements are implemented against the shared Pod list/view abstraction, not a separate list model.
- The view can render live Pods with idle/running/paused distinctions and stopped/restorable history entries.
- A persistent composer remains available while moving selection.
- Sending from the composer targets the selected idle live Pod without opening it as the main conversation view.
- Non-idle and stopped targets have explicit, safe UX behavior.
- Delivery failure does not lose user input.
- The UI clearly indicates the selected send target and status.
- Existing single-Pod TUI attach/resume behavior continues to work.
- Tests cover selection-to-target mapping, disabled/queued non-idle behavior, and composer preservation across selection changes.
- `cargo fmt --check`
- `cargo check -p tui -p client -p pod`
- Relevant focused tests for TUI state/model behavior.
## Out of scope
- Implementing the prerequisite Pod list/view abstraction itself.
- Child Pod panel completion (`20260527-000017-tui-spawned-pod-panel`).
- Host-wide Pod browser.
- Changing Pod visibility, permission, registry, or discovery authority.
- Protocol changes for accepting concurrent user messages while a Pod is already running.
- Native GUI.

View File

@ -0,0 +1,133 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:23Z -->
## Migrated
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
---
<!-- event: close author: hare at: 2026-05-28T16:09:01Z status: closed -->
## Closed
---
id: 20260527-000023-multi-pod-view-ui
slug: multi-pod-view-ui
title: Multi-Pod view UI
status: closed
kind: task
priority: P2
labels: [tui, pod]
created_at: 2026-05-27T00:00:23Z
updated_at: 2026-05-28T16:09:01Z
assignee: null
legacy_ticket: null
---
## Background
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
The direction is to make TUI capable of treating multiple Pods as first-class targets instead of forcing the operator to attach/open one Pod at a time before sending input. The main view should be able to show live Pods by status, show stopped Pod history entries, and keep an editable composer available while the user moves selection across Pods.
This ticket is downstream of the shared TUI Pod list/view abstraction. The concrete multi-Pod view requirements should be defined after the common list/view model exists, so this ticket can focus on view switching and interaction policy rather than inventing another Pod list representation.
## Prerequisite
- `20260528-141602-tui-pod-list-view-abstraction`
## CLI entrypoint
- Add `tui --multi` as the explicit entrypoint for the multi-Pod dashboard.
- Do not change `tui -r` / `tui --resume` semantics; those remain the resume picker.
- Do not add a short `-m` alias yet.
- `--multi` conflicts with direct single-Pod/session selectors for this ticket:
- positional pod name
- `--pod <name>`
- `--session <UUID>`
- `-r` / `--resume`
- `--socket`
- Initial selected Pod for `--multi --pod <name>` is out of scope; add it later if the UX needs it.
## Current implementation notes
Current TUI is essentially single-Pod oriented:
- `crates/tui/src/main.rs` starts one `PodClient` and the event loop sends composer input to that attached Pod.
- `App` owns one conversation/history view, one composer, and one local queued-input state for the currently attached Pod.
- The existing picker can list/restore/attach Pods, but choosing an entry transitions the TUI into that Pod rather than keeping a multi-Pod dashboard active.
- Live/stopped Pod discovery already exists around picker/discovery code, and should be reused through the prerequisite abstraction rather than duplicated in this ticket.
Because of this, multi-Pod view should be designed as a new TUI mode/state over the shared Pod list abstraction, not as a small tweak to the current single attached-Pod event loop.
## Desired UX direction
The multi-Pod view should center on a Pod list and a persistent composer:
- Live Pods are grouped or visibly categorized by status.
- waiting / idle Pods: ready to receive input.
- working / running Pods: currently processing; input should not be sent as another immediate `Method::Run` unless the protocol can accept it.
- paused Pods: distinguish from both idle and working.
- Stopped Pods are shown as history/restorable entries.
- They are visible for review/restore/open actions.
- Direct message send is disabled until an explicit restore/attach/create flow exists for that entry.
- The text area/composer remains visible and retains its contents while the selected Pod changes.
- The selected Pod is the current send target.
- The UI must show the target Pod name/status near the composer so a message cannot be sent to the wrong Pod silently.
- Sending to an idle live Pod should be possible without opening/attaching that Pod as the main conversation view.
- Sending should clear the composer only after delivery is accepted or otherwise reported as queued according to the rule below.
- For a working/running Pod, the initial behavior should be conservative.
- Do not blindly issue `Method::Run` and surface `AlreadyRunning` as normal UX.
- Either disable direct send with an actionbar diagnostic, or implement target-specific local queueing that sends when that Pod becomes idle.
- If queueing is implemented, queues must be per-Pod, visibly attached to the target, and should not reuse the current single-Pod composer queue implicitly.
## Requirements
- Add the `tui --multi` CLI entrypoint and reject conflicting single-Pod/session selectors.
- Build on the completed `tui-pod-list-view-abstraction` for row/state/source modeling.
- Add or design a TUI mode for multi-Pod view that can show:
- live idle/waiting Pods.
- live working/running Pods.
- paused Pods.
- stopped/restorable Pod history entries.
- Preserve a composer/text area while the selection changes.
- Support direct send to the selected idle live Pod without switching the whole TUI into that Pod view.
- Delivery must use the same safety expectations as other socket send paths: no fire-and-forget success, and no connect-time `Alert` / `Snapshot` deadlock.
- Failed delivery must leave the text in the composer or an explicit per-target queue.
- Define interaction for non-idle targets.
- running: disabled or per-target queued.
- paused: resume/continue action is separate from normal send unless protocol semantics are explicitly defined.
- stopped: restore/open action is separate from send.
- Keep the single-Pod conversation view available.
- Opening/attaching a selected Pod remains an explicit action.
- Direct send from multi-Pod view must not imply that the selected Pod's full history is now loaded as the main conversation view.
- Avoid host-wide visibility expansion.
- The list source must be explicit and must respect the visibility model decided by the prerequisite ticket.
## Acceptance criteria
- `tui --multi` starts the multi-Pod view, and conflicting CLI argument combinations are rejected with clear errors.
- Multi-Pod view requirements are implemented against the shared Pod list/view abstraction, not a separate list model.
- The view can render live Pods with idle/running/paused distinctions and stopped/restorable history entries.
- A persistent composer remains available while moving selection.
- Sending from the composer targets the selected idle live Pod without opening it as the main conversation view.
- Non-idle and stopped targets have explicit, safe UX behavior.
- Delivery failure does not lose user input.
- The UI clearly indicates the selected send target and status.
- Existing single-Pod TUI attach/resume behavior continues to work.
- Tests cover selection-to-target mapping, disabled/queued non-idle behavior, and composer preservation across selection changes.
- `cargo fmt --check`
- `cargo check -p tui -p client -p pod`
- Relevant focused tests for TUI state/model behavior.
## Out of scope
- Implementing the prerequisite Pod list/view abstraction itself.
- Child Pod panel completion (`20260527-000017-tui-spawned-pod-panel`).
- Host-wide Pod browser.
- Changing Pod visibility, permission, registry, or discovery authority.
- Protocol changes for accepting concurrent user messages while a Pod is already running.
- Native GUI.
---

View File

@ -0,0 +1,167 @@
---
id: 20260528-141602-tui-pod-list-view-abstraction
slug: tui-pod-list-view-abstraction
title: TUI Pod list/view abstraction
status: closed
kind: task
priority: P2
labels: [tui, pod, architecture]
created_at: 2026-05-28T14:16:02Z
updated_at: 2026-05-28T15:40:30Z
assignee: null
legacy_ticket: null
---
## Background
TUI で扱う Pod 関連 UI は、少なくとも次の二つの後続 ticket から使われる。
- `20260527-000017-tui-spawned-pod-panel`: spawned child Pod の一覧と一時 attach。
- `20260527-000023-multi-pod-view-ui`: 複数 Pod view を行き来する UI。
両者は表示対象や操作範囲が異なる一方で、Pod の一覧取得、status 表示、visible / attachable 判定、row 表示、選択状態、view 切り替えの土台を共有する。これを各 ticket が個別に実装すると、TUI 内で Pod list / picker / view 管理が重複し、visibility model や attach 診断がずれやすい。
まず TUI 内で用いる複数 Pod の list/view model を抽象化し、後続 UI が同じ情報構造と操作プリミティブを使える状態にする。
## Design direction
Trait 階層ではなく、source ごとの data struct を name-keyed に合成した UI model を採用する。
- `StoredPod` / `LivingPod` trait は作らない。
- `LivePodInfo``StoredPodInfo` は plain data struct として扱う。
- UI は `Vec<PodListEntry>` / `PodList` を読む。
- `PodListEntry` は Pod name を primary key として、`live: Option<LivePodInfo>` と `stored: Option<StoredPodInfo>` を合成した normalized row にする。
- live / stored は排他的ではない。
- 起動中かつ stored metadata がある Pod。
- 起動中だが durable metadata / segment がまだ薄い pending Pod。
- stopped で stored metadata だけある Pod。
- stored metadata が壊れている Pod。
- registry にはあるが socket unreachable な Pod。
これらを enum の継承的分類へ押し込めず、entry の合成状態として扱う。
この ticket で抽象化するのは list/read/merge/selection/action eligibility の土台まで。`Method::Run` の送信、attach、restore の実行そのものは入れない。
## Requirement
- TUI crate 内に Pod list 用 module を用意する。
- 推奨名: `crates/tui/src/pod_list.rs`
- 既存 picker の private `Row` / `PodRowState` / `LivePodRecord` / `build_rows` / metadata + registry + session summary 読み取りを、この module の model / builder へ寄せる。
- TUI が Pod 一覧 UI を構成するための共通 model / state / helper を用意する。
- `PodList`
- `PodListEntry`
- `LivePodInfo`
- `StoredPodInfo`
- `PodVisibilitySource`
- `PodEntryActions` または同等の action eligibility model
- selection stateindex だけでなく Pod name を primary identity として維持できること)
- `PodListEntry` は表示情報と action eligibility を持つ。
- Pod name
- source / visibility kind例: resume picker, current parent spawned child, future multi-view target
- live reachability / `PodStatus`
- socket path / attach target
- stored active session / segment id
- updated time / preview
- stopped / unreachable / missing state / corrupt metadata の診断情報
- `can_open`
- `can_restore`
- `can_send_now`
- `can_queue_send`
- disabled reason / diagnostic
- direct send 自体はこの ticket の範囲外だが、multi-pod view が send target 判定に使える情報は model に含める。
- live + reachable + `PodStatus::Idle` なら `can_send_now`
- running は send disabled または future queue eligible として区別できる。
- stopped は restore/open 可能だが direct send は不可。
- `tui -r` picker は新しい `PodList` / `PodListEntry` を最初の consumer として使う。
- picker の見た目・key binding・attach/restore outcome は変えない。
- existing picker-specific rendering は残してよいが、row data source は共有 model に寄せる。
- list row rendering / selection / refresh の責務境界を整理する。
- TUI widget は表示と選択に寄せる。
- Pod discovery / client protocol / registry state / session summary の取得詳細を UI 表示ロジックへ直接散らさない。
- child Pod panel と multi-Pod view UI が同じ抽象を使える設計にする。
- visibility model は変えない。
- host-wide Pod browser を新設しない。
- `tui -r` は既存 resume picker 相当の source だけを扱う。
- spawned child panel は current parent から見える child Pod のみを対象にする後続 consumer として想定する。
- multi-Pod view UI も、具体要件が決まるまではこの抽象に新しい可視範囲を勝手に足さない。
- 既存の `ListPods` / `ReadPodOutput` / `SendToPod` / `StopPod` tool semantics は変えない。
- 既存の TUI resume picker / attach flow を壊さない。
## Suggested model sketch
Exact names may differ, but implementation should keep this shape simple and data-oriented.
```rust
pub struct PodList {
pub entries: Vec<PodListEntry>,
pub selected_name: Option<String>,
}
pub struct PodListEntry {
pub name: String,
pub source: PodVisibilitySource,
pub live: Option<LivePodInfo>,
pub stored: Option<StoredPodInfo>,
pub summary: PodEntrySummary,
pub actions: PodEntryActions,
pub diagnostics: Vec<PodEntryDiagnostic>,
}
pub struct LivePodInfo {
pub socket_path: PathBuf,
pub status: Option<PodStatus>,
pub reachable: bool,
pub segment_id: Option<SegmentId>,
}
pub struct StoredPodInfo {
pub metadata_state: StoredMetadataState,
pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>,
pub updated_at: Option<u64>,
pub preview: Option<String>,
}
pub struct PodEntryActions {
pub can_open: bool,
pub can_restore: bool,
pub can_send_now: bool,
pub can_queue_send: bool,
pub disabled_reason: Option<String>,
}
```
## Acceptance criteria
- TUI crate 内に、複数 Pod list/view UI で再利用できる typed abstraction がある。
- 既存 `tui -r` picker が、その abstraction を使って rows を構成する。
- spawned child Pod list と multi-Pod view UI の後続実装が、その abstraction を使う前提で説明できる。
- Pod row の status / reachability / attach target / diagnostic 表示に必要な情報が一箇所の model にまとまっている。
- visibility scope は caller が明示的に渡すか、source kind として表現され、UI helper が host-wide enumeration を暗黙に行わない。
- selection は refresh 後も Pod name を primary identity として維持できる。
- unit test で以下が確認されている。
- stored only row は restore/open 可能で direct send 不可。
- live idle reachable row は open/attach 可能かつ direct send 可能。
- live running reachable row は attach 可能だが direct send 可能とは扱わない。
- corrupt stored metadata は diagnostic を持つ。
- rows refresh / rebuild 後に selected Pod name が維持される。
- 既存 picker / attach 関連テストが通る。
- `cargo fmt --check`
- `cargo check -p tui -p client -p pod`
- 必要に応じて `cargo test -p tui -p pod -p protocol`
## Relationship
This is a prerequisite for:
- `20260527-000017-tui-spawned-pod-panel`
- `20260527-000023-multi-pod-view-ui`
## Out of scope
- spawned child Pod panel の完成。
- 複数 Pod view UI の完成。
- child Pod への interactive input。
- multi-Pod view からの direct send 実行。
- host-wide Pod browser。
- Pod discovery / permission / registry visibility model の変更。
- native GUI。

View File

@ -0,0 +1,167 @@
---
id: 20260528-141602-tui-pod-list-view-abstraction
slug: tui-pod-list-view-abstraction
title: TUI Pod list/view abstraction
status: closed
kind: task
priority: P2
labels: [tui, pod, architecture]
created_at: 2026-05-28T14:16:02Z
updated_at: 2026-05-28T15:40:30Z
assignee: null
legacy_ticket: null
---
## Background
TUI で扱う Pod 関連 UI は、少なくとも次の二つの後続 ticket から使われる。
- `20260527-000017-tui-spawned-pod-panel`: spawned child Pod の一覧と一時 attach。
- `20260527-000023-multi-pod-view-ui`: 複数 Pod view を行き来する UI。
両者は表示対象や操作範囲が異なる一方で、Pod の一覧取得、status 表示、visible / attachable 判定、row 表示、選択状態、view 切り替えの土台を共有する。これを各 ticket が個別に実装すると、TUI 内で Pod list / picker / view 管理が重複し、visibility model や attach 診断がずれやすい。
まず TUI 内で用いる複数 Pod の list/view model を抽象化し、後続 UI が同じ情報構造と操作プリミティブを使える状態にする。
## Design direction
Trait 階層ではなく、source ごとの data struct を name-keyed に合成した UI model を採用する。
- `StoredPod` / `LivingPod` trait は作らない。
- `LivePodInfo``StoredPodInfo` は plain data struct として扱う。
- UI は `Vec<PodListEntry>` / `PodList` を読む。
- `PodListEntry` は Pod name を primary key として、`live: Option<LivePodInfo>` と `stored: Option<StoredPodInfo>` を合成した normalized row にする。
- live / stored は排他的ではない。
- 起動中かつ stored metadata がある Pod。
- 起動中だが durable metadata / segment がまだ薄い pending Pod。
- stopped で stored metadata だけある Pod。
- stored metadata が壊れている Pod。
- registry にはあるが socket unreachable な Pod。
これらを enum の継承的分類へ押し込めず、entry の合成状態として扱う。
この ticket で抽象化するのは list/read/merge/selection/action eligibility の土台まで。`Method::Run` の送信、attach、restore の実行そのものは入れない。
## Requirement
- TUI crate 内に Pod list 用 module を用意する。
- 推奨名: `crates/tui/src/pod_list.rs`
- 既存 picker の private `Row` / `PodRowState` / `LivePodRecord` / `build_rows` / metadata + registry + session summary 読み取りを、この module の model / builder へ寄せる。
- TUI が Pod 一覧 UI を構成するための共通 model / state / helper を用意する。
- `PodList`
- `PodListEntry`
- `LivePodInfo`
- `StoredPodInfo`
- `PodVisibilitySource`
- `PodEntryActions` または同等の action eligibility model
- selection stateindex だけでなく Pod name を primary identity として維持できること)
- `PodListEntry` は表示情報と action eligibility を持つ。
- Pod name
- source / visibility kind例: resume picker, current parent spawned child, future multi-view target
- live reachability / `PodStatus`
- socket path / attach target
- stored active session / segment id
- updated time / preview
- stopped / unreachable / missing state / corrupt metadata の診断情報
- `can_open`
- `can_restore`
- `can_send_now`
- `can_queue_send`
- disabled reason / diagnostic
- direct send 自体はこの ticket の範囲外だが、multi-pod view が send target 判定に使える情報は model に含める。
- live + reachable + `PodStatus::Idle` なら `can_send_now`
- running は send disabled または future queue eligible として区別できる。
- stopped は restore/open 可能だが direct send は不可。
- `tui -r` picker は新しい `PodList` / `PodListEntry` を最初の consumer として使う。
- picker の見た目・key binding・attach/restore outcome は変えない。
- existing picker-specific rendering は残してよいが、row data source は共有 model に寄せる。
- list row rendering / selection / refresh の責務境界を整理する。
- TUI widget は表示と選択に寄せる。
- Pod discovery / client protocol / registry state / session summary の取得詳細を UI 表示ロジックへ直接散らさない。
- child Pod panel と multi-Pod view UI が同じ抽象を使える設計にする。
- visibility model は変えない。
- host-wide Pod browser を新設しない。
- `tui -r` は既存 resume picker 相当の source だけを扱う。
- spawned child panel は current parent から見える child Pod のみを対象にする後続 consumer として想定する。
- multi-Pod view UI も、具体要件が決まるまではこの抽象に新しい可視範囲を勝手に足さない。
- 既存の `ListPods` / `ReadPodOutput` / `SendToPod` / `StopPod` tool semantics は変えない。
- 既存の TUI resume picker / attach flow を壊さない。
## Suggested model sketch
Exact names may differ, but implementation should keep this shape simple and data-oriented.
```rust
pub struct PodList {
pub entries: Vec<PodListEntry>,
pub selected_name: Option<String>,
}
pub struct PodListEntry {
pub name: String,
pub source: PodVisibilitySource,
pub live: Option<LivePodInfo>,
pub stored: Option<StoredPodInfo>,
pub summary: PodEntrySummary,
pub actions: PodEntryActions,
pub diagnostics: Vec<PodEntryDiagnostic>,
}
pub struct LivePodInfo {
pub socket_path: PathBuf,
pub status: Option<PodStatus>,
pub reachable: bool,
pub segment_id: Option<SegmentId>,
}
pub struct StoredPodInfo {
pub metadata_state: StoredMetadataState,
pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>,
pub updated_at: Option<u64>,
pub preview: Option<String>,
}
pub struct PodEntryActions {
pub can_open: bool,
pub can_restore: bool,
pub can_send_now: bool,
pub can_queue_send: bool,
pub disabled_reason: Option<String>,
}
```
## Acceptance criteria
- TUI crate 内に、複数 Pod list/view UI で再利用できる typed abstraction がある。
- 既存 `tui -r` picker が、その abstraction を使って rows を構成する。
- spawned child Pod list と multi-Pod view UI の後続実装が、その abstraction を使う前提で説明できる。
- Pod row の status / reachability / attach target / diagnostic 表示に必要な情報が一箇所の model にまとまっている。
- visibility scope は caller が明示的に渡すか、source kind として表現され、UI helper が host-wide enumeration を暗黙に行わない。
- selection は refresh 後も Pod name を primary identity として維持できる。
- unit test で以下が確認されている。
- stored only row は restore/open 可能で direct send 不可。
- live idle reachable row は open/attach 可能かつ direct send 可能。
- live running reachable row は attach 可能だが direct send 可能とは扱わない。
- corrupt stored metadata は diagnostic を持つ。
- rows refresh / rebuild 後に selected Pod name が維持される。
- 既存 picker / attach 関連テストが通る。
- `cargo fmt --check`
- `cargo check -p tui -p client -p pod`
- 必要に応じて `cargo test -p tui -p pod -p protocol`
## Relationship
This is a prerequisite for:
- `20260527-000017-tui-spawned-pod-panel`
- `20260527-000023-multi-pod-view-ui`
## Out of scope
- spawned child Pod panel の完成。
- 複数 Pod view UI の完成。
- child Pod への interactive input。
- multi-Pod view からの direct send 実行。
- host-wide Pod browser。
- Pod discovery / permission / registry visibility model の変更。
- native GUI。

View File

@ -0,0 +1,182 @@
<!-- event: create author: tickets.sh at: 2026-05-28T14:16:02Z -->
## Created
Created by tickets.sh create.
---
<!-- event: close author: hare at: 2026-05-28T15:40:30Z status: closed -->
## Closed
---
id: 20260528-141602-tui-pod-list-view-abstraction
slug: tui-pod-list-view-abstraction
title: TUI Pod list/view abstraction
status: closed
kind: task
priority: P2
labels: [tui, pod, architecture]
created_at: 2026-05-28T14:16:02Z
updated_at: 2026-05-28T15:40:30Z
assignee: null
legacy_ticket: null
---
## Background
TUI で扱う Pod 関連 UI は、少なくとも次の二つの後続 ticket から使われる。
- `20260527-000017-tui-spawned-pod-panel`: spawned child Pod の一覧と一時 attach。
- `20260527-000023-multi-pod-view-ui`: 複数 Pod view を行き来する UI。
両者は表示対象や操作範囲が異なる一方で、Pod の一覧取得、status 表示、visible / attachable 判定、row 表示、選択状態、view 切り替えの土台を共有する。これを各 ticket が個別に実装すると、TUI 内で Pod list / picker / view 管理が重複し、visibility model や attach 診断がずれやすい。
まず TUI 内で用いる複数 Pod の list/view model を抽象化し、後続 UI が同じ情報構造と操作プリミティブを使える状態にする。
## Design direction
Trait 階層ではなく、source ごとの data struct を name-keyed に合成した UI model を採用する。
- `StoredPod` / `LivingPod` trait は作らない。
- `LivePodInfo``StoredPodInfo` は plain data struct として扱う。
- UI は `Vec<PodListEntry>` / `PodList` を読む。
- `PodListEntry` は Pod name を primary key として、`live: Option<LivePodInfo>` と `stored: Option<StoredPodInfo>` を合成した normalized row にする。
- live / stored は排他的ではない。
- 起動中かつ stored metadata がある Pod。
- 起動中だが durable metadata / segment がまだ薄い pending Pod。
- stopped で stored metadata だけある Pod。
- stored metadata が壊れている Pod。
- registry にはあるが socket unreachable な Pod。
これらを enum の継承的分類へ押し込めず、entry の合成状態として扱う。
この ticket で抽象化するのは list/read/merge/selection/action eligibility の土台まで。`Method::Run` の送信、attach、restore の実行そのものは入れない。
## Requirement
- TUI crate 内に Pod list 用 module を用意する。
- 推奨名: `crates/tui/src/pod_list.rs`
- 既存 picker の private `Row` / `PodRowState` / `LivePodRecord` / `build_rows` / metadata + registry + session summary 読み取りを、この module の model / builder へ寄せる。
- TUI が Pod 一覧 UI を構成するための共通 model / state / helper を用意する。
- `PodList`
- `PodListEntry`
- `LivePodInfo`
- `StoredPodInfo`
- `PodVisibilitySource`
- `PodEntryActions` または同等の action eligibility model
- selection stateindex だけでなく Pod name を primary identity として維持できること)
- `PodListEntry` は表示情報と action eligibility を持つ。
- Pod name
- source / visibility kind例: resume picker, current parent spawned child, future multi-view target
- live reachability / `PodStatus`
- socket path / attach target
- stored active session / segment id
- updated time / preview
- stopped / unreachable / missing state / corrupt metadata の診断情報
- `can_open`
- `can_restore`
- `can_send_now`
- `can_queue_send`
- disabled reason / diagnostic
- direct send 自体はこの ticket の範囲外だが、multi-pod view が send target 判定に使える情報は model に含める。
- live + reachable + `PodStatus::Idle` なら `can_send_now`
- running は send disabled または future queue eligible として区別できる。
- stopped は restore/open 可能だが direct send は不可。
- `tui -r` picker は新しい `PodList` / `PodListEntry` を最初の consumer として使う。
- picker の見た目・key binding・attach/restore outcome は変えない。
- existing picker-specific rendering は残してよいが、row data source は共有 model に寄せる。
- list row rendering / selection / refresh の責務境界を整理する。
- TUI widget は表示と選択に寄せる。
- Pod discovery / client protocol / registry state / session summary の取得詳細を UI 表示ロジックへ直接散らさない。
- child Pod panel と multi-Pod view UI が同じ抽象を使える設計にする。
- visibility model は変えない。
- host-wide Pod browser を新設しない。
- `tui -r` は既存 resume picker 相当の source だけを扱う。
- spawned child panel は current parent から見える child Pod のみを対象にする後続 consumer として想定する。
- multi-Pod view UI も、具体要件が決まるまではこの抽象に新しい可視範囲を勝手に足さない。
- 既存の `ListPods` / `ReadPodOutput` / `SendToPod` / `StopPod` tool semantics は変えない。
- 既存の TUI resume picker / attach flow を壊さない。
## Suggested model sketch
Exact names may differ, but implementation should keep this shape simple and data-oriented.
```rust
pub struct PodList {
pub entries: Vec<PodListEntry>,
pub selected_name: Option<String>,
}
pub struct PodListEntry {
pub name: String,
pub source: PodVisibilitySource,
pub live: Option<LivePodInfo>,
pub stored: Option<StoredPodInfo>,
pub summary: PodEntrySummary,
pub actions: PodEntryActions,
pub diagnostics: Vec<PodEntryDiagnostic>,
}
pub struct LivePodInfo {
pub socket_path: PathBuf,
pub status: Option<PodStatus>,
pub reachable: bool,
pub segment_id: Option<SegmentId>,
}
pub struct StoredPodInfo {
pub metadata_state: StoredMetadataState,
pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>,
pub updated_at: Option<u64>,
pub preview: Option<String>,
}
pub struct PodEntryActions {
pub can_open: bool,
pub can_restore: bool,
pub can_send_now: bool,
pub can_queue_send: bool,
pub disabled_reason: Option<String>,
}
```
## Acceptance criteria
- TUI crate 内に、複数 Pod list/view UI で再利用できる typed abstraction がある。
- 既存 `tui -r` picker が、その abstraction を使って rows を構成する。
- spawned child Pod list と multi-Pod view UI の後続実装が、その abstraction を使う前提で説明できる。
- Pod row の status / reachability / attach target / diagnostic 表示に必要な情報が一箇所の model にまとまっている。
- visibility scope は caller が明示的に渡すか、source kind として表現され、UI helper が host-wide enumeration を暗黙に行わない。
- selection は refresh 後も Pod name を primary identity として維持できる。
- unit test で以下が確認されている。
- stored only row は restore/open 可能で direct send 不可。
- live idle reachable row は open/attach 可能かつ direct send 可能。
- live running reachable row は attach 可能だが direct send 可能とは扱わない。
- corrupt stored metadata は diagnostic を持つ。
- rows refresh / rebuild 後に selected Pod name が維持される。
- 既存 picker / attach 関連テストが通る。
- `cargo fmt --check`
- `cargo check -p tui -p client -p pod`
- 必要に応じて `cargo test -p tui -p pod -p protocol`
## Relationship
This is a prerequisite for:
- `20260527-000017-tui-spawned-pod-panel`
- `20260527-000023-multi-pod-view-ui`
## Out of scope
- spawned child Pod panel の完成。
- 複数 Pod view UI の完成。
- child Pod への interactive input。
- multi-Pod view からの direct send 実行。
- host-wide Pod browser。
- Pod discovery / permission / registry visibility model の変更。
- native GUI。
---

View File

@ -0,0 +1,73 @@
---
id: 20260528-152959-nix-packaging
slug: nix-packaging
title: Package Insomnia with Nix
status: closed
kind: task
priority: P2
labels: [packaging, nix, distribution]
created_at: 2026-05-28T15:29:59Z
updated_at: 2026-05-28T16:42:08Z
assignee: null
legacy_ticket: null
---
## Background
Insomnia should be easy to install and run on Nix/NixOS systems without requiring each user to hand-roll a local derivation. Add a Nix packaging entry point suitable for development and user installation.
This ticket is about packaging and installability, not changing runtime behavior. The package should build the Rust workspace binaries and include the runtime resources needed by the installed commands.
## Existing Nix file layout
The repository already separates the Nix package definition from the developer shell:
- `package.nix` is the package derivation used for install/build outputs.
- `devshell.nix` is for the development shell only.
- `flake.nix` may remain the entry point, but package outputs should call/use `package.nix`.
Do not implement the installable package primarily in `devshell.nix`. Update `devshell.nix` only if the development shell genuinely needs small supporting changes.
## Requirement
- Add Nix packaging for the repository.
- Use `package.nix` for the installable derivation.
- `flake.nix` should expose package outputs by importing/calling `package.nix`.
- Keep `devshell.nix` scoped to development shell concerns.
- Provide package outputs for the user-facing binaries, at minimum the Pod CLI and TUI binaries produced by the workspace.
- Provide a dev shell or equivalent developer environment if it can be done without large scope creep.
- Ensure runtime resources are included or discoverable.
- Built-in prompts/resources required at runtime must be packaged in the derivation output.
- Installed binaries should not rely on the source checkout layout unless explicitly running in development mode.
- Keep local/user configuration separate from packaged resources.
- Packaging should not bake user manifests, provider keys, sessions, memory, or runtime state into the derivation.
- Existing XDG / `INSOMNIA_*` path behavior should remain the source of user config/data/runtime locations.
- Make the package reproducible and CI-friendly.
- Pin inputs through the flake lock if a flake is used.
- Avoid network access during the build.
- Vendor or hash Cargo dependencies through normal Nix Rust packaging mechanisms.
- Document usage.
- How to build the package.
- How to run TUI/Pod binaries from Nix.
- How user config is discovered.
- Known limitations.
## Acceptance criteria
- `package.nix` contains the installable package derivation and is used by `flake.nix` package outputs.
- `nix build` or the documented equivalent builds the package from a clean checkout.
- Installed binaries can find built-in resources/prompts at runtime.
- User config/data/runtime paths continue to resolve through existing path logic and are not stored in the Nix store.
- A minimal smoke test or check verifies at least command startup/help/version without requiring real provider credentials.
- Documentation exists for Nix users.
- Packaging files are formatted by the relevant Nix formatter if one is adopted.
- `cargo fmt --check`
- Existing Rust checks affected by packaging changes still pass, or packaging-only validation is clearly documented.
## Out of scope
- Publishing to nixpkgs.
- NixOS module / Home Manager module.
- Packaging external LLM providers or model runtimes.
- Secret management for provider API keys.
- Changing manifest/path semantics specifically for Nix unless a separate design decision is made.

View File

@ -0,0 +1,73 @@
---
id: 20260528-152959-nix-packaging
slug: nix-packaging
title: Package Insomnia with Nix
status: closed
kind: task
priority: P2
labels: [packaging, nix, distribution]
created_at: 2026-05-28T15:29:59Z
updated_at: 2026-05-28T16:42:08Z
assignee: null
legacy_ticket: null
---
## Background
Insomnia should be easy to install and run on Nix/NixOS systems without requiring each user to hand-roll a local derivation. Add a Nix packaging entry point suitable for development and user installation.
This ticket is about packaging and installability, not changing runtime behavior. The package should build the Rust workspace binaries and include the runtime resources needed by the installed commands.
## Existing Nix file layout
The repository already separates the Nix package definition from the developer shell:
- `package.nix` is the package derivation used for install/build outputs.
- `devshell.nix` is for the development shell only.
- `flake.nix` may remain the entry point, but package outputs should call/use `package.nix`.
Do not implement the installable package primarily in `devshell.nix`. Update `devshell.nix` only if the development shell genuinely needs small supporting changes.
## Requirement
- Add Nix packaging for the repository.
- Use `package.nix` for the installable derivation.
- `flake.nix` should expose package outputs by importing/calling `package.nix`.
- Keep `devshell.nix` scoped to development shell concerns.
- Provide package outputs for the user-facing binaries, at minimum the Pod CLI and TUI binaries produced by the workspace.
- Provide a dev shell or equivalent developer environment if it can be done without large scope creep.
- Ensure runtime resources are included or discoverable.
- Built-in prompts/resources required at runtime must be packaged in the derivation output.
- Installed binaries should not rely on the source checkout layout unless explicitly running in development mode.
- Keep local/user configuration separate from packaged resources.
- Packaging should not bake user manifests, provider keys, sessions, memory, or runtime state into the derivation.
- Existing XDG / `INSOMNIA_*` path behavior should remain the source of user config/data/runtime locations.
- Make the package reproducible and CI-friendly.
- Pin inputs through the flake lock if a flake is used.
- Avoid network access during the build.
- Vendor or hash Cargo dependencies through normal Nix Rust packaging mechanisms.
- Document usage.
- How to build the package.
- How to run TUI/Pod binaries from Nix.
- How user config is discovered.
- Known limitations.
## Acceptance criteria
- `package.nix` contains the installable package derivation and is used by `flake.nix` package outputs.
- `nix build` or the documented equivalent builds the package from a clean checkout.
- Installed binaries can find built-in resources/prompts at runtime.
- User config/data/runtime paths continue to resolve through existing path logic and are not stored in the Nix store.
- A minimal smoke test or check verifies at least command startup/help/version without requiring real provider credentials.
- Documentation exists for Nix users.
- Packaging files are formatted by the relevant Nix formatter if one is adopted.
- `cargo fmt --check`
- Existing Rust checks affected by packaging changes still pass, or packaging-only validation is clearly documented.
## Out of scope
- Publishing to nixpkgs.
- NixOS module / Home Manager module.
- Packaging external LLM providers or model runtimes.
- Secret management for provider API keys.
- Changing manifest/path semantics specifically for Nix unless a separate design decision is made.

View File

@ -0,0 +1,88 @@
<!-- event: create author: tickets.sh at: 2026-05-28T15:29:59Z -->
## Created
Created by tickets.sh create.
---
<!-- event: close author: hare at: 2026-05-28T16:42:08Z status: closed -->
## Closed
---
id: 20260528-152959-nix-packaging
slug: nix-packaging
title: Package Insomnia with Nix
status: closed
kind: task
priority: P2
labels: [packaging, nix, distribution]
created_at: 2026-05-28T15:29:59Z
updated_at: 2026-05-28T16:42:08Z
assignee: null
legacy_ticket: null
---
## Background
Insomnia should be easy to install and run on Nix/NixOS systems without requiring each user to hand-roll a local derivation. Add a Nix packaging entry point suitable for development and user installation.
This ticket is about packaging and installability, not changing runtime behavior. The package should build the Rust workspace binaries and include the runtime resources needed by the installed commands.
## Existing Nix file layout
The repository already separates the Nix package definition from the developer shell:
- `package.nix` is the package derivation used for install/build outputs.
- `devshell.nix` is for the development shell only.
- `flake.nix` may remain the entry point, but package outputs should call/use `package.nix`.
Do not implement the installable package primarily in `devshell.nix`. Update `devshell.nix` only if the development shell genuinely needs small supporting changes.
## Requirement
- Add Nix packaging for the repository.
- Use `package.nix` for the installable derivation.
- `flake.nix` should expose package outputs by importing/calling `package.nix`.
- Keep `devshell.nix` scoped to development shell concerns.
- Provide package outputs for the user-facing binaries, at minimum the Pod CLI and TUI binaries produced by the workspace.
- Provide a dev shell or equivalent developer environment if it can be done without large scope creep.
- Ensure runtime resources are included or discoverable.
- Built-in prompts/resources required at runtime must be packaged in the derivation output.
- Installed binaries should not rely on the source checkout layout unless explicitly running in development mode.
- Keep local/user configuration separate from packaged resources.
- Packaging should not bake user manifests, provider keys, sessions, memory, or runtime state into the derivation.
- Existing XDG / `INSOMNIA_*` path behavior should remain the source of user config/data/runtime locations.
- Make the package reproducible and CI-friendly.
- Pin inputs through the flake lock if a flake is used.
- Avoid network access during the build.
- Vendor or hash Cargo dependencies through normal Nix Rust packaging mechanisms.
- Document usage.
- How to build the package.
- How to run TUI/Pod binaries from Nix.
- How user config is discovered.
- Known limitations.
## Acceptance criteria
- `package.nix` contains the installable package derivation and is used by `flake.nix` package outputs.
- `nix build` or the documented equivalent builds the package from a clean checkout.
- Installed binaries can find built-in resources/prompts at runtime.
- User config/data/runtime paths continue to resolve through existing path logic and are not stored in the Nix store.
- A minimal smoke test or check verifies at least command startup/help/version without requiring real provider credentials.
- Documentation exists for Nix users.
- Packaging files are formatted by the relevant Nix formatter if one is adopted.
- `cargo fmt --check`
- Existing Rust checks affected by packaging changes still pass, or packaging-only validation is clearly documented.
## Out of scope
- Publishing to nixpkgs.
- NixOS module / Home Manager module.
- Packaging external LLM providers or model runtimes.
- Secret management for provider API keys.
- Changing manifest/path semantics specifically for Nix unless a separate design decision is made.
---

View File

@ -0,0 +1,84 @@
---
id: 20260528-163238-multi-pod-view-section-layout
slug: multi-pod-view-section-layout
title: Polish multi-Pod view section layout
status: closed
kind: task
priority: P2
labels: [tui, pod, ux]
created_at: 2026-05-28T16:32:38Z
updated_at: 2026-05-28T16:49:25Z
assignee: null
legacy_ticket: null
---
## Background
`20260527-000023-multi-pod-view-ui` implemented the initial `tui --multi` dashboard. The current layout should be polished before building more interaction on top of it.
The desired list shape is sectioned by Pod state rather than a flat row list. The list area should visually emphasize live work first and keep closed/stopped history compact.
Target shape:
```text
--pending---
a
b
--working---
c
d
--closed--
# only a few rows
```
The blank area between `working` and `closed` is intentional: the live sections should occupy the available vertical space, while the closed section stays compact at the bottom.
There is also a visual defect where the input-area separator and the list-area separator produce two adjacent separators. The multi-Pod view should have a single clean boundary between the Pod list/dashboard and the composer/input area.
## Requirements
- Change `tui --multi` list layout to explicit sections:
- `pending`: live Pods that are idle/waiting and ready for input.
- `working`: live Pods that are running/processing, plus paused Pods if a separate paused section is not introduced.
- `closed`: stopped/restorable history entries.
- Render section headers even when a section is empty only if that makes the view easier to understand; otherwise empty sections may be hidden. The choice should be consistent and tested/snapshotted where practical.
- Allocate vertical space so that:
- live sections (`pending` + `working`) take the main flexible area.
- `closed` is pinned near the bottom of the list/dashboard area.
- `closed` shows only a small fixed number of rows initially, with 3 visible rows as the target.
- excess height appears as blank space above the `closed` section rather than expanding closed history.
- Keep selection/navigation sane across sections.
- Selection should move through visible rows in display order.
- Direct send eligibility remains based on the selected `PodListEntry` action state.
- Hidden closed rows must not accidentally become selected unless scrolling/paging for closed entries is explicitly implemented.
- Fix the double-separator defect between the Pod list/dashboard and the composer/input area.
- There should be one visual boundary, not two adjacent horizontal rules/borders.
- Do not introduce the same double-border issue between section headers and rows.
- Preserve existing `tui --multi` behavior outside layout.
- `tui --multi` CLI entrypoint and conflicts remain unchanged.
- Composer contents are preserved across selection changes.
- Direct send to selected idle live Pod remains supported.
- running/paused/stopped targets remain safely disabled unless separately implemented.
## Acceptance criteria
- `tui --multi` renders Pod rows grouped into `pending`, `working`, and compact `closed` sections.
- The closed section is limited to about 3 visible rows and is visually anchored below the flexible live area.
- The blank/flexible space is placed above `closed`, not below it and not by expanding closed history.
- The boundary between list/dashboard and composer has a single separator/border.
- Selection and direct-send target mapping still use the underlying `PodListEntry` and remain correct after sectioning.
- Focused tests cover section classification, closed-row limiting, selection over visible section rows, and composer separator layout state where practical.
- `cargo fmt --check`
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
- `cargo check -p tui -p client -p pod`
## Out of scope
- Reopening the completed `multi-pod-view-ui` ticket.
- Adding per-section scrolling unless needed for a minimal correct implementation.
- Changing `PodList` discovery/visibility semantics.
- Changing direct-send delivery semantics.
- Adding new CLI flags.

View File

@ -0,0 +1,84 @@
---
id: 20260528-163238-multi-pod-view-section-layout
slug: multi-pod-view-section-layout
title: Polish multi-Pod view section layout
status: closed
kind: task
priority: P2
labels: [tui, pod, ux]
created_at: 2026-05-28T16:32:38Z
updated_at: 2026-05-28T16:49:25Z
assignee: null
legacy_ticket: null
---
## Background
`20260527-000023-multi-pod-view-ui` implemented the initial `tui --multi` dashboard. The current layout should be polished before building more interaction on top of it.
The desired list shape is sectioned by Pod state rather than a flat row list. The list area should visually emphasize live work first and keep closed/stopped history compact.
Target shape:
```text
--pending---
a
b
--working---
c
d
--closed--
# only a few rows
```
The blank area between `working` and `closed` is intentional: the live sections should occupy the available vertical space, while the closed section stays compact at the bottom.
There is also a visual defect where the input-area separator and the list-area separator produce two adjacent separators. The multi-Pod view should have a single clean boundary between the Pod list/dashboard and the composer/input area.
## Requirements
- Change `tui --multi` list layout to explicit sections:
- `pending`: live Pods that are idle/waiting and ready for input.
- `working`: live Pods that are running/processing, plus paused Pods if a separate paused section is not introduced.
- `closed`: stopped/restorable history entries.
- Render section headers even when a section is empty only if that makes the view easier to understand; otherwise empty sections may be hidden. The choice should be consistent and tested/snapshotted where practical.
- Allocate vertical space so that:
- live sections (`pending` + `working`) take the main flexible area.
- `closed` is pinned near the bottom of the list/dashboard area.
- `closed` shows only a small fixed number of rows initially, with 3 visible rows as the target.
- excess height appears as blank space above the `closed` section rather than expanding closed history.
- Keep selection/navigation sane across sections.
- Selection should move through visible rows in display order.
- Direct send eligibility remains based on the selected `PodListEntry` action state.
- Hidden closed rows must not accidentally become selected unless scrolling/paging for closed entries is explicitly implemented.
- Fix the double-separator defect between the Pod list/dashboard and the composer/input area.
- There should be one visual boundary, not two adjacent horizontal rules/borders.
- Do not introduce the same double-border issue between section headers and rows.
- Preserve existing `tui --multi` behavior outside layout.
- `tui --multi` CLI entrypoint and conflicts remain unchanged.
- Composer contents are preserved across selection changes.
- Direct send to selected idle live Pod remains supported.
- running/paused/stopped targets remain safely disabled unless separately implemented.
## Acceptance criteria
- `tui --multi` renders Pod rows grouped into `pending`, `working`, and compact `closed` sections.
- The closed section is limited to about 3 visible rows and is visually anchored below the flexible live area.
- The blank/flexible space is placed above `closed`, not below it and not by expanding closed history.
- The boundary between list/dashboard and composer has a single separator/border.
- Selection and direct-send target mapping still use the underlying `PodListEntry` and remain correct after sectioning.
- Focused tests cover section classification, closed-row limiting, selection over visible section rows, and composer separator layout state where practical.
- `cargo fmt --check`
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
- `cargo check -p tui -p client -p pod`
## Out of scope
- Reopening the completed `multi-pod-view-ui` ticket.
- Adding per-section scrolling unless needed for a minimal correct implementation.
- Changing `PodList` discovery/visibility semantics.
- Changing direct-send delivery semantics.
- Adding new CLI flags.

View File

@ -0,0 +1,99 @@
<!-- event: create author: tickets.sh at: 2026-05-28T16:32:38Z -->
## Created
Created by tickets.sh create.
---
<!-- event: close author: hare at: 2026-05-28T16:49:25Z status: closed -->
## Closed
---
id: 20260528-163238-multi-pod-view-section-layout
slug: multi-pod-view-section-layout
title: Polish multi-Pod view section layout
status: closed
kind: task
priority: P2
labels: [tui, pod, ux]
created_at: 2026-05-28T16:32:38Z
updated_at: 2026-05-28T16:49:25Z
assignee: null
legacy_ticket: null
---
## Background
`20260527-000023-multi-pod-view-ui` implemented the initial `tui --multi` dashboard. The current layout should be polished before building more interaction on top of it.
The desired list shape is sectioned by Pod state rather than a flat row list. The list area should visually emphasize live work first and keep closed/stopped history compact.
Target shape:
```text
--pending---
a
b
--working---
c
d
--closed--
# only a few rows
```
The blank area between `working` and `closed` is intentional: the live sections should occupy the available vertical space, while the closed section stays compact at the bottom.
There is also a visual defect where the input-area separator and the list-area separator produce two adjacent separators. The multi-Pod view should have a single clean boundary between the Pod list/dashboard and the composer/input area.
## Requirements
- Change `tui --multi` list layout to explicit sections:
- `pending`: live Pods that are idle/waiting and ready for input.
- `working`: live Pods that are running/processing, plus paused Pods if a separate paused section is not introduced.
- `closed`: stopped/restorable history entries.
- Render section headers even when a section is empty only if that makes the view easier to understand; otherwise empty sections may be hidden. The choice should be consistent and tested/snapshotted where practical.
- Allocate vertical space so that:
- live sections (`pending` + `working`) take the main flexible area.
- `closed` is pinned near the bottom of the list/dashboard area.
- `closed` shows only a small fixed number of rows initially, with 3 visible rows as the target.
- excess height appears as blank space above the `closed` section rather than expanding closed history.
- Keep selection/navigation sane across sections.
- Selection should move through visible rows in display order.
- Direct send eligibility remains based on the selected `PodListEntry` action state.
- Hidden closed rows must not accidentally become selected unless scrolling/paging for closed entries is explicitly implemented.
- Fix the double-separator defect between the Pod list/dashboard and the composer/input area.
- There should be one visual boundary, not two adjacent horizontal rules/borders.
- Do not introduce the same double-border issue between section headers and rows.
- Preserve existing `tui --multi` behavior outside layout.
- `tui --multi` CLI entrypoint and conflicts remain unchanged.
- Composer contents are preserved across selection changes.
- Direct send to selected idle live Pod remains supported.
- running/paused/stopped targets remain safely disabled unless separately implemented.
## Acceptance criteria
- `tui --multi` renders Pod rows grouped into `pending`, `working`, and compact `closed` sections.
- The closed section is limited to about 3 visible rows and is visually anchored below the flexible live area.
- The blank/flexible space is placed above `closed`, not below it and not by expanding closed history.
- The boundary between list/dashboard and composer has a single separator/border.
- Selection and direct-send target mapping still use the underlying `PodListEntry` and remain correct after sectioning.
- Focused tests cover section classification, closed-row limiting, selection over visible section rows, and composer separator layout state where practical.
- `cargo fmt --check`
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
- `cargo check -p tui -p client -p pod`
## Out of scope
- Reopening the completed `multi-pod-view-ui` ticket.
- Adding per-section scrolling unless needed for a minimal correct implementation.
- Changing `PodList` discovery/visibility semantics.
- Changing direct-send delivery semantics.
- Adding new CLI flags.
---

View File

@ -1,7 +0,0 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:12Z -->
## Migrated
Migrated from tickets/spawnpod-initial-run-confirmation.md. No legacy review file was present at migration time.
---

View File

@ -7,7 +7,7 @@ kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:17Z
updated_at: 2026-05-27T00:00:17Z
updated_at: 2026-05-28T14:16:02Z
assignee: null
legacy_ticket: tickets/tui-spawned-pod-panel.md
---
@ -25,6 +25,12 @@ insomnia の開発では、親 Pod が複数の実装 Pod / reviewer Pod を spa
ネイティブ GUI は将来的には便利だが、現時点で必要なタスクではない。まず TUI のまま、現在の Pod が spawn した child Pod を一覧し、一時的に attach / view できる UI を用意したい。
## Prerequisite
- `20260528-141602-tui-pod-list-view-abstraction`
This ticket should build on the shared TUI Pod list/view abstraction instead of introducing a separate child-Pod-specific list model. The child panel may specialize the source/visibility to current-parent spawned children, but row status, reachability diagnostics, attach target representation, selection, and refresh behavior should reuse the prerequisite abstraction.
## 要件
- TUI 上で、現在の Pod が spawn した child Pod を一覧できる。

View File

@ -1,28 +0,0 @@
---
id: 20260527-000023-multi-pod-view-ui
slug: multi-pod-view-ui
title: 複数のPodのViewを行き来できるUI
status: open
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:23Z
updated_at: 2026-05-27T00:00:23Z
assignee: null
legacy_ticket: null
---
## Migration reference
- legacy_ticket: null
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
# 複数のPodのViewを行き来できるUI
## Background
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
## Acceptance criteria
- Define the concrete requirements before implementation.

View File

@ -1,7 +0,0 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:23Z -->
## Migrated
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
---

View File

@ -0,0 +1,412 @@
# Crate boundary audit
Date: 2026-05-28
## summary
The workspace dependency graph is broadly acyclic and mostly layered in the expected direction: `protocol` / `lint-common` / proc-macros sit at the bottom, `llm-worker` / `manifest` / `tools` / `provider` / `session-store` provide shared infrastructure, and `pod` / `tui` are orchestration or UI layers. I did not find a hard Cargo-level cycle or an obvious UI crate being depended on by a lower crate.
The main boundary problems are subtler:
1. `protocol` exposes several public wire payloads as `serde_json::Value` while documenting them as the JSON form of `session_store::*` types. This avoids a Rust dependency edge but creates a hidden schema dependency from `protocol`/clients to `session-store`.
2. `workflow` depends on `memory` for `WorkspaceLayout`, and `memory::WorkspaceLayout` owns workflow paths. This makes `memory` a cross-domain workspace-layout hub rather than only the memory subsystem.
3. Several lower/shared crates have comments/doc-comments explaining `Pod`, `TUI`, controller, prompt-catalog, or downstream orchestration behavior. Most are acceptable integration-contract notes, but a few are implementation knowledge that should move upward or be generalized.
No code, formatting, commits, merges, or project-record files outside `artifacts/` were changed.
## inspected commands / files
### Commands run
- `cargo metadata --no-deps --format-version 1 | jq ... > artifacts/deps.txt`
- Extracted workspace-internal normal/dev dependency edges.
- `cargo metadata --no-deps --format-version 1 | jq ... > artifacts/reverse-deps.txt`
- Extracted reverse dependency summary.
- `rg -n 'pub (struct|enum|fn|mod|trait|type|use) ...' crates --glob '*.rs' > artifacts/public-concept-hits.txt`
- Searched public APIs for boundary-relevant terms (`Pod`, `TUI`, `Workflow`, `Manifest`, `Memory`, `Session`, etc.).
- `rg -n '(^\s*(//!|///|//)\s?.*(...))' crates --glob '*.rs' > artifacts/comment-concept-hits.txt`
- Searched comments/doc-comments for crate names and upper-layer concepts.
- `rg -n 'TUI / GUI|session_store::|parent Controller|Pod treats|Pod side|...' ... > artifacts/suspicious-excerpts.txt`
- Narrowed suspicious comment excerpts.
- `rg -n 'use (session_store|pod_registry|llm_worker|manifest)::|...' crates/tui/src`
- Checked why TUI depends on lower internal crates.
- `rg -n 'WorkspaceLayout|memory::' crates/workflow/src`
- Checked `workflow -> memory` dependency use.
Failed exploratory commands:
- `python` / `python3` parse attempts failed because Python was not available in the environment; switched to `cargo metadata` + `jq`.
Supplemental raw outputs left in the artifact directory:
- `deps.txt`, `reverse-deps.txt`
- `deps-numbered.txt`, `reverse-deps-numbered.txt`
- `public-concept-hits.txt`
- `comment-concept-hits.txt`
- `suspicious-excerpts.txt`
### Main files inspected directly
- Root/workspace:
- `Cargo.toml`
- `work-items/open/20260528-131317-crate-boundary-audit/item.md`
- Cargo manifests:
- `crates/protocol/Cargo.toml`
- `crates/manifest/Cargo.toml`
- `crates/llm-worker/Cargo.toml`
- `crates/pod/Cargo.toml`
- `crates/client/Cargo.toml`
- `crates/tui/Cargo.toml`
- `crates/memory/Cargo.toml`
- `crates/workflow/Cargo.toml`
- `crates/provider/Cargo.toml`
- `crates/session-store/Cargo.toml`
- `crates/pod-registry/Cargo.toml`
- `crates/session-metrics/Cargo.toml`
- `crates/tools/Cargo.toml`
- `crates/daemon/Cargo.toml`
- `crates/lint-common/Cargo.toml`
- `crates/llm-worker-macros/Cargo.toml`
- Public/API and suspicious source files:
- `crates/protocol/src/lib.rs`
- `crates/manifest/src/lib.rs`
- `crates/manifest/src/model.rs`
- `crates/llm-worker/src/lib.rs`
- `crates/llm-worker/src/interceptor.rs`
- `crates/llm-worker/src/llm_client/types.rs`
- `crates/pod/src/lib.rs`
- `crates/pod/src/pod.rs` (grep/read excerpts)
- `crates/pod/src/spawn/comm_tools.rs` (grep excerpts)
- `crates/client/src/lib.rs`
- `crates/client/src/spawn.rs`
- `crates/tui/src/app.rs` (grep excerpts)
- `crates/tui/src/spawn.rs` (grep excerpts)
- `crates/tui/src/picker.rs` (grep excerpts)
- `crates/memory/src/lib.rs`
- `crates/memory/src/scope.rs`
- `crates/memory/src/workspace.rs`
- `crates/memory/src/extract/mod.rs` (grep excerpts)
- `crates/memory/src/consolidate/mod.rs` (grep excerpts)
- `crates/memory/src/resident.rs` (grep excerpts)
- `crates/workflow/src/lib.rs`
- `crates/workflow/src/linter.rs` (grep excerpts)
- `crates/workflow/src/scope.rs` (grep excerpts)
- `crates/workflow/src/workflow.rs` (grep excerpts)
- `crates/session-store/src/lib.rs`
- `crates/session-store/src/segment.rs`
- `crates/session-store/src/segment_log.rs` (grep excerpts)
- `crates/session-store/src/system_item.rs`
- `crates/session-store/src/pod_metadata.rs`
- `crates/pod-registry/src/lib.rs`
- `crates/provider/src/lib.rs`
- `crates/tools/src/lib.rs`
## dependency graph overview
Internal dependency edges from `cargo metadata --no-deps`:
```text
client -> manifest, protocol
daemon -> manifest, protocol
lint-common -> (none)
llm-worker -> llm-worker-macros
llm-worker-macros -> (none)
manifest -> llm-worker, protocol
memory -> lint-common, llm-worker, manifest
pod -> llm-worker, manifest, memory, pod-registry, protocol, provider, session-metrics, session-store, tools, workflow
pod-registry -> manifest, session-store
protocol -> (none)
provider -> llm-worker, manifest
session-metrics -> session-store
session-store -> llm-worker, protocol
tools -> llm-worker, manifest
tui -> client, llm-worker, manifest, pod-registry, protocol, session-store; dev-dep tools
workflow -> lint-common, manifest, memory
```
Reverse summary:
```text
client <- tui
lint-common <- memory, workflow
llm-worker <- manifest, memory, pod, provider, session-store, tools, tui
manifest <- client, daemon, memory, pod, pod-registry, provider, tools, tui, workflow
memory <- pod, workflow
pod-registry <- pod, tui
protocol <- client, daemon, manifest, pod, session-store, tui
provider <- pod
session-metrics <- pod
session-store <- pod, pod-registry, session-metrics, tui
tools <- pod, tui
workflow <- pod
```
This is directionally reasonable for orchestration-heavy code: `pod` is the main integrator; `tui` sits above `client` but also reads lower schemas; `protocol` has no Rust workspace dependencies.
## dependency/interface findings grouped by severity
### Severity: actual problem / should ticket
#### 1. `protocol` public API has hidden `session-store` schema coupling through `serde_json::Value`
Evidence:
- `crates/protocol/src/lib.rs:237` documents `Event::SystemItem.item` as the JSON form of `session_store::SystemItem`.
- `crates/protocol/src/lib.rs:394` documents `Event::Snapshot.entries` as the JSON form of `session_store::LogEntry`.
- `crates/protocol/src/lib.rs:419` documents `Event::SegmentRotated.entry` as the JSON form of `session_store::LogEntry::SegmentStart`.
- `crates/tui/src/app.rs:1236` and nearby lines deserialize snapshot entries back into `session_store::LogEntry`.
- `crates/tui/src/app.rs:1277` and nearby lines deserialize `Event::SystemItem.item` into `session_store::SystemItem`.
Why this is a boundary issue:
- `protocol` is dependency-free at the Cargo level, but its wire contract is not actually self-owned: clients must know `session-store` schemas to reconstruct state correctly.
- The type system cannot enforce compatibility between `protocol` and `session-store` because the public protocol type is only `serde_json::Value`.
- This explains why `tui` depends directly on `session-store` despite also depending on `client`/`protocol`.
Recommended direction:
- Extract the wire-stable log/system-item DTOs into a neutral crate, or move protocol-facing DTOs into `protocol` and have `session-store` convert to/from them.
- Avoid public protocol docs that say “this is `session_store::X` JSON” unless `session-store` is intentionally part of the protocol contract and typed as such.
#### 2. `workflow -> memory` dependency exists for shared workspace layout
Evidence:
- `crates/workflow/Cargo.toml:11` depends on `memory`.
- `crates/workflow/src/linter.rs:5`, `crates/workflow/src/scope.rs:6`, `crates/workflow/src/workflow.rs:17` use `memory::WorkspaceLayout`.
- `crates/memory/src/workspace.rs:8` includes `<root>/.insomnia/workflow/<slug>.md` in memory's layout documentation.
- `crates/memory/src/workspace.rs:16-18` says workflows are human-managed and live one level up under `.insomnia/workflow/`.
- `crates/memory/src/workspace.rs:127-165` exposes `workflow_dir()` / `workflow_path()` from the memory crate.
Why this is a boundary issue:
- `workflow` is conceptually a sibling subsystem, not a consumer of generated memory state.
- The current dependency is only for path layout. That makes `memory` own cross-subsystem workspace conventions and forces workflow to import a memory-domain crate for non-memory concerns.
- This is not severe yet, but it will make future workflow growth pull against crate ownership.
Recommended direction:
- Extract `WorkspaceLayout` / `.insomnia` path conventions into a neutral crate or a neutral module under `manifest`/new `workspace-layout` crate.
- Then make `memory` and `workflow` both depend on that neutral layout instead of depending on each other.
### Severity: suspicious but currently acceptable
#### 3. `session-store` owns Pod metadata and spawned-child metadata
Evidence:
- `crates/session-store/src/pod_metadata.rs:1-6` defines “Pod metadata persistence API”.
- `crates/session-store/src/pod_metadata.rs:42-60` defines `PodSpawnedScopeRule` / `PodSpawnedChild`, including delegated scope and `callback_address`.
- `crates/session-store/src/pod_metadata.rs:62-88` exposes `PodMetadata` and `PodMetadataStore` publicly.
Assessment:
- This is Pod/orchestration-specific state inside a crate named `session-store`.
- It is acceptable if `session-store` is intentionally “insomnia persistence primitives”, not a generic conversation-log crate. Current project decisions appear to lean that way.
- If the intended boundary is “session-store only stores sessions/segments/logs”, this should be split or renamed. If the intended boundary is “session-store stores all durable Pod state”, the naming/docs should say that explicitly.
Recommended direction:
- No immediate refactor unless the ownership goal changes.
- Clarify crate-level docs: either broaden `session-store`'s stated responsibility to durable Pod/session persistence, or split Pod metadata into a `pod-state`/`pod-metadata` crate.
#### 4. TUI directly depends on persistence/registry crates
Evidence:
- `crates/tui/Cargo.toml` depends on `session-store`, `pod-registry`, `manifest`, `llm-worker`, and `protocol` in addition to `client`.
- `crates/tui/src/picker.rs` uses `pod_registry::{LockFileGuard, default_registry_path}` and `session_store::{...}`.
- `crates/tui/src/app.rs:1236-1298` parses `session_store::LogEntry` / `session_store::SystemItem`.
- `crates/tui/src/spawn.rs:408-409` uses `session_store::FsStore` and `restore_by_segment` for resume-related paths.
Assessment:
- TUI is a top-level crate, so dependency direction is allowed.
- The direct `session-store` parse dependency is largely a symptom of finding #1: protocol sends untyped JSON whose real schema lives in `session-store`.
- Direct `pod-registry` access for picker/runtime discovery may be acceptable for a local-first TUI, but it bypasses a cleaner “TUI talks protocol/client only” boundary.
Recommended direction:
- Fix protocol DTO ownership first.
- After that, re-evaluate whether TUI still needs direct `session-store` and `pod-registry` dependencies or whether picker/discovery can move behind `client`/`protocol` APIs.
#### 5. `manifest -> llm-worker` dependency is acceptable but should remain one-way
Evidence:
- `crates/manifest/Cargo.toml` depends on `llm-worker` and `protocol`.
- `crates/manifest/src/model.rs:17-19` re-exports `llm_worker::llm_client::capability::{ModelCapability, ReasoningControl, ReasoningEffort}`.
Assessment:
- This is a reasonable tradeoff to avoid duplicate model-capability types.
- It does mean `manifest` is not a pure data crate independent of worker runtime types.
- The boundary remains acceptable as long as `llm-worker` does not depend back on `manifest`, and provider-level resolution stays in `provider`.
### Severity: no issue found
- No Rust workspace dependency cycle was found in the inspected graph.
- I did not find lower crates depending on `tui` or `client` implementation crates.
- `client -> protocol/manifest` and `pod -> provider/tools/session-store/memory/workflow` are directionally appropriate.
- `provider -> llm-worker/manifest` is appropriate: provider constructs concrete `LlmClient` implementations from resolved model configuration.
- `tools -> llm-worker/manifest` is appropriate: tools expose `ToolDefinition`s and enforce manifest scopes.
- `pod-registry -> session-store` is acceptable if registry entries need session/segment identity and durable state coordination.
## comment/doc-comment findings
### Problematic or should be generalized
#### `protocol` describes parent controller and pod-registry side effects
- `crates/protocol/src/lib.rs:65-70`
- `PodEvent` docs say the “parent Controller applies variant-specific side effects (registry / pod-registry updates)”.
- This is implementation knowledge from the `pod` crate inside a dependency-free protocol crate.
- Better: state the wire contract (“event is delivered to the parent; receiver is responsible for handling lifecycle effects”) and keep registry-specific behavior in `pod` docs.
#### `protocol` documents `session_store::*` JSON shapes as protocol payloads
- `crates/protocol/src/lib.rs:237`
- `crates/protocol/src/lib.rs:394`
- `crates/protocol/src/lib.rs:419`
This is the comment-level manifestation of the public-interface issue in finding #1.
#### `llm-worker` public request docs mention Pod-specific cache-key choice
- `crates/llm-worker/src/llm_client/types.rs:523-526`
- `Request::cache_key` doc says pod side is expected to pass `SegmentId`.
- `llm-worker` should expose the generic concept: a stable caller-provided conversation/cache namespace key.
- Pod's choice of `SegmentId` belongs in `pod` docs/tests, not in the generic request type.
#### `memory` docs prescribe Pod assembly details
- `crates/memory/src/lib.rs:3-7`
- Says generic CRUD tools must not touch memory/knowledge and Pod is responsible for denying them.
- `crates/memory/src/scope.rs:4-8`
- Says Pod is expected to call `deny_write_rules` and pass the result to `tools::ScopedFs`.
- `crates/memory/src/extract/mod.rs:3-14`
- Explains Pod post-run hook, `PromptCatalog`, `PodPrompt::MemoryExtractSystem`, and pointer persistence responsibility.
- `crates/memory/src/consolidate/mod.rs:5-15`
- Explains Pod assembling a disposable Worker and using `PodPrompt::MemoryConsolidationSystem`.
- `crates/memory/src/resident.rs:3-11`
- Says surfaces are used by the Pod system-prompt assembler and Pod IPC layer for TUI `#` completion.
Assessment:
- These are understandable because `memory` is currently a helper subsystem consumed by `pod`.
- They nevertheless make `memory` read like it is documenting Pod orchestration rather than memory-owned contracts.
- Prefer caller-neutral wording: “the orchestrator/caller registers these tools”, “the caller persists the pointer”, “completion consumers may use ...”. Keep Pod-specific sequence docs in `pod`.
### Suspicious but acceptable integration-contract comments
#### `protocol::Segment` docs mention TUI/GUI and Pod parsing behavior
- `crates/protocol/src/lib.rs:116-126`
- Mentions richer clients (TUI/GUI) producing typed atoms and Pod not re-parsing flattened strings.
- `crates/protocol/src/lib.rs:143-153`
- Mentions Pod resolving `FileRef` and treating unknown variants as unresolved input.
- `crates/protocol/src/lib.rs:222-231`
- Mentions additional TUI/GUI instances rendering user messages.
Assessment:
- Mentioning client classes can be acceptable in protocol docs when it explains wire semantics.
- The Pod behavior details are more debatable; they should be limited to required protocol semantics, not specific controller implementation.
#### `session-store::SystemItem` mentions TUI typed rendering
- `crates/session-store/src/system_item.rs:27-35`
- `crates/session-store/src/system_item.rs:49-52`
Assessment:
- It is valid to document why typed payload exists.
- “so the TUI can render” should probably be generalized to “so clients can render” because `session-store` is lower than `tui`.
#### `session-store::segment` mentions Pod as typical caller
- `crates/session-store/src/segment.rs:3-5`
- `crates/session-store/src/segment.rs:38-40`
- `crates/session-store/src/segment.rs:175-180`
- `crates/session-store/src/segment.rs:252-254`
Assessment:
- Mostly acceptable because Pod is currently the primary writer.
- Better wording would say “caller/orchestrator” first and optionally “e.g. Pod” only where it clarifies current integration.
#### `client` docs mention TUI/GUI/E2E
- `crates/client/src/lib.rs:9`
- `crates/client/src/spawn.rs:4-10`
- `crates/client/src/spawn.rs:92-96`
Assessment:
- Acceptable: `client` is explicitly a library for UI/GUI/E2E callers to speak Pod protocol. These are consumer examples rather than lower-layer implementation leakage.
#### `llm-worker::Interceptor` docs mention Pod as an upper layer
- `crates/llm-worker/src/interceptor.rs:3-6`
- `crates/llm-worker/src/interceptor.rs:122-126`
- `crates/llm-worker/src/interceptor.rs:140-146`
Assessment:
- Mostly acceptable: the docs explicitly say Worker does not know higher-level concepts and Pod is only an example upper layer.
- For stricter boundary hygiene, prefer “upper layers/orchestrators” and avoid naming Pod except in examples.
## acceptable dependency-aware comments criteria
I treated a comment as acceptable when it met at least one of these criteria:
1. It explains a public wire or file-format contract that consumers must honor, without prescribing one consumer's private implementation.
2. It names a higher layer only as an example (`e.g. Pod`) while the API remains generic and caller-owned.
3. It documents an intentional direction-of-control boundary, such as “the lower crate exposes a hook; upper layers implement policy”.
4. It references another crate that the current crate actually depends on and whose type or function is part of the local API.
5. It appears in tests/examples whose purpose is cross-crate contract verification.
I treated a comment as problematic when it did any of the following:
1. A lower crate explains what a dependent higher crate currently does internally.
2. A lower crate's public docs define a payload as another higher crate's private or semi-private JSON schema.
3. A shared subsystem describes its API mainly as a sequence of Pod/TUI orchestration steps, rather than a caller-neutral contract.
4. The comment reveals a hidden dependency that Cargo cannot type-check.
## recommended follow-up tickets
1. **Typed protocol snapshot/system-item payloads**
- Goal: remove `protocol` public `serde_json::Value` payloads whose real schemas are `session_store::*`.
- Candidate implementation directions:
- Move wire DTOs for log entries/system items into `protocol`, with `session-store` converting to/from them; or
- Extract a neutral `session-log-schema` / `wire-log` crate used by both `protocol` and `session-store`.
- Success condition: TUI/client code can parse snapshots/system items using protocol-owned typed structures, not `session_store::LogEntry` hidden behind JSON.
2. **Extract neutral workspace layout from `memory`**
- Goal: remove `workflow -> memory` when the only need is `.insomnia` path layout.
- Candidate implementation directions:
- New neutral crate/module for `WorkspaceLayout`; or
- Move `.insomnia` path layout into `manifest` if that crate is intended to own workspace configuration.
- Success condition: `workflow` and `memory` are siblings depending on a neutral layout owner.
3. **Boundary-comment hygiene pass**
- Goal: replace reverse-knowledge comments in lower/shared crates with caller-neutral wording.
- Scope:
- `protocol/src/lib.rs` controller/session-store JSON wording.
- `llm-worker/src/llm_client/types.rs` Pod `SegmentId` cache-key wording.
- `memory/src/{scope,extract,consolidate,resident}.rs` Pod/TUI orchestration wording.
- `session-store/src/{system_item,segment}.rs` TUI/Pod-specific wording where not required.
- Success condition: comments explain local contracts and extension points; dependent-crate implementation details live in the dependent crate.
4. **Clarify `session-store` crate responsibility**
- Goal: decide whether `session-store` is only session/segment log storage or the broader durable Pod-state persistence crate.
- If broader: update crate docs/naming comments to say so.
- If narrower: split `pod_metadata` into a Pod-owned persistence crate/module.
## unresolved questions
1. Is `protocol` intended to be the sole owner of all stable wire DTOs, or is `session-store` intentionally part of the protocol contract despite the current `serde_json::Value` indirection?
2. Is `session-store` deliberately the durable state crate for all Pod metadata, or should it be constrained to conversation/session logs?
3. Should `WorkspaceLayout` be considered a memory-domain concept, or a repository/workspace-domain concept shared by memory, knowledge, and workflow?
4. Should TUI remain allowed to inspect local registry/session files directly for picker and restore UX, or should those capabilities move behind `client`/`protocol` APIs?
5. Are comments allowed to name the primary current consumer (`Pod`) when documenting a generic lower-layer extension point, or should comments avoid such names unless the type itself is Pod-specific?

View File

@ -0,0 +1,58 @@
1 client:
2 -> manifest [normal]
3 -> protocol [normal]
4 daemon:
5 -> manifest [normal]
6 -> protocol [normal]
7 lint-common:
8 (no workspace deps)
9 llm-worker:
10 -> llm-worker-macros [normal]
11 llm-worker-macros:
12 (no workspace deps)
13 manifest:
14 -> llm-worker [normal]
15 -> protocol [normal]
16 memory:
17 -> lint-common [normal]
18 -> llm-worker [normal]
19 -> manifest [normal]
20 pod:
21 -> llm-worker [normal]
22 -> manifest [normal]
23 -> memory [normal]
24 -> pod-registry [normal]
25 -> protocol [normal]
26 -> provider [normal]
27 -> session-metrics [normal]
28 -> session-store [normal]
29 -> tools [normal]
30 -> workflow [normal]
31 pod-registry:
32 -> manifest [normal]
33 -> session-store [normal]
34 protocol:
35 (no workspace deps)
36 provider:
37 -> llm-worker [normal]
38 -> manifest [normal]
39 session-metrics:
40 -> session-store [normal]
41 session-store:
42 -> llm-worker [normal]
43 -> protocol [normal]
44 tools:
45 -> llm-worker [normal]
46 -> manifest [normal]
47 tui:
48 -> client [normal]
49 -> llm-worker [normal]
50 -> manifest [normal]
51 -> pod-registry [normal]
52 -> protocol [normal]
53 -> session-store [normal]
54 -> tools [dev]
55 workflow:
56 -> lint-common [normal]
57 -> manifest [normal]
58 -> memory [normal]

View File

@ -0,0 +1,58 @@
client:
-> manifest [normal]
-> protocol [normal]
daemon:
-> manifest [normal]
-> protocol [normal]
lint-common:
(no workspace deps)
llm-worker:
-> llm-worker-macros [normal]
llm-worker-macros:
(no workspace deps)
manifest:
-> llm-worker [normal]
-> protocol [normal]
memory:
-> lint-common [normal]
-> llm-worker [normal]
-> manifest [normal]
pod:
-> llm-worker [normal]
-> manifest [normal]
-> memory [normal]
-> pod-registry [normal]
-> protocol [normal]
-> provider [normal]
-> session-metrics [normal]
-> session-store [normal]
-> tools [normal]
-> workflow [normal]
pod-registry:
-> manifest [normal]
-> session-store [normal]
protocol:
(no workspace deps)
provider:
-> llm-worker [normal]
-> manifest [normal]
session-metrics:
-> session-store [normal]
session-store:
-> llm-worker [normal]
-> protocol [normal]
tools:
-> llm-worker [normal]
-> manifest [normal]
tui:
-> client [normal]
-> llm-worker [normal]
-> manifest [normal]
-> pod-registry [normal]
-> protocol [normal]
-> session-store [normal]
-> tools [dev]
workflow:
-> lint-common [normal]
-> manifest [normal]
-> memory [normal]

View File

@ -0,0 +1,202 @@
crates/llm-worker-macros/src/lib.rs:257: pub fn #definition_name(&self) -> ::llm_worker::tool::ToolDefinition {
crates/provider/src/codex_oauth/error.rs:49: pub fn to_client_error(self) -> ClientError {
crates/session-store/src/system_item.rs:114:pub fn render_pod_event(event: &PodEvent) -> String {
crates/memory/src/tool/read.rs:182:pub fn read_tool(layout: WorkspaceLayout) -> ToolDefinition {
crates/provider/src/codex_oauth/mod.rs:62: pub fn from_default_home() -> Result<Self, ClientError> {
crates/llm-worker/src/interceptor.rs:97:pub struct ToolCallInfo {
crates/llm-worker/src/interceptor.rs:107:pub struct ToolResultInfo {
crates/memory/src/tool/write.rs:188:pub fn write_tool(layout: WorkspaceLayout) -> ToolDefinition {
crates/session-store/src/lib.rs:69:pub type SessionId = uuid::Uuid;
crates/session-store/src/lib.rs:75:pub fn new_session_id() -> SessionId {
crates/memory/src/tool/query.rs:473:pub fn memory_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
crates/memory/src/tool/query.rs:488:pub fn knowledge_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
crates/session-store/src/pod_metadata.rs:18:pub struct PodActiveSegmentRef {
crates/session-store/src/pod_metadata.rs:26: pub fn pending_segment(session_id: SessionId) -> Self {
crates/session-store/src/pod_metadata.rs:34: pub fn active_segment(session_id: SessionId, segment_id: SegmentId) -> Self {
crates/session-store/src/pod_metadata.rs:46:pub struct PodSpawnedScopeRule {
crates/session-store/src/pod_metadata.rs:55:pub struct PodSpawnedChild {
crates/session-store/src/pod_metadata.rs:64:pub struct PodMetadata {
crates/session-store/src/pod_metadata.rs:74: pub fn new(pod_name: impl Into<String>, active: Option<PodActiveSegmentRef>) -> Self {
crates/session-store/src/pod_metadata.rs:88:pub trait PodMetadataStore: Send + Sync {
crates/memory/src/tool/mod.rs:33:pub enum MemoryToolKind {
crates/session-store/src/segment_log.rs:174:pub struct PodScopeSnapshot {
crates/llm-worker/src/worker.rs:57:pub enum ToolRegistryError {
crates/llm-worker/src/worker.rs:483: pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
crates/llm-worker/src/worker.rs:528: pub fn tool_server_handle(&self) -> ToolServerHandle {
crates/llm-worker/src/worker.rs:1646: pub fn set_tool_output_limits(&mut self, limits: Option<ToolOutputLimits>) {
crates/memory/src/tool/edit.rs:268:pub fn edit_tool(layout: WorkspaceLayout) -> ToolDefinition {
crates/llm-worker/src/llm_client/transport.rs:98: pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
crates/memory/src/tool/delete.rs:98:pub fn delete_tool(layout: WorkspaceLayout) -> ToolDefinition {
crates/llm-worker/src/lib.rs:56:pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
crates/llm-worker/src/lib.rs:57:pub use handler::ToolUseBlockStart;
crates/llm-worker/src/lib.rs:60:pub use tool::{ToolCall, ToolOutputLimits, ToolResult};
crates/memory/src/scope.rs:19:pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
crates/llm-worker/src/llm_client/error.rs:7:pub enum ClientError {
crates/llm-worker/src/llm_client/error.rs:107:pub fn is_retryable(error: &ClientError) -> bool {
crates/llm-worker/src/tool.rs:16:pub enum ToolError {
crates/llm-worker/src/tool.rs:48:pub struct ToolOutputLimits {
crates/llm-worker/src/tool.rs:99:pub struct ToolOutput {
crates/llm-worker/src/tool.rs:135:pub struct ToolMeta {
crates/llm-worker/src/tool.rs:190:pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Sync>;
crates/llm-worker/src/tool.rs:245:pub trait Tool: Send + Sync {
crates/llm-worker/src/tool.rs:265:pub struct ToolCall {
crates/llm-worker/src/tool.rs:279:pub struct ToolResult {
crates/llm-worker/src/tool.rs:294: pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
crates/memory/src/error.rs:10:pub enum MemoryError {
crates/pod-registry/src/error.rs:11:pub enum ScopeLockError {
crates/llm-worker/src/llm_client/capability.rs:41:pub enum ToolCallingSupport {
crates/llm-worker/src/tool_server.rs:13:pub enum ToolServerError {
crates/llm-worker/src/tool_server.rs:27:pub struct ToolServer {
crates/llm-worker/src/tool_server.rs:39: pub fn handle(&self) -> ToolServerHandle {
crates/llm-worker/src/tool_server.rs:49:pub struct ToolServerHandle {
crates/llm-worker/src/tool_server.rs:108: pub fn get_tool(&self, name: &str) -> Option<(ToolMeta, Arc<dyn Tool>)> {
crates/llm-worker/src/tool_server.rs:137: pub fn unregister(&self, name: &str) -> Result<(), ToolServerError> {
crates/llm-worker/src/tool_server.rs:150: pub fn replace(&self, factory: WorkerToolDefinition) -> Result<(), ToolServerError> {
crates/pod-registry/src/lifecycle.rs:18:pub struct ScopeAllocationGuard {
crates/pod-registry/src/lifecycle.rs:129:pub fn update_segment(pod_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> {
crates/pod-registry/src/lifecycle.rs:164:pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>, ScopeLockError> {
crates/llm-worker/src/callback.rs:191:pub struct ToolUseBlockScope {
crates/llm-worker/src/callback.rs:212: pub fn on_stop(&mut self, f: impl FnMut(&ToolCall) + Send + Sync + 'static) {
crates/memory/src/lib.rs:21:pub use error::{LintError, LintWarning, MemoryError};
crates/pod-registry/src/lib.rs:28:pub use error::ScopeLockError;
crates/llm-worker/examples/record_test_fixtures/recorder.rs:23:pub struct SessionMetadata {
crates/pod-registry/src/mutate.rs:161:pub fn release_pod(guard: &mut LockFileGuard, pod_name: &str) -> Result<(), ScopeLockError> {
crates/llm-worker/src/timeline/tool_call_collector.rs:30:pub struct ToolCallCollector {
crates/llm-worker/src/timeline/tool_call_collector.rs:44: pub fn take_collected(&self) -> Vec<ToolCall> {
crates/llm-worker/src/timeline/tool_call_collector.rs:50: pub fn collected(&self) -> Vec<ToolCall> {
crates/pod-registry/src/conflict.rs:50:pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule) -> bool {
crates/memory/src/extract/tool.rs:92:pub fn write_extracted_tool(ctx: Arc<ExtractWorkerContext>) -> ToolDefinition {
crates/llm-worker/src/timeline/mod.rs:23:pub use tool_call_collector::ToolCallCollector;
crates/workflow/src/skill.rs:74: pub fn into_workflow_record(self, source: WorkflowSource) -> WorkflowRecord {
crates/llm-worker/src/handler.rs:158:pub struct ToolUseBlockKind;
crates/llm-worker/src/handler.rs:165:pub enum ToolUseBlockEvent {
crates/llm-worker/src/handler.rs:173:pub struct ToolUseBlockStart {
crates/llm-worker/src/handler.rs:180:pub struct ToolUseBlockStop {
crates/tui/src/input.rs:63:pub struct WorkflowInvokeAtom {
crates/workflow/src/schema.rs:12:pub struct WorkflowFrontmatter {
crates/workflow/src/schema.rs:45:pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> {
crates/client/src/lib.rs:14:pub use pod_client::PodClient;
crates/workflow/src/workflow.rs:29:pub enum WorkflowSource {
crates/workflow/src/workflow.rs:50:pub struct WorkflowRecord {
crates/workflow/src/workflow.rs:93:pub struct WorkflowRegistry {
crates/workflow/src/workflow.rs:110: pub fn get(&self, slug: &Slug) -> Option<&WorkflowRecord> {
crates/workflow/src/workflow.rs:114: pub fn iter(&self) -> impl Iterator<Item = &WorkflowRecord> {
crates/workflow/src/workflow.rs:143: pub fn merge_skill(&mut self, record: WorkflowRecord) -> Option<ShadowedSkill> {
crates/workflow/src/workflow.rs:165:pub enum WorkflowLoadError {
crates/workflow/src/workflow.rs:191:pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
crates/client/src/pod_client.rs:9:pub struct PodClient {
crates/workflow/src/scope.rs:10:pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
crates/tui/src/block.rs:98:pub struct ToolCallBlock {
crates/tui/src/block.rs:113:pub enum ToolCallState {
crates/workflow/src/error.rs:10:pub enum WorkflowLintError {
crates/memory/src/workspace.rs:90: pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
crates/workflow/src/lib.rs:10:pub use error::WorkflowLintError;
crates/workflow/src/lib.rs:12:pub use linter::{WorkflowLintReport, WorkflowLinter};
crates/workflow/src/lib.rs:13:pub use schema::{WorkflowFrontmatter, split_frontmatter};
crates/workflow/src/linter.rs:15:pub struct WorkflowLintReport {
crates/workflow/src/linter.rs:24: pub fn push_error(&mut self, err: WorkflowLintError) {
crates/workflow/src/linter.rs:30:pub struct WorkflowLinter {
crates/workflow/src/linter.rs:47: pub fn lint(&self, content: &str) -> WorkflowLintReport {
crates/tui/src/tool.rs:22:pub struct ToolRenderOutput {
crates/tui/src/app.rs:193: pub fn set_pod_status(&mut self, status: PodStatus) {
crates/tools/src/task.rs:464:pub fn task_tools(store: TaskStore) -> Vec<ToolDefinition> {
crates/tools/src/bash.rs:330:pub fn bash_tool(fs: ScopedFs, output_dir: PathBuf) -> ToolDefinition {
crates/llm-worker/src/llm_client/scheme/openai_chat/events.rs:68: pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
crates/manifest/src/scope.rs:22:pub struct Scope {
crates/manifest/src/scope.rs:37:pub enum ScopeError {
crates/manifest/src/scope.rs:58: pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
crates/manifest/src/scope.rs:151: pub fn allow_rules(&self) -> Vec<ScopeRule> {
crates/manifest/src/scope.rs:168: pub fn deny_rules(&self) -> Vec<ScopeRule> {
crates/manifest/src/scope.rs:320: pub fn new(scope: Scope) -> Self {
crates/manifest/src/scope.rs:331: pub fn load(&self) -> Guard<Arc<Scope>> {
crates/manifest/src/scope.rs:338: pub fn snapshot(&self) -> Arc<Scope> {
crates/manifest/src/scope.rs:347: pub fn update<F>(&self, f: F) -> Result<(), ScopeError>
crates/tools/src/lib.rs:35:pub use error::ToolsError;
crates/tools/src/lib.rs:39:pub use scoped_fs::ScopedFs;
crates/tools/src/tracker.rs:116: pub fn verify(&self, path: &Path, current_bytes: &[u8]) -> Result<(), ToolsError> {
crates/llm-worker/src/llm_client/types.rs:573: pub fn tool(mut self, tool: ToolDefinition) -> Self {
crates/llm-worker/src/llm_client/types.rs:638:pub struct ToolDefinition {
crates/tools/src/read.rs:117:pub fn read_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
crates/tools/src/glob.rs:196:pub fn glob_tool(fs: ScopedFs) -> ToolDefinition {
crates/tools/src/grep.rs:106:pub fn grep_tool(fs: ScopedFs) -> ToolDefinition {
crates/llm-worker/src/llm_client/client.rs:39:pub type ResponseStream = Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>;
crates/tools/src/write.rs:78:pub fn write_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
crates/manifest/src/lib.rs:19:pub use protocol::{Permission, ScopeRule};
crates/manifest/src/lib.rs:20:pub use scope::{Scope, ScopeError, SharedScope};
crates/manifest/src/lib.rs:35:pub struct PodManifest {
crates/manifest/src/lib.rs:90:pub struct MemoryConfig {
crates/manifest/src/lib.rs:153:pub struct PodMeta {
crates/manifest/src/lib.rs:223:pub struct ToolOutputLimits {
crates/manifest/src/lib.rs:295:pub struct ScopeConfig {
crates/manifest/src/lib.rs:307:pub struct SessionConfig {
crates/manifest/src/lib.rs:320:pub struct ToolPermissionConfig {
crates/manifest/src/lib.rs:328:pub struct ToolPermissionRule {
crates/manifest/src/lib.rs:341:pub enum ToolPermissionAction {
crates/tools/src/edit.rs:137:pub fn edit_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
crates/tools/src/error.rs:12:pub enum ToolsError {
crates/tools/src/scoped_fs.rs:34:pub struct ScopedFs {
crates/tools/src/scoped_fs.rs:67: pub fn new(scope: Scope, pwd: PathBuf) -> Self {
crates/tools/src/scoped_fs.rs:83: pub fn scope(&self) -> Arc<Scope> {
crates/tools/src/scoped_fs.rs:108: pub fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
crates/tools/src/scoped_fs.rs:160: pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsError> {
crates/manifest/src/config.rs:28:pub struct PodManifestConfig {
crates/manifest/src/config.rs:58:pub struct PodMetaConfig {
crates/manifest/src/config.rs:95:pub struct ToolOutputLimitsPartial {
crates/manifest/src/config.rs:109:pub struct SessionConfigPartial {
crates/manifest/src/config.rs:282: pub fn merge(self, upper: PodManifestConfig) -> Self {
crates/pod/src/controller.rs:34:pub struct PodHandle {
crates/pod/src/controller.rs:130:pub struct PodController;
crates/protocol/src/lib.rs:77:pub enum PodEvent {
crates/protocol/src/lib.rs:492:pub struct MemoryWorkerEvent {
crates/protocol/src/lib.rs:575:pub enum PodStatus {
crates/protocol/src/lib.rs:649:pub struct ScopeRule {
crates/manifest/src/cascade.rs:63:pub fn load_layer(path: &Path) -> Result<PodManifestConfig, LayerLoadError> {
crates/pod/src/ipc/event.rs:46:pub fn fire_and_forget(socket: Option<PathBuf>, event: PodEvent) {
crates/pod/src/ipc/event.rs:60:pub fn render_event(event: &PodEvent) -> String {
crates/pod/src/lib.rs:20:pub use controller::{PodController, PodHandle, ShutdownReceiver};
crates/pod/src/lib.rs:21:pub use factory::{FactoryError, PodFactory};
crates/pod/src/lib.rs:28:pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
crates/pod/src/lib.rs:29:pub use prompt::catalog::{CatalogError, PodPrompt, PromptCatalog};
crates/pod/src/lib.rs:32:pub use protocol::{ErrorCode, Event, Method, PodStatus, TurnResult};
crates/pod/src/lib.rs:36:pub use shared_state::PodSharedState;
crates/pod/src/ipc/notify_buffer.rs:68: pub fn push_pod_event(&self, event: PodEvent) {
crates/pod/src/shared_state.rs:10:pub struct WorkflowCandidate {
crates/pod/src/shared_state.rs:29:pub struct PodSharedState {
crates/pod/src/shared_state.rs:67: pub fn set_fs_view(&self, view: PodFsView) {
crates/pod/src/shared_state.rs:73: pub fn fs_view(&self) -> Option<&PodFsView> {
crates/pod/src/shared_state.rs:77: pub fn set_workflows(&self, workflows: Vec<WorkflowCandidate>) {
crates/pod/src/shared_state.rs:81: pub fn list_workflow_completions(&self, prefix: &str) -> Vec<WorkflowCandidate> {
crates/pod/src/shared_state.rs:111: pub fn set_status(&self, status: PodStatus) {
crates/pod/src/shared_state.rs:117: pub fn get_status(&self) -> PodStatus {
crates/pod/src/pod.rs:86: pub fn new(session_id: SessionId, segment_id: SegmentId, entries_written: usize) -> Arc<Self> {
crates/pod/src/pod.rs:100: pub fn session_id(&self) -> SessionId {
crates/pod/src/pod.rs:222:pub struct Pod<C: LlmClient, St: Store> {
crates/pod/src/pod.rs:690: pub fn session_id(&self) -> SessionId {
crates/pod/src/pod.rs:695: pub fn manifest(&self) -> &PodManifest {
crates/pod/src/pod.rs:713: pub fn scope_snapshot(&self) -> Arc<Scope> {
crates/pod/src/pod.rs:791: pub fn scope_change_sink(&self) -> Arc<dyn Fn(PodScopeSnapshot) + Send + Sync> {
crates/pod/src/pod.rs:1056: pub fn push_pod_event_notify(&self, event: protocol::PodEvent) {
crates/pod/src/pod.rs:4061:pub enum PodRunResult {
crates/pod/src/pod.rs:4332:pub enum PodError {
crates/pod/src/discovery.rs:33:pub struct PodDiscovery<St> {
crates/pod/src/discovery.rs:368:pub enum PodStateStatus {
crates/pod/src/discovery.rs:441:pub struct PodDetail {
crates/pod/src/discovery.rs:482:pub enum PodDiscoveryError {
crates/pod/src/discovery.rs:678:pub fn list_visible_pods_tool<St>(discovery: PodDiscovery<St>) -> ToolDefinition
crates/pod/src/discovery.rs:699:pub fn inspect_pod_tool<St>(discovery: PodDiscovery<St>) -> ToolDefinition
crates/pod/src/discovery.rs:716:pub fn attach_or_restore_pod_tool<St>(discovery: PodDiscovery<St>) -> ToolDefinition
crates/pod/src/factory.rs:76:pub struct PodFactory {
crates/pod/src/factory.rs:189: pub fn with_overlay_config(mut self, config: PodManifestConfig) -> Result<Self, FactoryError> {
crates/pod/src/factory.rs:241: pub fn resolve(self) -> Result<(PodManifest, PromptLoader), FactoryError> {
crates/pod/src/hook.rs:75:pub struct ToolCallSummary {
crates/pod/src/hook.rs:90:pub struct ToolResultSummary {
crates/pod/src/fs_view.rs:40:pub struct PodFsView {
crates/pod/src/fs_view.rs:77: pub fn new(fs: ScopedFs) -> Self {
crates/pod/src/fs_view.rs:81: pub fn fs(&self) -> &ScopedFs {
crates/pod/src/workflow/mod.rs:17:pub enum WorkflowResolveError {
crates/pod/src/prompt/catalog.rs:61:pub enum PodPrompt {
crates/pod/src/prompt/catalog.rs:303: pub fn render(&self, prompt: PodPrompt, ctx: Value) -> Result<String, CatalogError> {
crates/pod/src/spawn/comm_tools.rs:94:pub fn send_to_pod_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
crates/pod/src/spawn/comm_tools.rs:169:pub fn read_pod_output_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
crates/pod/src/spawn/comm_tools.rs:229:pub fn stop_pod_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
crates/pod/src/spawn/comm_tools.rs:299:pub fn list_pods_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {

View File

@ -0,0 +1,16 @@
1 client <- tui
2 daemon <-
3 lint-common <- memory, workflow
4 llm-worker <- manifest, memory, pod, provider, session-store, tools, tui
5 llm-worker-macros <- llm-worker
6 manifest <- client, daemon, memory, pod, pod-registry, provider, tools, tui, workflow
7 memory <- pod, workflow
8 pod <-
9 pod-registry <- pod, tui
10 protocol <- client, daemon, manifest, pod, session-store, tui
11 provider <- pod
12 session-metrics <- pod
13 session-store <- pod, pod-registry, session-metrics, tui
14 tools <- pod, tui
15 tui <-
16 workflow <- pod

View File

@ -0,0 +1,16 @@
client <- tui
daemon <-
lint-common <- memory, workflow
llm-worker <- manifest, memory, pod, provider, session-store, tools, tui
llm-worker-macros <- llm-worker
manifest <- client, daemon, memory, pod, pod-registry, provider, tools, tui, workflow
memory <- pod, workflow
pod <-
pod-registry <- pod, tui
protocol <- client, daemon, manifest, pod, session-store, tui
provider <- pod
session-metrics <- pod
session-store <- pod, pod-registry, session-metrics, tui
tools <- pod, tui
tui <-
workflow <- pod

View File

@ -0,0 +1,35 @@
crates/protocol/src/lib.rs:68:/// parent Controller applies variant-specific side effects (registry /
crates/protocol/src/lib.rs:118:/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
crates/protocol/src/lib.rs:120:/// send them through directly so the Pod side never has to re-parse a
crates/protocol/src/lib.rs:124:/// `Segment::Unknown`. Pod treats this the same as known-but-unresolved
crates/protocol/src/lib.rs:152: /// Unknown variant from a newer client. Pod treats this as an
crates/protocol/src/lib.rs:224: /// additional TUI / GUI instances show the same pending user line
crates/protocol/src/lib.rs:237: /// Carries the JSON form of `session_store::SystemItem`. Covers
crates/protocol/src/lib.rs:394: /// as the JSON form of `session_store::LogEntry`. This is the
crates/protocol/src/lib.rs:419: /// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
crates/protocol/src/lib.rs:459: /// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
crates/llm-worker/src/llm_client/types.rs:523: /// 会話単位の安定キー。`prompt_cache_key` として送られる
crates/llm-worker/src/llm_client/types.rs:526: /// ほぼヒットしないため、pod 側で `SegmentId` を渡す運用を想定。
crates/llm-worker/src/llm_client/types.rs:529: /// `prompt_cache_key` を持たない provider は無視する。
crates/session-store/src/segment.rs:11:use crate::{SegmentId, SessionId};
crates/session-store/src/segment.rs:29:) -> Result<(SessionId, SegmentId), StoreError> {
crates/session-store/src/segment.rs:44: segment_id: SegmentId,
crates/session-store/src/segment.rs:69: source_segment_id: SegmentId,
crates/session-store/src/segment.rs:71:) -> Result<SegmentId, StoreError> {
crates/session-store/src/segment.rs:96: segment_id: SegmentId,
crates/session-store/src/segment.rs:109: segment_id: SegmentId,
crates/session-store/src/segment.rs:146: segment_id: &mut SegmentId,
crates/session-store/src/segment.rs:184: segment_id: SegmentId,
crates/session-store/src/segment.rs:209: segment_id: SegmentId,
crates/session-store/src/segment.rs:258: segment_id: SegmentId,
crates/session-store/src/segment.rs:276: segment_id: SegmentId,
crates/session-store/src/segment.rs:294: segment_id: SegmentId,
crates/session-store/src/segment.rs:317: segment_id: SegmentId,
crates/session-store/src/segment.rs:342: segment_id: SegmentId,
crates/session-store/src/segment.rs:372: segment_id: SegmentId,
crates/session-store/src/segment.rs:392: segment_id: SegmentId,
crates/session-store/src/segment.rs:409: segment_id: SegmentId,
crates/session-store/src/segment.rs:432:) -> Result<(SessionId, SegmentId), StoreError> {
crates/session-store/src/segment.rs:466: source_id: SegmentId,
crates/session-store/src/segment.rs:468:) -> Result<SegmentId, StoreError> {
crates/session-store/src/segment.rs:511: segment_id: SegmentId,

View File

@ -0,0 +1,47 @@
---
id: 20260528-131317-crate-boundary-audit
slug: crate-boundary-audit
title: Audit crate responsibility boundaries
status: open
kind: audit
priority: P2
labels: [architecture, crates]
created_at: 2026-05-28T13:13:17Z
updated_at: 2026-05-28T13:13:17Z
assignee: null
legacy_ticket: null
---
## Background
The workspace has grown across multiple crates (`pod`, `protocol`, `llm-worker`, `manifest`, `client`, `tui`, `memory`, `workflow`, etc.). Before adding more orchestration and policy features, audit whether crate responsibilities, dependency direction, and public interfaces are still clean.
This is an architecture audit, not an implementation ticket. The output should be actionable findings: either concrete boundary violations to fix, or an explicit statement that the inspected area is acceptable.
The audit must also check code comments and documentation comments. Comments inside one crate should not explain or justify behavior primarily in terms of a downstream crate that depends on it. If such comments exist, record them because they can indicate inverted ownership or an interface that is leaking caller-specific concerns.
## Scope
Inspect the Rust workspace at least for:
- crate dependency graph and suspicious dependency direction.
- public types/functions/modules whose names or contracts expose another crate's implementation details unnecessarily.
- code paths where a lower-level crate appears to know about higher-level orchestration, UI, or caller concerns.
- comments/doc-comments that mention another crate which depends on the current crate, especially when the comment describes why the dependent crate needs that behavior.
- duplicated interfaces or ad-hoc glue that should be owned by a clearer boundary.
Out of scope:
- broad refactoring.
- formatting-only changes.
- changing dependency direction before findings are reviewed.
- rewriting comments unless a follow-up implementation ticket is explicitly created.
## Acceptance criteria
- A dependency/interface audit summary exists with concrete findings grouped by severity.
- The audit names files/modules/functions/comments involved in each finding.
- The audit distinguishes actual boundary problems from acceptable dependency-aware documentation.
- The audit specifically reports whether comments in crates refer to crates that depend on them.
- If no blocking issue is found, the audit explains why the current separation is acceptable.
- Follow-up implementation tickets are proposed only for findings that are specific and actionable.

View File

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

View File

@ -0,0 +1,67 @@
---
id: 20260528-152959-web-search-fetch-tools
slug: web-search-fetch-tools
title: Add WebSearch and WebFetch tools
status: open
kind: task
priority: P2
labels: [tools, web, llm]
created_at: 2026-05-28T15:29:59Z
updated_at: 2026-05-28T15:29:59Z
assignee: null
legacy_ticket: null
---
## Background
Insomnia currently has strong local filesystem / shell / memory tools, but the agent cannot directly consult current web information except through user-provided excerpts or shell commands. Add first-class WebSearch and WebFetch tools so the model can gather public web information through bounded, observable tool calls.
This should be implemented as normal built-in tools, not as hidden context injection. Tool calls and results must remain visible in history, subject to manifest permission policy, and bounded by output limits.
## Requirement
- Add `WebSearch` tool.
- Input includes query string and optional result limit.
- Output returns structured results: title, URL, snippet/summary, source/search provider metadata where available.
- Search provider must be configurable. If no provider/API key is configured, the tool should fail with a clear diagnostic instead of falling back to scraping arbitrary search pages.
- Add `WebFetch` tool.
- Input includes URL and optional mode/limits.
- Output returns normalized text content plus metadata such as final URL, status, content type, title if available, and byte/token truncation indication.
- HTML should be converted to readable text. Non-text content should be rejected or summarized only when a safe explicit handler exists.
- Add manifest configuration for web tools.
- Enable/disable controls.
- Search provider/API key configuration.
- Fetch timeout, max response bytes, max output bytes/tokens, redirect limit.
- Allowed/denied URL schemes and host policy.
- Integrate with built-in tool registration and manifest permission policy.
- Web tools are normal tool calls and should go through the existing tool permission mechanism.
- No implicit network access should happen outside a tool call.
- Add security and reliability protections.
- Only `http`/`https` by default.
- Reject local/private/link-local/loopback addresses by default unless explicitly configured.
- Bound redirects and re-check final URLs.
- Bound download size and output size.
- Provide clear errors for timeout, DNS/network failure, unsupported content, blocked host/scheme, and truncation.
- Prompts/tool descriptions should tell the model when to use WebSearch vs WebFetch and that fetched content may be stale/untrusted.
## Acceptance criteria
- `WebSearch` and `WebFetch` are registered built-in tools when enabled/configured.
- Tool schemas are typed and validated.
- Manifest docs/config examples describe how to enable/configure web tools.
- Permission policy can allow/deny/ask these tools like other tools.
- Tool results are bounded and visible in history; no hidden web context is injected.
- Unit tests cover input validation, disabled/unconfigured errors, URL policy, redirect/final URL policy, output truncation, and representative HTML-to-text conversion.
- At least one integration-style test uses a local test HTTP server or mock provider rather than the public internet.
- `cargo fmt --check`
- `cargo check -p tools -p manifest -p pod`
- Relevant focused tests for tools/manifest.
## Out of scope
- Browser automation.
- Authenticated browsing / cookies / sessions.
- Javascript rendering.
- File downloads as attachments.
- Using arbitrary shell commands as the primary web access path.
- Hidden pre-request browsing or automatic web context injection.

View File

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