Compare commits

...

14 Commits

22 changed files with 1688 additions and 457 deletions

View File

@ -1,148 +1,143 @@
--- ---
description: TODO.md と tickets/ から半自動で実装候補を選び、worktree・実装 Pod・reviewer・人間確認を使い分けて完了候補まで進める description: TODO / tickets / docs / git history から次の作業候補を見繕い、課題発見や方針決定を半自動でイテレーションする WIP maintainer workflow
model_invokation: false model_invokation: false
user_invocable: true user_invocable: true
requires: [] requires: []
--- ---
# Auto Maintain Workflow # Auto Maintain Workflow (WIP)
半自動 maintainer として、`TODO.md` と `tickets/` を俯瞰し、実装できる作業を選び、必要に応じて worktree / 実装 Pod / reviewer Pod を orchestration する。最終判断、git commit / merge / push、ticket 完了削除は人間に戻す insomnia を AI maintainer として運用するための半自動 loop。TODO / tickets から「今進められそうな作業」を選ぶだけでなく、課題の発見、設計判断の切り分け、次に人間へ戻すべき問いの整理までを扱う
の Workflow は常駐 scheduler ではない。ユーザーが `/auto-maintain` を明示的に呼んだ時だけ動く れは unattended 自動開発ではない。実装の並列委譲は `multi-agent-workflow`、worktree の機械的作成は `worktree-workflow` に任せる。本 Workflow はその前段として、何を進めるべきか、何をまだ決めるべきかを整理する
この Workflow は親 Pod / orchestrator 専用である。実装 Pod に `/auto-maintain` を渡してはならない。実装 Pod には、親 Pod が選んだ ticket、既に用意した child worktree、許可された write scope、禁止事項を具体的に渡す。 参照:
## 基本方針 - `docs/plan/ai-maintainer.md`
- `tickets/auto-maintain-workflow.md`
- main workspace は制御面として扱う。 ## 位置づけ
- `.insomnia/`
- maintainer inbox / trial log
- 実装差分は原則 child git worktree に隔離する。
- child worktree には `.insomnia` を置かない。必要なら `/worktree-workflow` の手順に従い sparse checkout で `.insomnia` を除外する。
- 実装 Pod と reviewer Pod は原則分ける。ただし scope 衝突や作業粒度により、親 Pod が review してよい。
- review artifact や完了候補の記録は main workspace 側に置き、実装 Pod には書かせない。
- git commit / merge / push / ticket 完了削除は行わず、人間へ完了候補として報告する。
## 事前調査 AI maintainer の目的は、コードを書くこと自体ではなく、プロジェクト状態を前に進めることである。
最初に以下を読む。 この Workflow は WIP として、以下を行う。
- TODO / tickets / docs / git history を読んで現在地を把握する。
- 実装可能な ticket と、方針決定が必要な ticket を分ける。
- 小さく実装できる候補を提案する。
- 設計相談が必要な論点を人間に戻す。
- 運用上の問題や繰り返し発生する詰まりを report / ticket / workflow 改訂候補として整理する。
## 非目標
現時点では以下をしない。
- 常駐 scheduler として自動実行する。
- 人間の合意なしに新規 ticket を作る。
- 人間の合意なしに既存 ticket を大幅変更する。
- 人間の合意なしに ticket 完了削除を行う。
- push する。
- Workflow を自律生成・自律改訂する。
- scope / permission / history persistence / prompt context 加工原則に関わる判断を勝手に決める。
## 入力として読むもの
必要に応じて以下を読む。
1. `TODO.md` 1. `TODO.md`
2. 対象候補の `tickets/*.md` 2. `tickets/*.md`
3. 関連 docs / report 3. `docs/plan/`
4. 既存 review file があれば `tickets/*.review.md` 4. `docs/report/`
5. `git log --oneline` / ticket file の git history
6. 既存 worktree / branch 状態
7. 最近の失敗や通知、ユーザーからの観測
TODO と tickets の不整合を見つけた場合は、勝手に ticket を作成・削除せず、人間へ報告する。 TODO と ticket の不整合を見つけたら、勝手に修正せず、まず報告する。ただしユーザーが明示的に「直して」と言った場合は Mode 1 として整理してよい
## 着手候補の選び方 ## 分類
優先する作業: 候補を以下に分ける。
- ticket の要件と完了条件が具体的 ### A. 実装委譲可能
- 影響範囲が限定的
- build / test で確認しやすい
- scope / permission / history 永続化 / prompt context 加工原則に触れない
- narrow write scope を切りやすい
初回試走や小さな運用確認では、TUI 表示改善など局所的な ticket を優先する。 - 要件と完了条件が具体的。
- 影響範囲が限定的。
- test / build で確認できる。
- 大きな設計判断が不要。
- scope を狭く切れる。
避ける、または人間確認してから進める作業: この場合は、人間に候補として提示する。人間が実行を許可したら `$user/multi-agent-workflow` に進む。
- 複数の設計方針が自然に導け、将来構造に影響する ### B. 方針決定が必要
- permission / scope / Pod lifecycle / history persistence / prompt context 加工原則に触れる
- manifest cascade や protocol wire を広く変える - 複数の設計方針が自然に導ける。
- test 不能または再現不能 - protocol / permission / scope / persistence / prompt context に触れる。
- ticket 自体の大幅な要件変更が必要 - UX の仕様が未確定。
- 既存 ticket の要件が古い。
この場合は、実装せず、決めるべき問いを短く提示する。
### C. ticket 整理が必要
- TODO にあるが ticket がない。
- ticket があるが TODO にない。
- 完了済みに見えるが残っている。
- ticket の前提が変わっている。
この場合は、不整合と修正案を提示する。修正は人間の許可後に行う。
### D. report / workflow 改善候補
- 同じ tool 問題が繰り返し出る。
- Workflow の指示が曖昧で実装 Pod が迷った。
- AI が過剰に Task tool を使うなど、運用上の癖が出た。
- 通知や Pod completion tracking など、開発基盤の不足が観測された。
この場合は、すぐ ticket 化するか、`docs/report/` に観測として残すか、人間に確認する。
## 半自動 iteration
1. 状態把握
- TODO / tickets / git status を読む。
- 最近完了した流れや未完了 branch を確認する。
2. 候補抽出
- 実装可能そうな ticket を 2〜5 件挙げる。
- correctness / developer experience / user-visible UX / cleanup で分類する。
3. 推奨順位
- blocking correctness を最優先。
- 実害が出ている運用問題を次点。
- 小さく完了できる UX / cleanup を次点。
- 大きな設計変更は方針相談に回す。
4. 人間への提示
- 「次に進めるなら X」を1つ推奨する。
- 理由を短く述べる。
- 実装委譲する場合の scope / test 方針を添える。
5. 実行への接続
- 人間が「進めて」と言ったら `$user/multi-agent-workflow` に接続する。
- worktree 作成は `$user/worktree-workflow` に従う。
## エスカレーション基準 ## エスカレーション基準
以下では作業を止めて人間へ確認する。 以下では実装に進まず、人間へ戻す
- ticket の要件から複数の設計方針が自然に導ける - ticket の要件から複数の設計方針が自然に導ける。
- scope / permission / history 永続化 / prompt context 加工原則など安全モデルに触れる - 長期構造、crate boundary、protocol、permission、scope、history persistence に触れる。
- 新 ticket 追加、既存 ticket の大幅変更、ticket 完了削除が必要 - prompt context 加工原則に関わる。
- git commit / merge / push / branch cleanup などの git 書き込み操作が必要 - 新 ticket の作成、既存 ticket の大幅変更、ticket 完了削除について合意がない。
- `git worktree add` など、作業ツリー作成の許可が明示されていない - test 不能、再現不能、または作業範囲外の不具合に遭遇した。
- テスト不能、再現不能、作業範囲外の不具合に遭遇した - WorkItem / Thread / Lease / maintainer state など、まだ設計中の概念が必要になる。
- child worktree に `.insomnia` が出てしまった
## Worktree 作成
実装差分を隔離する必要がある場合、親 Pod が `/worktree-workflow` の手順を使う。実装 Pod に worktree 作成を任せてはならない。要点は以下。 ## まだ固定しないもの
```bash 以下は `docs/plan/ai-maintainer.md` の上位設計に残し、本 Workflow では詳細を固定しない。
git worktree add .worktree/<task-name> -b <task-name>
git -C .worktree/<task-name> sparse-checkout init --no-cone - WorkItemStore / LeaseStore。
git -C .worktree/<task-name> sparse-checkout set --no-cone \ - operation inbox / trial log。
'/*' \ - QA feedback を ticket / review / report のどれに落とすか。
'!/.insomnia/' \ - AI 自身の feedback を Knowledge / report / ticket / workflow 改訂のどれにするか。
'!/.insomnia/**' - maintainer doctor。
``` - reviewer Pod の評価基準の機械化。
`git worktree add` は git 書き込み操作なので、ユーザーが明示的に許可していない場合は実行前に確認する。
## 実装 Pod の spawn
実装 Pod を使う場合は、対象 ticket、既に作成済みの child worktree path、write scope、禁止事項を明示する。実装 Pod は `/auto-maintain``/worktree-workflow` を実行せず、与えられた worktree 内で実装・確認・報告だけを行う。
推奨 scope:
- read: main workspace 全体
- write: child worktree 内の必要最小ディレクトリ
実装 Pod への依頼には以下を含める。
- 対象 ticket と完了条件
- 作業対象は child worktree であり、Bash は `cd <child-worktree> && ...` で実行すること
- `.insomnia` / `TODO.md` / `tickets/` / review artifact / inbox は書かないこと
- git commit / merge / push はしないこと
- build / test / format の結果を報告すること
- 未解決事項と人間確認が必要な点を報告すること
子 Pod の出力は `ReadPodOutput` で確認し、追加依頼が必要なら `SendToPod` を使う。不要になった Pod は `StopPod` する。
## Review
実装 Pod 完了後、原則として実装 Pod を停止して scope を回収してから review する。
reviewer Pod を使う場合は read-only を基本にする。reviewer には以下を依頼する。
- ticket の背景・要件・完了条件を読む
- diff が要件を満たすか確認する
- 既存設計を歪めていないか確認する
- 不必要な実装や過剰な抽象がないか確認する
- build / test 結果が妥当か確認する
review artifact を書く場合は、親 Pod が main workspace 側に書く。child worktree 内の `tickets/` に書く運用は scope 衝突を起こしやすいため避ける。
## 修正依頼
review 指摘があれば、指摘内容をまとめて実装 Pod に再依頼する。既存 Pod を止めている場合は、同じ child worktree と narrow write scope で再 spawn する。
修正後は build / test を再確認し、必要なら再 review する。
## 完了候補報告
最後は作業を完了削除せず、次の形式で人間へ報告する。
```text
完了候補:
- ticket: <path>
- worktree: <path>
- branch: <name>
- 実装概要: ...
- 変更ファイル: ...
- build/test: ...
- review: approve / changes requested / skipped
- 未解決事項: ...
- 人間に戻す判断:
- commit するか
- merge するか
- ticket / review file を更新または削除するか
- TODO.md から削除するか
```
## 試走記録
この Workflow 自体の試走では、対象 ticket、scope 方針、実装 Pod / reviewer の使い分け、停止した理由、不足点を記録する。不足が見つかっても、この Workflow 内で永続ジョブキュー、scope handoff、Pod sleep、ticket lifecycle 再設計を実装しない。必要なら別 ticket として人間に提案する。

View File

@ -0,0 +1,150 @@
---
description: worktree と子 Pod を使って複数 ticket の実装・レビュー・修正・完了処理を並列に進める orchestration フロー
model_invokation: true
user_invocable: true
requires: []
---
# Multi-agent Worktree Workflow
insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod review の標準フロー。これは **実装を並列に進めるためのフロー** であり、worktree の機械的作成手順は `$user/worktree-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
## 目的
- 実装差分を ticket ごとの child worktree に隔離する。
- 実装 Pod に narrow write scope を渡して並列実装させる。
- 親 Pod が diff / test / ticket 要件を review し、必要なら修正依頼する。
- approve 後に merge / ticket 完了処理 / main workspace での再検証を行う。
## 開始条件
以下が揃っている時に使う。
- 対象 ticket が決まっている。
- ticket の背景・要件・完了条件から実装方針が概ね導ける。
- worktree 作成と git 書き込み操作について、人間の許可がある。
- main workspace の unrelated dirty changes を把握している。
設計方針が複数自然に導ける場合、protocol / scope / permission / history persistence に触れる場合、ticket 自体の再定義が必要な場合は、実装委譲前に人間へ戻す。
## 親 Pod / orchestrator の責務
1. 状態確認
- `git status --short --branch`
- 対象 ticket
- 関連 TODO / docs / 既存 worktree
2. worktree 作成
- `$user/worktree-workflow` に従い `./.worktree/<task-name>` を作る。
- `.insomnia` を sparse checkout で除外する。
3. 実装 Pod spawn
- read scope: main workspace 全体。
- write scope: child worktree、または必要最小 directory。
- task には以下を明示する。
- child worktree path / branch
- 対象 ticket path
- Bash は必ず child worktree に `cd` すること
- main workspace の `TODO.md` / `tickets/` / `docs/report/` / `.insomnia` は編集しないこと
- 範囲外事項
- 実行すべき build / test / format
- 完了報告項目
4. 監督
- `ReadPodOutput` で報告を読む。
- 通知が来ない場合でも、worktree の `git status` / `git diff` / test で完了状態を確認する。
- 必要なら `SendToPod` で修正依頼する。
5. review
- ticket の背景・要件・完了条件・範囲外に照らして diff を確認する。
- build / test / `git diff --check` を確認する。
- 必要なら reviewer Pod を read-only で立てる。
6. merge / lifecycle
- approve 後に main workspace へ merge する。
- `TODO.md` から該当行を削除し、`tickets/foo.md` を削除して完了 commit を作る。
- main workspace で必要な test / `cargo check --workspace` / `cargo fmt --check` を再実行する。
## 実装 Pod の責務
- child worktree 内でのみ実装する。
- main workspace の管理ファイルを書かない。
- 指定された build / test / format を実行する。
- ticket 要件外の設計変更、依存関係追加、scope / permission / history persistence / prompt context 加工原則に触れる変更が必要なら止めて報告する。
- 完了時に以下を報告する。
- worktree path / branch
- commit hashcommit した場合)
- 変更ファイル
- 実装概要
- 実行した build / test / format
- 未解決事項
- review に回せるか
## 実装 Pod の commit 方針
実装 Pod には child worktree 内での commit を許可してよい。
- commit は ticket 内で意味のある粒度にする。
- 例: `feat: ...`、`fix: ...`、`test: ...`、`docs: ...`
- 実装 Pod は merge / push / branch deletion / worktree remove をしない。
- 実装 Pod は `TODO.md` / `tickets/` の完了処理 commit をしない。
- 親 Pod は review 時に commit 粒度も確認する。
- 必要な修正は、原則追加 commit として積む。履歴改変や squash は人間の明示指示がある時だけ行う。
## Review → 修正 → 完了の標準形
### Approve
1. 実装 Pod を停止し、scope を回収する。
2. 親 Pod が main workspace で `git merge --no-ff <branch>` する。
3. 親 Pod が `TODO.md``tickets/foo.md` を完了処理して commit する。
4. main workspace で検証コマンドを再実行する。
5. 変更内容・commit・検証結果・残 dirty changes を報告する。
### Request changes
1. blocking finding をファイル / 行 / 理由 / 修正方針つきで整理する。
2. 実装 Pod が生きていれば `SendToPod` で修正依頼する。
3. 停止済みなら、同じ worktree / branch / scope で再 spawn するか、親 Pod が最小修正する。
4. 修正後に focused test と必要な broader test を再実行する。
5. 再 review する。
### Non-blocking comments
- ticket 要件外の改善はその場で混ぜない。
- 必要なら後続 ticket / docs/report にする。
- non-blocking を理由に completion を遅らせない。
## 並列実装時の注意
- 1 ticket = 1 worktree = 1 branch を基本にする。
- 複数 Pod に同じ write scope を渡さない。
- parent は child の write scope 配下を直接編集しない。
- 依存関係がある ticket は、土台 branch を merge してから次 worktree を切る。
- parallel に走らせた Pod の完了通知は取りこぼしうるため、`ReadPodOutput` と worktree 状態で確認する。
## 完了報告の標準形
```text
完了:
- ticket: <path>
- branch: <name>
- commits:
- <hash> <subject>
- 変更概要: ...
- 検証:
- cargo fmt --check
- cargo check --workspace
- cargo test ...
- review: approve / approve with comments / request changes
- 未解決事項: ...
- 残 dirty changes: ...
```
## この Workflow で扱わないもの
以下は `$user/auto-maintain` または別の設計相談で扱う。
- ticket 候補を見繕うこと。
- 新規 ticket 作成判断。
- QA feedback / AI feedback を ticket / report / workflow に落とす判断。
- 長期 maintainer loop / WorkItemStore / LeaseStore の設計。

View File

@ -1,40 +1,47 @@
--- ---
description: Git worktree を使って実装用作業ツリーを作り、main workspace の管理ファイルと子 worktree のコード差分を分離して開発を進める description: insomnia プロジェクトで child git worktree を作成・管理するための機械的手順。実装 Pod に作らせず、親 Pod が main workspace で実行する。
model_invokation: false model_invokation: false
user_invocable: true user_invocable: true
requires: [] requires: []
--- ---
# Worktree Workflow # Worktree Workflow
Git worktree を使って、実装差分を main workspace から分離して進める。子 worktree はコード変更専用の作業面として扱う。 insomnia プロジェクトで実装差分を main workspace から分離するため、`./.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、実装委譲、review、merge の運用は `$user/multi-agent-workflow` 側で扱う。
insomnia では Pod の write scope が排他的に委譲されるため、子 worktree に `.insomnia` を置かず、親 Pod が main workspace 側の管理ファイルを書ける状態を保つ insomnia では Pod の write scope が排他的に委譲されるため、child worktree に `.insomnia` を置かない。main workspace は orchestration / ticket / docs / memory / workflow 管理の場所として残し、child worktree はコード差分専用の作業面として扱う
この Workflow は親 Pod / orchestrator が worktree を用意するための手順である。実装 Pod にこの Workflow を渡して worktree を作らせてはならない。実装 Pod は親 Pod から既存 child worktree を受け取り、その中で実装・build・test・報告だけを行う。 ## 適用範囲
この Workflow は親 Pod / orchestrator が main workspace で実行する。
- 実装 Pod にこの Workflow を渡して worktree を作らせない。
- 実装 Pod は、親 Pod が作成済みの child worktree を受け取り、その中で実装・build・test・報告を行う。
- ticket 作成、TODO 更新、review artifact、docs/report は main workspace 側で扱う。
## 原則 ## 原則
- 1 セッション / 1 ticket / 1 task につき 1 worktree を作る。 - 1 ticket / 1 実装 task につき 1 worktree を作る。
- worktree は `./.worktree/<task-name>` に作る。 - worktree path `./.worktree/<task-name>`
- branch 名は原則 `<task-name>` と同じにする。 - branch 名は原則 `<task-name>` と同じ kebab-case
- 子 worktree はコード差分専用にし、`TODO.md` / `tickets/` / `docs/report/` / inbox などの管理ファイルは main workspace 側で扱う - child worktree には `.insomnia` を出さない
- 子 worktree には `.insomnia` を出さない。worktree 作成後に sparse checkout で `.insomnia` を除外する - child worktree は実装差分用。`TODO.md` / `tickets/` / `docs/report/` / workflow / memory は原則 main workspace 側で扱う
- git commit / merge / push / branch deletion / worktree remove 、人間が明示た場合以外は行わない。 - push はしない。
## 事前確認 ## 事前確認
前に以下を確認する。 前に以下を確認する。
1. 対象 ticket / task 名が決まっているか。 1. 対象 ticket / task が決まっているか。
2. branch / worktree 名に使える kebab-case の `<task-name>` があるか。 2. `<task-name>` が branch / path 名に使える kebab-case か。
3. git 書き込み操作を行ってよい明示許可があるか。 3. `git worktree add` を実行してよい許可があるか。
4. main workspace の未保存差分や既存 worktree と衝突しないか。 4. main workspace に混ぜてはいけない未保存差分がないか。
5. 同名 branch / worktree が既に存在しないか。
許可が曖昧な場合、`git worktree add` の前に人間へ確認する 同名 branch がある場合は、既存 branch を使うか、人間に確認する。`git worktree add -b` で上書きしない
## 作成手順 ## 作成手順
`<task-name>` を確定したら、main workspace で以下を実行する。 main workspace で実行する。
```bash ```bash
git worktree add .worktree/<task-name> -b <task-name> git worktree add .worktree/<task-name> -b <task-name>
@ -46,44 +53,46 @@ git -C .worktree/<task-name> sparse-checkout set --no-cone \
'!/.insomnia/**' '!/.insomnia/**'
``` ```
既に branch がある場合は `-b` で新規作成せず、既存 branch を使うか人間へ確認する。`git worktree add` が失敗した場合は、worktree / branch / lock の状態を確認してから人間へ報告する。 確認する。
```bash
git -C .worktree/<task-name> status --short --branch
test ! -e .worktree/<task-name>/.insomnia
```
失敗した場合は、worktree / branch / lock の状態を確認し、勝手に cleanup せず人間へ報告する。
## 子 Pod へ渡す scope ## 子 Pod へ渡す scope
子 Pod を使う場合、子 Pod の cwd は現状 main workspace のままになる。子 Pod には作業対象が child worktree であることを明示し、Bash 実行時は必ず `cd .worktree/<task-name> && ...` させる。 子 Pod を使う場合、子 Pod の cwd は main workspace のままになる。必ず作業対象が child worktree であることを明示し、Bash 実行時は毎回 `cd /home/hare/Projects/insomnia/.worktree/<task-name> && ...` させる。
推奨 scope: 推奨 scope:
- read: main workspace 全体
- write: child worktree の必要最小ディレクトリだけ
例:
```text ```text
read: /home/hare/Projects/insomnia read: /home/hare/Projects/insomnia
write: /home/hare/Projects/insomnia/.worktree/<task-name>/crates/tui write: /home/hare/Projects/insomnia/.worktree/<task-name>
``` ```
子 Pod には `tickets/` や inbox を書かせない。review artifact や完了候補の記録は親 Pod が main workspace 側に書く より狭く切れる場合は、write scope を変更対象 crate / directory まで狭めてよい。ただし build / test に必要な生成物を書けることを確認する
## 実装中のルール ## child worktree 内の禁止事項
- child worktree 内で build / test / format を実行する。 - `.insomnia` を作らない / コピーしない。
- main workspace の `.insomnia` や memory は child worktree にコピーしない。 - main workspace の `TODO.md` / `tickets/` / `docs/report/` を編集しない。
- `.insomnia` が child worktree に現れた場合は作業を止め、sparse checkout 設定を確認する。 - merge / push / branch deletion / worktree remove をしない。
- ticket 要件外の設計変更、scope / permission / history 永続化 / prompt context 加工原則に触れる変更は実装前に人間へ確認する。 - scope / permission / history persistence / prompt context 加工原則に関わる設計変更を無断で行わない。
- 依存関係追加や大きな設計変更が必要になった場合も人間へ確認する。
## 完了時 ## 完了時の扱い
実装が終わったら、merge は行わず、以下を報告する。 worktree 作成 Workflow としては、完了時に merge しない。merge、ticket 完了、TODO 削除は `$user/multi-agent-workflow` または人間の明示指示で行う。
実装 Pod へ渡す完了報告項目の標準形:
- worktree path - worktree path
- branch 名 - branch 名
- commit hash実装 Pod に commit を許可した場合)
- 変更ファイル - 変更ファイル
- 実装概要 - 実装概要
- 実行した build / test / format - 実行した build / test / format
- 未解決事項 - 未解決事項
- review に回せるかどうか - review に回せるか
マージウィンドウとして人間が明示的にこの Workflow を呼んだ場合だけ、merge / worktree remove / branch cleanup の候補手順を提示する。実行は人間の明示許可を待つ。

View File

@ -10,8 +10,10 @@
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。 LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
- **許される**: 既存 history から純粋に再現可能な変換器pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。同じ history を入力すれば同じ結果が出る決定的な加工で、history そのものを書き換えるわけでもなく、外から新しい情報を持ち込まない。 Podの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
- **禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。 原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / PodEvent / `<system-reminder>` 系はこの原則で扱う(→ `tickets/notify-history-persist.md`)。 新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / PodEvent / `<system-reminder>` 系はこの原則で扱う(→ `tickets/notify-history-persist.md`)。
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。 また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
@ -26,7 +28,7 @@ LLM に投げる context への割り込みは、大きく2種類に分かれる
## Git操作 ## Git操作
Gitはpush以外のすべての操作が許可されている workflowで明示されない限り、読み取り以外の操作は控えること
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。 基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。 コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。
@ -34,6 +36,8 @@ Gitはpush以外のすべての操作が許可されている。
--- ---
## Ticketの運用について
`TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。 `TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。
### TODO.md ### TODO.md

View File

@ -9,14 +9,12 @@
- Pod: Inbound PodEvent ハンドリングの重複を統合 → [tickets/pod-inbound-pod-event-dedup.md](tickets/pod-inbound-pod-event-dedup.md) - Pod: Inbound PodEvent ハンドリングの重複を統合 → [tickets/pod-inbound-pod-event-dedup.md](tickets/pod-inbound-pod-event-dedup.md)
- llm-worker のエラー耐性 - llm-worker のエラー耐性
- ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md) - ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)
- llm-worker: history append を callback 経由の単一経路に閉じる → [tickets/worker-history-append-contract.md](tickets/worker-history-append-contract.md)
- ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
- E2E テストハーネス(`tests/e2e/`、opt-in → [tickets/e2e-harness.md](tickets/e2e-harness.md) - E2E テストハーネス(`tests/e2e/`、opt-in → [tickets/e2e-harness.md](tickets/e2e-harness.md)
- TUI 拡充 - TUI 拡充
- TUI から任意タイミングで Compact を発火する system command → [tickets/tui-system-command-compact.md](tickets/tui-system-command-compact.md) - navigation mode / block focus の設計 → [tickets/tui-navigation-mode-design.md](tickets/tui-navigation-mode-design.md)
- spawned child Pod の一覧と一時 attach → [tickets/tui-spawned-pod-panel.md](tickets/tui-spawned-pod-panel.md)
- user manifest env override 時の spawn scope overlay 前提ズレ → [tickets/tui-user-manifest-env-overlay.md](tickets/tui-user-manifest-env-overlay.md) - user manifest env override 時の spawn scope overlay 前提ズレ → [tickets/tui-user-manifest-env-overlay.md](tickets/tui-user-manifest-env-overlay.md)
- ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md) - ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md)
- メモリ機構 - メモリ機構
- extract / consolidation 監査ログ → [tickets/memory-audit-log.md](tickets/memory-audit-log.md) - extract / consolidation 監査ログ → [tickets/memory-audit-log.md](tickets/memory-audit-log.md)
- セッション内 Task ツールの注意機構(無アクティビティで `<system-reminder>` ナッジ) → [tickets/session-todo-reminder.md](tickets/session-todo-reminder.md) - セッション内 Task ツールの注意機構(無アクティビティで `<system-reminder>` ナッジ) → [tickets/session-todo-reminder.md](tickets/session-todo-reminder.md)

View File

@ -127,7 +127,7 @@ enum ToolExecutionResult {
/// ///
/// // To edit between turns, unlock back to Mutable /// // To edit between turns, unlock back to Mutable
/// let mut worker = worker.unlock(); /// let mut worker = worker.unlock();
/// worker.history_mut().truncate(5); /// worker.truncate_history(5);
/// let out = worker.run("Continue").await?; /// let out = worker.run("Continue").await?;
/// let mut worker = out.worker; /// let mut worker = out.worker;
/// ``` /// ```
@ -400,7 +400,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
} }
} }
fn extend_history_with_callbacks(&mut self, items: impl IntoIterator<Item = Item>) { fn append_history_items(&mut self, items: impl IntoIterator<Item = Item>) {
for item in items { for item in items {
self.emit_history_append(&item); self.emit_history_append(&item);
self.history.push(item); self.history.push(item);
@ -985,7 +985,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
// get persisted by the upper layer that owns history.json. // get persisted by the upper layer that owns history.json.
let pending = self.interceptor.pending_history_appends().await; let pending = self.interceptor.pending_history_appends().await;
if !pending.is_empty() { if !pending.is_empty() {
self.extend_history_with_callbacks(pending); self.append_history_items(pending);
} }
// Clone the history into a per-request context. Everything // Clone the history into a per-request context. Everything
@ -1093,7 +1093,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
self.turn_count += 1; self.turn_count += 1;
// Collect and commit assistant items. Routed through // Collect and commit assistant items. Routed through
// `extend_history_with_callbacks` so observers (e.g. the // `append_history_items` so observers (e.g. the
// Pod-side per-item session-log committer) see each item // Pod-side per-item session-log committer) see each item
// as it lands. // as it lands.
let reasoning_items = self.reasoning_item_collector.take_collected(); let reasoning_items = self.reasoning_item_collector.take_collected();
@ -1101,7 +1101,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
let tool_calls = self.tool_call_collector.take_collected(); let tool_calls = self.tool_call_collector.take_collected();
let assistant_items = let assistant_items =
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls); self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
self.extend_history_with_callbacks(assistant_items); self.append_history_items(assistant_items);
if tool_calls.is_empty() { if tool_calls.is_empty() {
match self.interceptor.on_turn_end(&self.history).await { match self.interceptor.on_turn_end(&self.history).await {
@ -1110,7 +1110,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
return Ok(WorkerResult::Finished); return Ok(WorkerResult::Finished);
} }
TurnEndAction::ContinueWithMessages(additional) => { TurnEndAction::ContinueWithMessages(additional) => {
self.extend_history_with_callbacks(additional); self.append_history_items(additional);
continue; continue;
} }
TurnEndAction::Pause => { TurnEndAction::Pause => {
@ -1227,7 +1227,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
result.is_error, result.is_error,
) )
}); });
self.extend_history_with_callbacks(items); self.append_history_items(items);
Ok(None) Ok(None)
} }
Err(err) => { Err(err) => {
@ -1411,38 +1411,28 @@ impl<C: LlmClient> Worker<C, Mutable> {
} }
} }
/// Get a mutable reference to history /// Replace history during restore/rebuild without emitting append callbacks.
/// ///
/// Available only in Mutable state. /// This is not a history-growth API. Live append paths must use
pub fn history_mut(&mut self) -> &mut Vec<Item> { /// [`append_history`](Self::append_history) so `on_history_append` observers
&mut self.history /// see every inserted item.
}
/// Set history
pub fn set_history(&mut self, items: Vec<Item>) { pub fn set_history(&mut self, items: Vec<Item>) {
self.history = items; self.history = items;
} }
/// Add an item to history (builder pattern) /// Append items to history and notify history-append observers for each
pub fn with_item(mut self, item: Item) -> Self { /// item before it lands. This is the only public Mutable-state API for
self.history.push(item); /// growing worker history; callers that need session-log persistence must
self /// install [`on_history_append`](Self::on_history_append) before calling it.
pub fn append_history(&mut self, items: impl IntoIterator<Item = Item>) {
self.append_history_items(items);
} }
/// Add an item to history /// Truncate history without emitting append callbacks.
pub fn push_item(&mut self, item: Item) { ///
self.history.push(item); /// This is an edit operation, not a history-growth path.
} pub fn truncate_history(&mut self, len: usize) {
self.history.truncate(len);
/// Add multiple items to history (builder pattern)
pub fn with_items(mut self, items: impl IntoIterator<Item = Item>) -> Self {
self.history.extend(items);
self
}
/// Add multiple items to history
pub fn extend_history(&mut self, items: impl IntoIterator<Item = Item>) {
self.history.extend(items);
} }
/// Clear history /// Clear history
@ -1575,9 +1565,9 @@ impl<C: LlmClient> Worker<C, Locked> {
PromptAction::Continue => Vec::new(), PromptAction::Continue => Vec::new(),
PromptAction::ContinueWith(items) => items, PromptAction::ContinueWith(items) => items,
}; };
self.history.push(user_item); self.append_history_items(std::iter::once(user_item));
if !extras.is_empty() { if !extras.is_empty() {
self.extend_history_with_callbacks(extras); self.append_history_items(extras);
} }
let result = self.run_turn_loop().await; let result = self.run_turn_loop().await;
self.finalize_interruption(result).await self.finalize_interruption(result).await

View File

@ -5,8 +5,8 @@
mod common; mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait; use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
@ -44,14 +44,12 @@ fn test_mutable_history_manipulation() {
assert!(worker.history().is_empty()); assert!(worker.history().is_empty());
// Add to history // Add to history
worker.push_item(Item::user_message("Hello")); worker.append_history(vec![Item::user_message("Hello")]);
worker.push_item(Item::assistant_message("Hi there!")); worker.append_history(vec![Item::assistant_message("Hi there!")]);
assert_eq!(worker.history().len(), 2); assert_eq!(worker.history().len(), 2);
// Mutable access to history // Append to history via the callback-aware API.
worker worker.append_history(vec![Item::user_message("How are you?")]);
.history_mut()
.push(Item::user_message("How are you?"));
assert_eq!(worker.history().len(), 3); assert_eq!(worker.history().len(), 3);
// Clear history // Clear history
@ -71,34 +69,38 @@ fn test_mutable_history_manipulation() {
#[test] #[test]
fn test_mutable_builder_pattern() { fn test_mutable_builder_pattern() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let worker = Worker::new(client) let worker = Worker::new(client).system_prompt("System prompt");
.system_prompt("System prompt")
.with_item(Item::user_message("Hello"))
.with_item(Item::assistant_message("Hi!"))
.with_items(vec![
Item::user_message("How are you?"),
Item::assistant_message("I'm fine!"),
]);
assert_eq!(worker.get_system_prompt(), Some("System prompt")); assert_eq!(worker.get_system_prompt(), Some("System prompt"));
assert_eq!(worker.history().len(), 4); assert!(worker.history().is_empty());
} }
/// Verify that multiple items can be added with extend_history /// Verify that multiple items can be added with append_history and callbacks fire.
#[test] #[test]
fn test_mutable_extend_history() { fn test_mutable_append_history() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let observed = Arc::new(Mutex::new(Vec::new()));
let observed_for_callback = Arc::clone(&observed);
let mut worker = Worker::new(client); let mut worker = Worker::new(client);
worker.on_history_append(move |item| {
if let Some(text) = item.as_text() {
observed_for_callback.lock().unwrap().push(text.to_string());
}
});
worker.push_item(Item::user_message("First")); worker.append_history(vec![Item::user_message("First")]);
worker.extend_history(vec![ worker.append_history(vec![
Item::assistant_message("Response 1"), Item::assistant_message("Response 1"),
Item::user_message("Second"), Item::user_message("Second"),
Item::assistant_message("Response 2"), Item::assistant_message("Response 2"),
]); ]);
assert_eq!(worker.history().len(), 4); assert_eq!(worker.history().len(), 4);
assert_eq!(
observed.lock().unwrap().as_slice(),
["First", "Response 1", "Second", "Response 2"]
);
} }
#[derive(Clone)] #[derive(Clone)]
@ -162,8 +164,8 @@ fn test_lock_transition() {
let mut worker = Worker::new(client); let mut worker = Worker::new(client);
worker.set_system_prompt("System"); worker.set_system_prompt("System");
worker.push_item(Item::user_message("Hello")); worker.append_history(vec![Item::user_message("Hello")]);
worker.push_item(Item::assistant_message("Hi")); worker.append_history(vec![Item::assistant_message("Hi")]);
// Lock // Lock
let locked_worker = worker.lock(); let locked_worker = worker.lock();
@ -180,14 +182,14 @@ fn test_unlock_transition() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut worker = Worker::new(client); let mut worker = Worker::new(client);
worker.push_item(Item::user_message("Hello")); worker.append_history(vec![Item::user_message("Hello")]);
let locked_worker = worker.lock(); let locked_worker = worker.lock();
// Unlock // Unlock
let mut worker = locked_worker.unlock(); let mut worker = locked_worker.unlock();
// History operations are available again in Mutable state // History operations are available again in Mutable state
worker.push_item(Item::assistant_message("Hi")); worker.append_history(vec![Item::assistant_message("Hi")]);
worker.clear_history(); worker.clear_history();
assert!(worker.history().is_empty()); assert!(worker.history().is_empty());
} }
@ -310,8 +312,8 @@ async fn test_locked_prefix_len_tracking() {
let mut worker = Worker::new(client); let mut worker = Worker::new(client);
// Add items beforehand // Add items beforehand
worker.push_item(Item::user_message("Pre-existing message 1")); worker.append_history(vec![Item::user_message("Pre-existing message 1")]);
worker.push_item(Item::assistant_message("Pre-existing response 1")); worker.append_history(vec![Item::assistant_message("Pre-existing response 1")]);
assert_eq!(worker.history().len(), 2); assert_eq!(worker.history().len(), 2);
@ -380,9 +382,11 @@ async fn test_unlock_edit_relock() {
}), }),
]]); ]]);
let worker = Worker::new(client) let mut worker = Worker::new(client);
.with_item(Item::user_message("Hello")) worker.append_history(vec![
.with_item(Item::assistant_message("Hi")); Item::user_message("Hello"),
Item::assistant_message("Hi"),
]);
// Lock -> Unlock // Lock -> Unlock
let locked = worker.lock(); let locked = worker.lock();
@ -392,7 +396,7 @@ async fn test_unlock_edit_relock() {
// Edit history // Edit history
unlocked.clear_history(); unlocked.clear_history();
unlocked.push_item(Item::user_message("Fresh start")); unlocked.append_history(vec![Item::user_message("Fresh start")]);
// Re-lock // Re-lock
let relocked = unlocked.lock(); let relocked = unlocked.lock();

View File

@ -732,6 +732,31 @@ async fn controller_loop<C, St>(
} }
} }
Method::Compact => match shared_state.get_status() {
PodStatus::Idle => {
if let Err(error) = pod.manual_compact().await {
let _ = event_tx.send(Event::Error {
code: worker_error_code(&error),
message: error.to_string(),
});
}
}
PodStatus::Paused => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
message: "Cannot compact while the Pod is paused; resume or start a fresh turn first"
.into(),
});
}
PodStatus::Running => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Pod is already executing a turn; compact can only run while idle"
.into(),
});
}
},
Method::Shutdown => { Method::Shutdown => {
let _ = event_tx.send(Event::Shutdown); let _ = event_tx.send(Event::Shutdown);
break; break;
@ -965,6 +990,13 @@ where
message: "Pod is already executing a turn".into(), message: "Pod is already executing a turn".into(),
}); });
} }
Some(Method::Compact) => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Pod is already executing a turn; compact can only run while idle"
.into(),
});
}
Some(Method::Notify { message }) => { Some(Method::Notify { message }) => {
// Live echo arrives via `Event::SystemItem` once // Live echo arrives via `Event::SystemItem` once
// the in-flight turn's next `pre_llm_request` // the in-flight turn's next `pre_llm_request`
@ -1320,4 +1352,46 @@ mod tests {
"expected no PodEvent for notification-originated worker error" "expected no PodEvent for notification-originated worker error"
); );
} }
#[tokio::test]
async fn compact_method_is_rejected_while_running() {
let mut env = make_env().await;
let mut events = env.event_tx.subscribe();
env._method_tx
.send(Method::Compact)
.await
.expect("send compact");
let pod_future = async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<_, PodError>(PodRunResult::Finished)
};
let (status, shutdown) = drive_turn(
pod_future,
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.shared_state,
&env.notify_buffer,
Some(&env.parent_socket_path),
"child-pod",
&env.spawned_registry,
false,
)
.await;
assert_eq!(status, PodStatus::Idle);
assert!(!shutdown);
let event = tokio::time::timeout(Duration::from_secs(1), events.recv())
.await
.expect("event timeout")
.expect("event");
match event {
Event::Error { code, message } => {
assert_eq!(code, ErrorCode::AlreadyRunning);
assert!(message.contains("compact"), "got message: {message}");
}
other => panic!("expected compact rejection error, got {other:?}"),
}
}
} }

View File

@ -470,9 +470,10 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Pod<C, St> {
/// ///
/// `user_message` items are skipped because they are committed /// `user_message` items are skipped because they are committed
/// up-front via `commit_entry(LogEntry::UserInput { segments })`. /// up-front via `commit_entry(LogEntry::UserInput { segments })`.
/// `role:system` items are committed by `PodInterceptor` as typed /// `role:system` items are committed as typed `LogEntry::SystemItem`
/// `LogEntry::SystemItem` entries before they reach the worker's /// entries by their producers (for example `PodInterceptor` and
/// history (so this callback would otherwise double-write them). /// interrupted-turn prep) before they reach the worker's history, so this
/// callback would otherwise double-write them.
pub fn wire_history_persistence(&mut self) { pub fn wire_history_persistence(&mut self) {
let writer = self.log_writer_handle(); let writer = self.log_writer_handle();
self.worker_mut().on_history_append(move |item| { self.worker_mut().on_history_append(move |item| {
@ -1312,9 +1313,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
&mut self, &mut self,
snapshot: EmptyTurnRollbackSnapshot, snapshot: EmptyTurnRollbackSnapshot,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.worker_mut() self.worker_mut().truncate_history(snapshot.history_len);
.history_mut()
.truncate(snapshot.history_len);
self.worker_mut() self.worker_mut()
.set_last_run_interrupted(snapshot.last_run_interrupted); .set_last_run_interrupted(snapshot.last_run_interrupted);
self.user_segments.truncate(snapshot.user_segments_len); self.user_segments.truncate(snapshot.user_segments_len);
@ -1661,10 +1660,18 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
&tool_result_summary, &tool_result_summary,
); );
if !closures.is_empty() { if !closures.is_empty() {
self.worker_mut().extend_history(closures); self.worker_mut().append_history(closures);
} }
self.commit_entry(LogEntry::SystemItem {
ts: segment_log::now_millis(),
item: SystemItem::Interrupt {
body: system_note.clone(),
},
})?;
self.worker_mut() self.worker_mut()
.push_item(llm_worker::Item::system_message(system_note)); .append_history(std::iter::once(llm_worker::Item::system_message(
system_note,
)));
Ok(()) Ok(())
} }
@ -2001,6 +2008,86 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} }
} }
/// Run an explicit user-requested compaction between turns.
///
/// The controller only calls this while Idle. Paused turns keep their
/// interrupted Worker state intact and are intentionally rejected before
/// this method is reached.
pub async fn manual_compact(&mut self) -> Result<ManualCompactResult, PodError> {
if self.manifest.compaction.is_none() {
let message =
"manual compact is unavailable because [compaction] is not configured".to_string();
self.alert(AlertLevel::Warn, AlertSource::Compactor, message.clone());
return Ok(ManualCompactResult::Skipped { message });
}
if self.history().is_empty() {
let message = "manual compact skipped: no conversation history to compact".to_string();
self.alert(AlertLevel::Warn, AlertSource::Compactor, message.clone());
return Ok(ManualCompactResult::Skipped { message });
}
self.ensure_interceptor_installed();
self.cleanup_finished_memory_task();
self.ensure_segment_head()?;
let state = self.compact_state.clone();
if state.as_ref().is_some_and(|s| s.is_disabled()) {
let message =
"manual compact is disabled after repeated compaction failures".to_string();
self.alert(AlertLevel::Warn, AlertSource::Compactor, message.clone());
return Ok(ManualCompactResult::Skipped { message });
}
let retained = state
.as_ref()
.map(|s| s.retained_tokens())
.or_else(|| {
self.manifest
.compaction
.as_ref()
.map(|c| c.compact_retained_tokens)
})
.unwrap_or(manifest::defaults::COMPACT_RETAINED_TOKENS);
let current_tokens = self.total_tokens().tokens;
let cut = self.split_for_retained(retained);
if cut.index == 0 {
let message = format!(
"manual compact skipped: current context is within the retained tail ({current_tokens} <= {retained} tokens)"
);
self.alert(AlertLevel::Warn, AlertSource::Compactor, message.clone());
return Ok(ManualCompactResult::Skipped { message });
}
self.join_memory_task().await;
self.send_event(Event::CompactStart);
match self.compact(retained).await {
Ok(new_segment_id) => {
info!(new_segment_id = %new_segment_id, "Manual compaction succeeded");
self.send_event(Event::CompactDone { new_segment_id });
if let Some(ref state) = state {
state.record_compact_success();
}
Ok(ManualCompactResult::Compacted { new_segment_id })
}
Err(e) => {
warn!(error = %e, "Manual compaction failed");
self.send_event(Event::CompactFailed {
error: e.to_string(),
});
self.alert(
AlertLevel::Error,
AlertSource::Compactor,
format!("manual compaction failed: {e}"),
);
if let Some(ref state) = state {
state.record_compact_failure();
}
Err(e)
}
}
}
/// Persist delta + turn end + outcome after a run/resume. /// Persist delta + turn end + outcome after a run/resume.
async fn persist_turn( async fn persist_turn(
&mut self, &mut self,
@ -3300,6 +3387,15 @@ pub enum PodRunResult {
RolledBack, RolledBack,
} }
/// Result of a manual compaction request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManualCompactResult {
/// The history was compacted into a new segment.
Compacted { new_segment_id: SegmentId },
/// No compaction was run; the message has already been surfaced as an alert.
Skipped { message: String },
}
impl From<WorkerResult> for PodRunResult { impl From<WorkerResult> for PodRunResult {
fn from(r: WorkerResult) -> Self { fn from(r: WorkerResult) -> Self {
match r { match r {
@ -3748,6 +3844,102 @@ mod build_summary_prompt_tests {
assert_eq!(prompt, "[User] fix the bug\n\n[Assistant] done"); assert_eq!(prompt, "[User] fix the bug\n\n[Assistant] done");
} }
#[derive(Clone)]
struct NoopClient;
#[async_trait]
impl LlmClient for NoopClient {
async fn stream(
&self,
_request: llm_worker::llm_client::Request,
) -> Result<
std::pin::Pin<
Box<
dyn futures::Stream<
Item = Result<
llm_worker::llm_client::event::Event,
llm_worker::llm_client::ClientError,
>,
> + Send,
>,
>,
llm_worker::llm_client::ClientError,
> {
Ok(Box::pin(futures::stream::empty()))
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
#[tokio::test]
async fn apply_interrupt_prep_appends_via_callback_and_logs_independent_entries() {
let dir = tempfile::tempdir().unwrap();
let manifest = minimal_manifest_with_skills(vec![]);
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
let pwd = dir.path().join("workspace");
std::fs::create_dir_all(&pwd).unwrap();
let scope = Scope::writable(&pwd).unwrap();
let mut pod = Pod::new(manifest, Worker::new(NoopClient), store, pwd, scope)
.await
.unwrap();
pod.ensure_segment_head().unwrap();
pod.wire_history_persistence();
pod.worker_mut()
.set_history(vec![Item::tool_call("call-1", "Read", "{}")]);
pod.apply_interrupt_prep().unwrap();
let history = pod.worker().history();
assert_eq!(history.len(), 3);
assert!(matches!(history[1], Item::ToolResult { ref call_id, .. } if call_id == "call-1"));
assert!(matches!(
history[2],
Item::Message {
role: Role::System,
..
}
));
let interrupt_note = history[2].as_text().unwrap().to_string();
let entries = pod
.store
.read_all(
pod.segment_state.session_id(),
pod.segment_state.segment_id(),
)
.unwrap();
let tool_result_count = entries
.iter()
.filter(|entry| {
matches!(
entry,
LogEntry::ToolResult {
item: session_store::LoggedItem::ToolResult { call_id, .. },
..
} if call_id == "call-1"
)
})
.count();
let interrupt_system_count = entries
.iter()
.filter(|entry| {
matches!(
entry,
LogEntry::SystemItem {
item: SystemItem::Interrupt { body },
..
} if body == &interrupt_note
)
})
.count();
assert_eq!(tool_result_count, 1);
assert_eq!(interrupt_system_count, 1);
}
fn minimal_manifest_with_skills(dirs: Vec<PathBuf>) -> PodManifest { fn minimal_manifest_with_skills(dirs: Vec<PathBuf>) -> PodManifest {
// Construct the smallest possible PodManifest that resolves; only // Construct the smallest possible PodManifest that resolves; only
// the `skills` field matters for `skill_dir_read_rules`. // the `skills` field matters for `skill_dir_read_rules`.

View File

@ -16,11 +16,11 @@ use llm_worker::Worker;
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_worker::llm_client::types::Item; use llm_worker::llm_client::types::Item;
use llm_worker::llm_client::{ClientError, LlmClient, Request}; use llm_worker::llm_client::{ClientError, LlmClient, Request};
use protocol::Event; use protocol::{Event, Method, RunResult};
use session_store::{FsStore, LogEntry, PodMetadataStore, Store}; use session_store::{FsStore, LogEntry, PodMetadataStore, Store};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use pod::Pod; use pod::{Pod, PodController};
#[derive(Clone)] #[derive(Clone)]
struct MockClient { struct MockClient {
@ -754,3 +754,53 @@ async fn detached_extract_does_not_fork_session_log() {
clone carried its own counter" clone carried its own counter"
); );
} }
#[tokio::test]
async fn controller_compact_method_emits_start_and_done() {
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
write_summary_tool_use_events("manual-summary", "manual compact summary"),
single_text_events("done"),
]);
let pod = make_pod_with_manifest(POST_RUN_MANIFEST_TOML, client).await;
let runtime_tmp = tempfile::tempdir().unwrap();
let (handle, _shutdown) = PodController::spawn(pod, runtime_tmp.path()).await.unwrap();
let mut rx = handle.subscribe();
handle
.send(Method::run_text("seed history"))
.await
.expect("send run");
loop {
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for run end")
.expect("event")
{
Event::RunEnd {
result: RunResult::Finished,
} => break,
_ => {}
}
}
handle.send(Method::Compact).await.expect("send compact");
let mut saw_start = false;
loop {
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for compact events")
.expect("event")
{
Event::CompactStart => saw_start = true,
Event::CompactDone { .. } => {
break;
}
Event::CompactFailed { error } => panic!("manual compact failed: {error}"),
_ => {}
}
}
assert!(saw_start, "manual compact should emit CompactStart");
let _ = handle.send(Method::Shutdown).await;
}

View File

@ -31,6 +31,11 @@ pub enum Method {
/// fresh turn via `Run` (orphan `tool_use` items are closed with a /// fresh turn via `Run` (orphan `tool_use` items are closed with a
/// synthetic tool result before the new user message is appended). /// synthetic tool result before the new user message is appended).
Pause, Pause,
/// Request an explicit compaction while the Pod is otherwise idle.
///
/// This is a typed control method: clients must not send `compact` as a
/// `Method::Run` user message.
Compact,
Shutdown, Shutdown,
/// Request a list of completion candidates from the Pod. /// Request a list of completion candidates from the Pod.
/// ///
@ -732,6 +737,15 @@ mod tests {
assert_eq!(serialized, json); assert_eq!(serialized, json);
} }
#[test]
fn method_compact_roundtrip() {
let json = r#"{"method":"compact"}"#;
let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!(method, Method::Compact));
let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json);
}
#[test] #[test]
fn event_text_delta_format() { fn event_text_delta_format() {
let event = Event::TextDelta { let event = Event::TextDelta {

View File

@ -67,6 +67,11 @@ pub enum SystemItem {
/// `/<slug>` Workflow invocation. `body` is the workflow's /// `/<slug>` Workflow invocation. `body` is the workflow's
/// prompt body materialized into the LLM context. /// prompt body materialized into the LLM context.
Workflow { slug: String, body: String }, Workflow { slug: String, body: String },
/// Synthetic note inserted after an interrupted turn before the next
/// user input. `body` is the exact LLM-context text explaining that the
/// previous turn was cut short.
Interrupt { body: String },
} }
impl SystemItem { impl SystemItem {
@ -79,6 +84,7 @@ impl SystemItem {
SystemItem::FileAttachment { body, .. } => body.clone(), SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::Knowledge { body, .. } => body.clone(), SystemItem::Knowledge { body, .. } => body.clone(),
SystemItem::Workflow { body, .. } => body.clone(), SystemItem::Workflow { body, .. } => body.clone(),
SystemItem::Interrupt { body } => body.clone(),
} }
} }
@ -97,6 +103,7 @@ impl SystemItem {
SystemItem::FileAttachment { .. } => "file_attachment", SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::Knowledge { .. } => "knowledge", SystemItem::Knowledge { .. } => "knowledge",
SystemItem::Workflow { .. } => "workflow", SystemItem::Workflow { .. } => "workflow",
SystemItem::Interrupt { .. } => "interrupt",
} }
} }
} }

View File

@ -10,6 +10,7 @@ use crate::block::{
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState, Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
}; };
use crate::cache::FileCache; use crate::cache::FileCache;
use crate::command::{CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry};
use crate::input::InputBuffer; use crate::input::InputBuffer;
use crate::scroll::Scroll; use crate::scroll::Scroll;
use crate::task::TaskStore; use crate::task::TaskStore;
@ -88,7 +89,12 @@ pub struct App {
pub context_window: u64, pub context_window: u64,
pub turn_index: usize, pub turn_index: usize,
pub current_tool: Option<String>, pub current_tool: Option<String>,
/// Normal composer input that is submitted as `Method::Run`.
pub input: InputBuffer, pub input: InputBuffer,
/// Separate command-line input. It is never submitted as a user message.
pub command_input: InputBuffer,
pub input_mode: CommandInputMode,
pub command_registry: CommandRegistry,
pub quit: bool, pub quit: bool,
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press /// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
/// records the instant; a second press within the timeout exits the /// records the instant; a second press within the timeout exits the
@ -143,6 +149,9 @@ impl App {
turn_index: 0, turn_index: 0,
current_tool: None, current_tool: None,
input: InputBuffer::new(), input: InputBuffer::new(),
command_input: InputBuffer::new(),
input_mode: CommandInputMode::Composer,
command_registry: CommandRegistry::default(),
quit: false, quit: false,
quit_confirm: None, quit_confirm: None,
blocks: Vec::new(), blocks: Vec::new(),
@ -190,6 +199,10 @@ impl App {
/// Callers should invoke this after every input mutation that could /// Callers should invoke this after every input mutation that could
/// move the cursor or change atoms. /// move the cursor or change atoms.
pub fn refresh_completion(&mut self) -> Option<Method> { pub fn refresh_completion(&mut self) -> Option<Method> {
if self.is_command_mode() {
self.completion = None;
return None;
}
match self.input.pending_completion_prefix() { match self.input.pending_completion_prefix() {
Some((kind, start, prefix)) => { Some((kind, start, prefix)) => {
let need_query = match &self.completion { let need_query = match &self.completion {
@ -1013,43 +1026,119 @@ impl App {
} }
} }
pub fn is_command_mode(&self) -> bool {
self.input_mode == CommandInputMode::Command
}
pub fn enter_command_mode(&mut self) {
self.input_mode = CommandInputMode::Command;
self.completion = None;
self.quit_confirm = None;
}
pub fn exit_command_mode(&mut self) {
self.input_mode = CommandInputMode::Composer;
self.command_input.clear();
}
pub fn clear_command_input(&mut self) {
self.command_input.clear();
}
pub fn command_text(&self) -> String {
self.command_input.plain_text()
}
pub fn command_suggestions(&self) -> Vec<crate::command::CommandCandidate> {
self.command_registry.suggest(&self.command_text())
}
fn command_environment(&self) -> CommandEnvironment {
CommandEnvironment {
connected: self.connected,
running: self.running,
paused: self.paused,
}
}
pub fn submit_command(&mut self) -> Option<Method> {
let command_line = self.command_text();
let environment = self.command_environment();
let result = self.command_registry.dispatch(&command_line, &environment);
self.apply_command_execution(result)
}
fn apply_command_execution(&mut self, result: CommandExecution) -> Option<Method> {
for diagnostic in result.diagnostics {
self.push_command_diagnostic(diagnostic.message);
}
if result.clear_input {
self.command_input.clear();
}
if result.exit_command_mode {
self.input_mode = CommandInputMode::Composer;
}
result.method
}
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
self.blocks.push(Block::Alert {
level: AlertLevel::Warn,
source: AlertSource::Pod,
message: format!("TUI command: {}", message.into()),
});
}
fn active_input_mut(&mut self) -> &mut InputBuffer {
if self.is_command_mode() {
&mut self.command_input
} else {
&mut self.input
}
}
// Input manipulation — thin forwarders so call sites in main.rs // Input manipulation — thin forwarders so call sites in main.rs
// stay readable. // stay readable. In command mode these operate on the command line,
// keeping the normal composer buffer intact.
pub fn insert_char(&mut self, c: char) { pub fn insert_char(&mut self, c: char) {
self.input.insert_char(c); self.active_input_mut().insert_char(c);
} }
pub fn insert_newline(&mut self) { pub fn insert_newline(&mut self) {
self.input.insert_newline(); self.active_input_mut().insert_newline();
} }
pub fn insert_paste(&mut self, content: String) { pub fn insert_paste(&mut self, content: String) {
self.input.insert_paste(content); if self.is_command_mode() {
self.command_input.insert_str(&content);
} else {
self.input.insert_paste(content);
}
} }
pub fn delete_char_before(&mut self) { pub fn delete_char_before(&mut self) {
self.input.delete_before(); self.active_input_mut().delete_before();
} }
pub fn delete_char_after(&mut self) { pub fn delete_char_after(&mut self) {
self.input.delete_after(); self.active_input_mut().delete_after();
} }
pub fn move_cursor_left(&mut self) { pub fn move_cursor_left(&mut self) {
self.input.move_left(); self.active_input_mut().move_left();
} }
pub fn move_cursor_right(&mut self) { pub fn move_cursor_right(&mut self) {
self.input.move_right(); self.active_input_mut().move_right();
} }
pub fn move_cursor_start(&mut self) { pub fn move_cursor_start(&mut self) {
self.input.move_start(); self.active_input_mut().move_start();
} }
pub fn move_cursor_home(&mut self) { pub fn move_cursor_home(&mut self) {
self.input.move_home(); self.active_input_mut().move_home();
} }
pub fn move_cursor_end(&mut self) { pub fn move_cursor_end(&mut self) {
self.input.move_end(); self.active_input_mut().move_end();
} }
pub fn move_cursor_up(&mut self) { pub fn move_cursor_up(&mut self) {
self.input.move_up(); self.active_input_mut().move_up();
} }
pub fn move_cursor_down(&mut self) { pub fn move_cursor_down(&mut self) {
self.input.move_down(); self.active_input_mut().move_down();
} }
/// Reset the block list and replay a connect-time `Event::Snapshot`. /// Reset the block list and replay a connect-time `Event::Snapshot`.
@ -1160,7 +1249,8 @@ impl App {
} }
session_store::SystemItem::FileAttachment { body, .. } session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::Knowledge { body, .. } | session_store::SystemItem::Knowledge { body, .. }
| session_store::SystemItem::Workflow { body, .. } => { | session_store::SystemItem::Workflow { body, .. }
| session_store::SystemItem::Interrupt { body } => {
self.task_store.apply_system_message_text(&body); self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body }); self.blocks.push(Block::SystemMessage { text: body });
} }

424
crates/tui/src/command.rs Normal file
View File

@ -0,0 +1,424 @@
use protocol::Method;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandInputMode {
Composer,
Command,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandArgs {
raw: String,
argv: Vec<String>,
}
impl CommandArgs {
pub fn parse_whitespace(raw: &str) -> Self {
Self {
raw: raw.to_owned(),
argv: raw.split_whitespace().map(str::to_owned).collect(),
}
}
pub fn raw(&self) -> &str {
&self.raw
}
pub fn argv(&self) -> &[String] {
&self.argv
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandDiagnostic {
pub message: String,
}
impl CommandDiagnostic {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandEnvironment {
pub connected: bool,
pub running: bool,
pub paused: bool,
}
#[derive(Debug, Clone)]
pub struct CommandExecution {
pub method: Option<Method>,
pub diagnostics: Vec<CommandDiagnostic>,
pub exit_command_mode: bool,
pub clear_input: bool,
}
impl CommandExecution {
pub fn diagnostic(message: impl Into<String>) -> Self {
Self {
method: None,
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: false,
clear_input: false,
}
}
pub fn notice(message: impl Into<String>) -> Self {
Self {
method: None,
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: true,
clear_input: true,
}
}
}
pub type ArgumentParser = fn(&str) -> Result<CommandArgs, CommandDiagnostic>;
pub type AvailabilityCheck = fn(&CommandEnvironment) -> Result<(), CommandDiagnostic>;
pub type CommandExecutor = fn(CommandInvocation<'_>) -> CommandExecution;
#[derive(Clone)]
pub struct CommandSpec {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub usage: &'static str,
pub description: &'static str,
pub argument_parser: ArgumentParser,
pub can_execute: AvailabilityCheck,
pub executor: CommandExecutor,
}
pub struct CommandInvocation<'a> {
pub registry: &'a CommandRegistry,
pub command: &'a CommandSpec,
pub args: CommandArgs,
pub environment: &'a CommandEnvironment,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandCandidate {
pub name: &'static str,
pub usage: &'static str,
pub description: &'static str,
}
#[derive(Clone)]
pub struct CommandRegistry {
commands: Vec<CommandSpec>,
}
impl CommandRegistry {
pub fn new() -> Self {
Self {
commands: Vec::new(),
}
}
pub fn builtins() -> Self {
let mut registry = Self::new();
registry.register(CommandSpec {
name: "help",
aliases: &["?"],
usage: "help [command]",
description: "Show available TUI commands or details for one command.",
argument_parser: help_args,
can_execute: always_available,
executor: help_command,
});
registry.register(CommandSpec {
name: "noop",
aliases: &["nop"],
usage: "noop",
description: "Validate command dispatch without side effects.",
argument_parser: no_args,
can_execute: always_available,
executor: noop_command,
});
registry.register(CommandSpec {
name: "compact",
aliases: &[],
usage: "compact",
description: "Request immediate Pod context compaction.",
argument_parser: compact_args,
can_execute: compact_available,
executor: compact_command,
});
registry
}
pub fn register(&mut self, spec: CommandSpec) {
debug_assert!(!self.commands.iter().any(|c| c.name == spec.name));
self.commands.push(spec);
}
pub fn commands(&self) -> &[CommandSpec] {
&self.commands
}
pub fn find(&self, name_or_alias: &str) -> Option<&CommandSpec> {
self.commands.iter().find(|command| {
command.name == name_or_alias
|| command.aliases.iter().any(|alias| *alias == name_or_alias)
})
}
pub fn suggest(&self, command_line: &str) -> Vec<CommandCandidate> {
let prefix = command_prefix(command_line);
if prefix.is_empty() {
return self.commands.iter().map(CommandCandidate::from).collect();
}
self.commands
.iter()
.filter(|command| {
command.name.starts_with(prefix)
|| command
.aliases
.iter()
.any(|alias| alias.starts_with(prefix))
})
.map(CommandCandidate::from)
.collect()
}
pub fn dispatch(
&self,
command_line: &str,
environment: &CommandEnvironment,
) -> CommandExecution {
let trimmed = command_line.trim();
if trimmed.is_empty() {
return CommandExecution::diagnostic(
"Empty command. Type :help for available commands.",
);
}
let (name, raw_args) = split_command(trimmed);
let Some(command) = self.find(name) else {
return CommandExecution::diagnostic(format!(
"Unknown command: {name}. Type :help for available commands."
));
};
let args = match (command.argument_parser)(raw_args) {
Ok(args) => args,
Err(err) => return CommandExecution::diagnostic(err.message),
};
if let Err(err) = (command.can_execute)(environment) {
return CommandExecution::diagnostic(err.message);
}
(command.executor)(CommandInvocation {
registry: self,
command,
args,
environment,
})
}
}
impl Default for CommandRegistry {
fn default() -> Self {
Self::builtins()
}
}
impl From<&CommandSpec> for CommandCandidate {
fn from(command: &CommandSpec) -> Self {
Self {
name: command.name,
usage: command.usage,
description: command.description,
}
}
}
fn split_command(trimmed: &str) -> (&str, &str) {
match trimmed.find(char::is_whitespace) {
Some(idx) => {
let (name, rest) = trimmed.split_at(idx);
(name, rest.trim_start())
}
None => (trimmed, ""),
}
}
fn command_prefix(command_line: &str) -> &str {
let trimmed = command_line.trim_start();
match trimmed.find(char::is_whitespace) {
Some(idx) => &trimmed[..idx],
None => trimmed,
}
}
fn always_available(_environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
Ok(())
}
fn no_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
let args = CommandArgs::parse_whitespace(raw);
if args.argv().is_empty() {
Ok(args)
} else {
Err(CommandDiagnostic::new("Invalid arguments. Usage: noop"))
}
}
fn help_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
let args = CommandArgs::parse_whitespace(raw);
if args.argv().len() <= 1 {
Ok(args)
} else {
Err(CommandDiagnostic::new(
"Invalid arguments. Usage: help [command]",
))
}
}
fn compact_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
let args = CommandArgs::parse_whitespace(raw);
if args.argv().is_empty() {
Ok(args)
} else {
Err(CommandDiagnostic::new("Invalid arguments. Usage: compact"))
}
}
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
if !environment.connected {
return Err(CommandDiagnostic::new(
"Cannot compact: not connected to a Pod.",
));
}
if environment.running {
return Err(CommandDiagnostic::new(
"Cannot compact while the Pod is running.",
));
}
if environment.paused {
return Err(CommandDiagnostic::new(
"Cannot compact while the Pod is paused; resume or start a fresh turn first.",
));
}
Ok(())
}
fn help_command(invocation: CommandInvocation<'_>) -> CommandExecution {
if let Some(name) = invocation.args.argv().first() {
let Some(command) = invocation.registry.find(name) else {
return CommandExecution::diagnostic(format!(
"Unknown command: {name}. Type :help for available commands."
));
};
let aliases = if command.aliases.is_empty() {
"".to_owned()
} else {
format!(" aliases: {}.", command.aliases.join(", "))
};
return CommandExecution::notice(format!(
"command: {} — usage: {}.{} {}",
command.name, command.usage, aliases, command.description
));
}
let list = invocation
.registry
.commands()
.iter()
.map(|command| format!("{} ({})", command.name, command.usage))
.collect::<Vec<_>>()
.join(", ");
CommandExecution::notice(format!("available commands: {list}"))
}
fn noop_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.command;
let _ = invocation.environment;
let _ = invocation.args.raw();
CommandExecution::notice("noop: no action")
}
fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.command;
let _ = invocation.environment;
let _ = invocation.args.raw();
CommandExecution {
method: Some(Method::Compact),
diagnostics: vec![CommandDiagnostic::new("compact requested")],
exit_command_mode: true,
clear_input: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> CommandEnvironment {
CommandEnvironment {
connected: true,
running: false,
paused: false,
}
}
#[test]
fn builtins_suggest_by_prefix() {
let registry = CommandRegistry::builtins();
assert_eq!(registry.suggest("he")[0].name, "help");
assert_eq!(registry.suggest("n")[0].name, "noop");
}
#[test]
fn unknown_command_is_local_diagnostic() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("wat", &env());
assert!(result.method.is_none());
assert!(!result.exit_command_mode);
assert!(result.diagnostics[0].message.contains("Unknown command"));
}
#[test]
fn invalid_arguments_are_local_diagnostic() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("noop extra", &env());
assert!(result.method.is_none());
assert!(!result.exit_command_mode);
assert!(result.diagnostics[0].message.contains("Invalid arguments"));
}
#[test]
fn compact_command_returns_compact_method_not_run() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("compact", &env());
assert!(matches!(result.method, Some(Method::Compact)));
assert!(result.exit_command_mode);
assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("compact requested"));
}
#[test]
fn compact_invalid_arguments_are_local_diagnostic() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("compact now", &env());
assert!(result.method.is_none());
assert!(!result.exit_command_mode);
assert!(result.diagnostics[0].message.contains("Invalid arguments"));
}
#[test]
fn compact_rejects_running_and_paused_locally() {
let registry = CommandRegistry::builtins();
let mut running = env();
running.running = true;
let result = registry.dispatch("compact", &running);
assert!(result.method.is_none());
assert!(result.diagnostics[0].message.contains("running"));
let mut paused = env();
paused.paused = true;
let result = registry.dispatch("compact", &paused);
assert!(result.method.is_none());
assert!(result.diagnostics[0].message.contains("paused"));
}
}

View File

@ -223,6 +223,26 @@ impl InputBuffer {
self.cursor += 1; self.cursor += 1;
} }
pub fn insert_str(&mut self, text: &str) {
for c in text.chars() {
self.insert_char(c);
}
}
pub fn plain_text(&self) -> String {
let mut text = String::new();
for atom in &self.atoms {
match atom {
Atom::Char(c) => text.push(*c),
Atom::Paste(paste) => text.push_str(&paste.content),
Atom::FileRef(file) => text.push_str(&file.path),
Atom::KnowledgeRef(knowledge) => text.push_str(&knowledge.slug),
Atom::WorkflowInvoke(workflow) => text.push_str(&workflow.slug),
}
}
text
}
pub fn insert_newline(&mut self) { pub fn insert_newline(&mut self) {
self.insert_char('\n'); self.insert_char('\n');
} }

View File

@ -1,6 +1,7 @@
mod app; mod app;
mod block; mod block;
mod cache; mod cache;
mod command;
mod input; mod input;
mod markdown; mod markdown;
mod picker; mod picker;
@ -644,7 +645,13 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.move_cursor_start(); app.move_cursor_start();
Some(app.refresh_completion()) Some(app.refresh_completion())
} }
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') && alt && !ctrl => { KeyCode::Char('u') if ctrl && app.is_command_mode() => {
app.clear_command_input();
Some(None)
}
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
{
if app.restore_next_queued_input_to_composer() { if app.restore_next_queued_input_to_composer() {
Some(app.refresh_completion()) Some(app.refresh_completion())
} else { } else {
@ -668,8 +675,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
Some(None) Some(None)
} }
KeyCode::Enter if alt => { KeyCode::Enter if alt => {
app.insert_newline(); if app.is_command_mode() {
Some(app.refresh_completion()) Some(None)
} else {
app.insert_newline();
Some(app.refresh_completion())
}
} }
_ => None, _ => None,
} { } {
@ -705,6 +716,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
_ => {} _ => {}
} }
if app.is_command_mode() {
return handle_command_key(app, key);
}
// Completion popup overrides — only when there's something to // Completion popup overrides — only when there's something to
// navigate / commit. An empty popup (request in flight) falls // navigate / commit. An empty popup (request in flight) falls
// through to the default behaviour. // through to the default behaviour.
@ -790,6 +805,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.move_cursor_end(); app.move_cursor_end();
app.refresh_completion() app.refresh_completion()
} }
KeyCode::Char(':') if !alt && app.input.is_empty() => {
app.enter_command_mode();
None
}
KeyCode::Char(c) => { KeyCode::Char(c) => {
// Whitespace ends an in-flight completion token. Try the // Whitespace ends an in-flight completion token. Try the
// auto-confirm path first so an exact match (e.g. typed // auto-confirm path first so an exact match (e.g. typed
@ -807,6 +826,60 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
} }
} }
fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
match key.code {
KeyCode::Esc => {
app.exit_command_mode();
None
}
KeyCode::Enter => app.submit_command(),
KeyCode::Backspace => {
app.delete_char_before();
None
}
KeyCode::Delete => {
app.delete_char_after();
None
}
KeyCode::Left => {
app.move_cursor_left();
None
}
KeyCode::Right => {
app.move_cursor_right();
None
}
KeyCode::Up => {
app.move_cursor_up();
None
}
KeyCode::Down => {
app.move_cursor_down();
None
}
KeyCode::Home => {
app.move_cursor_home();
None
}
KeyCode::End => {
app.move_cursor_end();
None
}
KeyCode::Tab => None,
KeyCode::Char(c) => {
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return None;
}
app.insert_char(c);
None
}
_ => None,
}
}
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// Running → send `Method::Pause`. /// Running → send `Method::Pause`.
@ -1056,6 +1129,170 @@ mod tests {
assert_eq!(app.queued_input_count(), 0); assert_eq!(app.queued_input_count(), 0);
} }
#[test]
fn command_mode_enters_with_colon_and_esc_restores_composer() {
let mut app = App::new("agent".to_string());
app.insert_char('d');
app.insert_char('r');
app.insert_char('a');
app.insert_char('f');
app.insert_char('t');
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
)
.is_none()
);
assert!(!app.is_command_mode());
assert_eq!(input_text(&app), "draft:");
app.input.clear();
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
)
.is_none()
);
assert!(app.is_command_mode());
for c in "help".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
assert_eq!(input_text(&app), "");
assert_eq!(app.command_text(), "help");
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_none());
assert!(!app.is_command_mode());
assert_eq!(input_text(&app), "");
}
#[test]
fn unknown_command_is_not_sent_as_user_message() {
let mut app = App::new("agent".to_string());
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
)
.is_none()
);
for c in "does-not-exist".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(method.is_none());
assert!(app.is_command_mode());
assert_eq!(input_text(&app), "");
assert_eq!(app.queued_input_count(), 0);
assert!(app.blocks.iter().any(|block| match block {
crate::block::Block::Alert { message, .. } => message.contains("Unknown command"),
_ => false,
}));
}
#[test]
fn command_enter_dispatches_registry_without_run() {
let mut app = App::new("agent".to_string());
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
)
.is_none()
);
for c in "noop".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(method.is_none());
assert!(!app.is_command_mode());
assert_eq!(input_text(&app), "");
assert!(app.blocks.iter().any(|block| match block {
crate::block::Block::Alert { message, .. } => message.contains("noop: no action"),
_ => false,
}));
}
#[test]
fn compact_command_sends_compact_method_without_run() {
let mut app = App::new("agent".to_string());
app.connected = true;
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
)
.is_none()
);
for c in "compact".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(method, Some(protocol::Method::Compact)));
assert!(!app.is_command_mode());
assert_eq!(input_text(&app), "");
assert_eq!(app.queued_input_count(), 0);
assert!(app.blocks.iter().any(|block| match block {
crate::block::Block::Alert { message, .. } => message.contains("compact requested"),
_ => false,
}));
}
#[test]
fn command_registry_suggestions_are_available() {
let mut app = App::new("agent".to_string());
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)
)
.is_none()
);
assert!(
app.command_suggestions()
.iter()
.any(|candidate| candidate.name == "help")
);
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)
)
.is_none()
);
let suggestions = app.command_suggestions();
assert_eq!(suggestions.len(), 1);
assert_eq!(suggestions[0].name, "noop");
}
fn input_text(app: &App) -> String { fn input_text(app: &App) -> String {
protocol::Segment::flatten_to_text(&app.input.submit_segments()) protocol::Segment::flatten_to_text(&app.input.submit_segments())
} }

View File

@ -61,10 +61,14 @@ impl Mode {
pub fn draw(frame: &mut Frame, app: &mut App) { pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area(); let area = frame.area();
// Input content starts after the "> " / " " prompt, so the width // Input content starts after the prompt (`> ` or `: `), so the width
// available for wrapping is two columns narrower than the frame. // available for wrapping is two columns narrower than the frame.
let input_content_width = area.width.saturating_sub(2).max(1); let input_content_width = area.width.saturating_sub(2).max(1);
let input_render = app.input.render(input_content_width); let input_render = if app.is_command_mode() {
app.command_input.render(input_content_width)
} else {
app.input.render(input_content_width)
};
let input_height = input_area_height(&input_render, area.height); let input_height = input_area_height(&input_render, area.height);
let mini_view_h = task_mini_view_height(&app.task_store); let mini_view_h = task_mini_view_height(&app.task_store);
// One blank row separates the history tail from the mini-view so // One blank row separates the history tail from the mini-view so
@ -89,9 +93,11 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
} }
draw_separator(frame, chunks[3]); draw_separator(frame, chunks[3]);
draw_status(frame, app, chunks[4]); draw_status(frame, app, chunks[4]);
draw_input(frame, &input_render, chunks[5]); draw_input(frame, app, &input_render, chunks[5]);
draw_actionbar(frame, app, chunks[6]); draw_actionbar(frame, app, chunks[6]);
if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) { if !app.is_command_mode()
&& let Some(state) = app.completion.as_ref().filter(|c| c.is_active())
{
draw_completion_popup(frame, state, chunks[5]); draw_completion_popup(frame, state, chunks[5]);
} }
} }
@ -1146,6 +1152,16 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
spans.push(Span::styled(queue, Style::default().fg(Color::Magenta))); spans.push(Span::styled(queue, Style::default().fg(Color::Magenta)));
} }
if app.is_command_mode() {
spans.push(Span::raw(" | "));
spans.push(Span::styled(
"command",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
let right_text = context_usage_text(app); let right_text = context_usage_text(app);
let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray))) let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray)))
.alignment(ratatui::layout::Alignment::Right); .alignment(ratatui::layout::Alignment::Right);
@ -1156,7 +1172,28 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) { fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
let mut left: Vec<Span<'static>> = Vec::new(); let mut left: Vec<Span<'static>> = Vec::new();
if app.queued_input_count() > 0 { if app.is_command_mode() {
left.push(Span::styled(
"COMMAND Esc cancel Enter dispatch",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
let suggestions = app.command_suggestions();
if !suggestions.is_empty() {
let suggestion_text = suggestions
.iter()
.take(4)
.map(|candidate| format!("{}{}", candidate.name, candidate.description))
.collect::<Vec<_>>()
.join(" | ");
left.push(Span::styled(" ", Style::default()));
left.push(Span::styled(
truncate_with_ellipsis(&suggestion_text, area.width.saturating_sub(34) as usize),
Style::default().fg(Color::DarkGray),
));
}
} else if app.queued_input_count() > 0 {
left.push(Span::styled( left.push(Span::styled(
"Alt-q edit queued Alt-c clear queued", "Alt-q edit queued Alt-c clear queued",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
@ -1196,13 +1233,21 @@ fn queue_status_text(app: &App) -> Option<String> {
Some(text) Some(text)
} }
fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) { fn draw_input(frame: &mut Frame, app: &App, render: &crate::input::InputRender, area: Rect) {
// Prefix "> " on the first row, two-space gutter for continuation // Prefix prompt on the first row, matching-width gutter for continuation
// rows so multi-line input aligns visually. // rows so multi-line input aligns visually.
let prompt_style = Style::default().fg(Color::DarkGray); let prompt = if app.is_command_mode() { ": " } else { "> " };
let continuation = if app.is_command_mode() { ": " } else { " " };
let prompt_style = if app.is_command_mode() {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len()); let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len());
for (i, src) in render.lines.iter().enumerate() { for (i, src) in render.lines.iter().enumerate() {
let prefix = if i == 0 { "> " } else { " " }; let prefix = if i == 0 { prompt } else { continuation };
let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)]; let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)];
spans.extend(src.spans.iter().cloned()); spans.extend(src.spans.iter().cloned());
lines.push(Line::from(spans)); lines.push(Line::from(spans));

View File

@ -1,91 +0,0 @@
# ネイティブ GUI クライアント MVP
## 背景
TUI は ratatui の `insert_before` を使った append-only モデルで動いており、ツール呼び出しのライブ更新(引数のストリーミングプレビュー、状態遷移の視覚化)のような「既に描いた領域の書き換え」が本質的に苦手である。`tickets/tui-tool-call-ui.md` で妥協的な拡張策inline viewport を可変化してアクティブフレームを保持は立てたが、terminal のスクロールバックモデルと live-updating な LLM UX の相性は根本的に悪く、どう組んでも制約と妥協がついて回る。
一方、GUI 側ならそもそも**全領域が毎フレーム再描画される retained-mode**なので、ツールフレームの live 更新・折り畳み・インタラクティブな介入といった操作が自然に書ける。TUI は軽量・ssh 親和のクライアントとして残し、**リッチな対話は GUI クライアントに切り出す**方針を取る。
## 方針
### アーキテクチャ
- **プロセス分離 + ソケット接続を維持**。GUI は独立バイナリとして動き、Pod はこれまで通り別プロセス。
- **通信は既存の `protocol` クレート**`Method` / `Event`をそのまま使う。GUI と TUI は同じ protocol を喋る。
- **Pod の spawn は GUI から直接行う**。manifest を選択 → `pod` バイナリを subprocess として起動 → その socket に接続、という流れ。daemon 層は導入しない。
- **MVP は単一 Pod**。複数 Pod の並列管理は本チケットの範囲外とし、GUI 側の protocol 抽象が固まってから別チケットで拡張する。
### GUI フレームワーク: GPUI
- Rust ネイティブ + async-aware で、`protocol` クレートを直接リンクできる。
- GPU 加速の retained-mode で、ツールフレームの live 更新が素直に書ける。
- virtualized list / text input / scrollable 履歴など、LLM チャット UI に必要な部品が一通り揃っている。
- 既知のリスク: プラットフォーム成熟度macOS > Linux > Windows、独立ライブラリとしての新しさ、Markdown レンダラ等のウィジェット生態系が Tauri/Iced より薄い。本 MVP は Linux only なのでプラットフォーム面のリスクは受容できる。
### プラットフォーム
- **Linux only**。macOS / Windows は MVP の範囲外。GPUI の Linux サポートが動作する前提で組む。
## MVP スコープ
### 含む
1. **Pod の spawn と接続**
- manifest ファイルを選ぶ UIファイルダイアログ or CLI 引数)
- `pod` バイナリを subprocess として起動し、その socket に接続
- 接続確立後は TUI と同じ protocol で対話
2. **現 TUI の機能相当**
- 入力フィールド + 送信
- ストリーミングテキストの表示(`TextDelta` → 追記)
- ターンヘッダ / ターン統計 / ステータスバー相当の情報表示
- セッション再開時の履歴復元(`Event::History`
- エラー表示
- cancel / graceful shutdown
3. **ツール呼び出しのフレーム更新 UI**
- `tickets/tui-tool-call-ui.md` で定義したライフサイクルPending → Streaming → Executing → Done/Errorをそのまま GPUI 側で実装
- TUI と違い inline viewport の制約が無いので、履歴スクロール内でも自由に再描画できる
- `ToolCallArgsDelta` を毎フレーム反映してライブプレビュー
- 完了済みフレームは履歴内に状態が焼き込まれた形で残る
4. **Pod の明示的 shutdown**
- GUI から shutdown 操作を行い、Pod subprocess を graceful に終了させる
- shutdown 完了後は GUI 自体も正常終了する
### 含まない
- 複数 Pod の並列表示・切替(別チケット)
- daemon 層の導入
- macOS / Windows サポート
- ツール結果のリッチレンダリングMarkdown 整形、シンタックスハイライト、diff 表示等)
- ツール実行への対話的介入permission ask/reply の UI 実装は `tickets/permission-extension-point.md` 側)
- protocol の拡張compact 通知・R-R パターン等は `tickets/protocol-design.md` 側で進行し、GUI は完了次第追従する)
- GUI 内での manifest 編集
- テーマカスタマイズ、キーバインドカスタマイズ
## 設計で決めること
- **GPUI のイベントループと Tokio ランタイムの統合**: GPUI 側の executor に Pod からの socket イベントをどう流し込むか
- **socket client の置き場所**: 現 `crates/tui/src/client.rs` と同等のクライアントを別 crate に切り出して共有するか、GUI crate 内に閉じて持つか
- **Pod subprocess のライフサイクル管理**: GUI プロセスが落ちたときの Pod 側の後処理orphan prevention、Pod が異常終了したときの GUI 側の復帰 UX
- **ツールフレームのデータモデル**: `OutputItem` 列に載せるか別コレクションで持つかTUI 側の設計議論と共通部分あり。共有可能なら abstract して流用)
- **履歴スクロールの挙動**: 下端追従chat 流儀)と手動スクロール時の追従停止
- **入力エリアの多行対応**: 単一行でよいか、複数行 + Ctrl+Enter 送信等
## 完了条件
- Linux 上で GUI バイナリが起動し、manifest を指定すると `pod` subprocess を起動して socket 接続する。
- 基本的なチャットuser 入力 → assistant 応答のストリーミング → ターン統計)が TUI と同等に動く。
- ツール呼び出しが 1 フレームとして表示され、`ToolCallArgsDelta` がライブでプレビューされ、完了時に視覚的に状態が変わる。
- セッション再開時に履歴(ツール呼び出し含む)が復元される。
- GUI から shutdown 操作で Pod を正常終了させられ、GUI 自体も正常終了する。
## 新規クレート構成(案)
- `crates/gui/` GPUI を使うバイナリ)
- socket client を共有する場合は `crates/client/` を新設し、TUI と GUI がそれぞれ依存する形に整理する選択肢もある
## 範囲外(再掲・重要なもの)
- 複数 Pod の並列管理・切替。単一 Pod に集中する。
- macOS / Windows。Linux only で完結させる。
- protocol の新規イベント追加。既存 protocol で足りる範囲に留める。
- TUI の廃止。TUI は軽量クライアントとして並行して残る。

View File

@ -0,0 +1,62 @@
# TUI: navigation mode / block focus の設計
## 背景
TUI の操作は現在 composer を中心にしており、履歴 block / task 表示 / queued input / system 操作の間を移動する統一的な navigation model はまだない。今後 command mode、manual compact、rollback、Pod picker、queue 編集などが増えると、Ctrl/Alt shortcut だけでは操作体系が散らばる。
一方で、通常入力は最優先で守る必要がある。特に streaming 中の入力取りこぼしや rollback restore、Run 中 input queue を入れたことで、composer の文字入力を暗黙操作で壊さないことが重要になっている。
本チケットは navigation mode のアイデアを保持する設計 ticket であり、すぐ実装する前提ではない。
## アイデア
- 通常は composer mode。
- 文字入力、Enter submit/queue、`@` / `#` / `/` 補完を優先する。
- `Esc` など明示操作で navigation mode に入る。
- 履歴 block / task pane / queued input / picker 的 UI に focus を移す。
- focus があることを視覚的に分かるようにする。
- navigation mode では `j/k` または `↑/↓` で block focus / scroll を行う。
- `i` / `Enter` / `Esc` で composer に戻る案。
- composer のカーソルが最上行にある時の `↑` で履歴へ抜ける自然操作も候補。
- ただし multi-line input / IME / completion / typed segment と衝突しやすいため、初期実装では慎重に扱う。
- 暗黙 focus 移動より、明示 navigation mode を優先する案が安全。
- command mode (`:`) とは分ける。
- command mode は system command 入力。
- navigation mode は画面上の対象選択 / scroll / block action。
## 検討事項
- mode 名と status/actionbar 表示。
- composer mode から navigation mode へ入る key。
- navigation mode から composer mode へ戻る key。
- `↑/↓` を composer cursor movement と block focus movement のどちらに使うか。
- block focus の単位。
- Turn header
- User message
- Assistant block
- Tool call/result
- System message
- Task row
- Queued input row
- focused block に対する action。
- copy
- expand/collapse
- retry/fork/rollback など将来操作
- scrollback と block focus の関係。
- search (`/` ではなく別 key が必要。`/` は WorkflowRef と衝突する可能性)。
- mouse support を入れるか。
## 完了条件(未確定)
- navigation mode の keymap と UI 表示方針が決まる。
- composer 入力を壊さない focus 移動ルールが決まる。
- block focus の最小単位が決まる。
- command mode / queue / rollback / Pod picker と衝突しない。
- 実装 ticket に分割できる。
## 範囲外
- 今すぐの実装。
- command mode の実装(`tickets/tui-command-mode.md`)。
- compact command の実装。
- Vim 完全互換。

View File

@ -0,0 +1,60 @@
# TUI: spawned child Pod の一覧と一時 attach
## 背景
insomnia の開発では、親 Pod が複数の実装 Pod / reviewer Pod を spawn し、並列に作業させる運用が増えている。現在、spawned child の状態確認や出力確認は主に tool (`ListPods`, `ReadPodOutput`, `SendToPod`, `StopPod`) 経由で行っているが、TUI 上では親 Pod の会話と child Pod の進捗を行き来しにくい。
ネイティブ GUI は将来的には便利だが、現時点で必要なタスクではない。まず TUI のまま、現在の Pod が spawn した child Pod を一覧し、一時的に attach / view できる UI を用意したい。
## 要件
- TUI 上で、現在の Pod が spawn した child Pod を一覧できる。
- source は spawned child registry / Pod state persistence を使う。
- ホスト上の全 Pod を無条件に見せる UI にはしない。
- current parent から見える child Pod だけを対象にする。
- 各 child row には最低限以下を表示する。
- pod name
- alive / stopped / unreachable などの状態
- delegated scope の概要
- 最終更新時刻または最終出力時刻(取得できる範囲)
- 未読出力の有無または最終 assistant text preview可能なら
- TUI から child Pod に一時 attach / view できる。
- 親 Pod の TUI を完全に終了せず、child の履歴 / streaming 出力を確認できる。
- 戻る操作で親 Pod view に戻れる。
- 最小実装では read-only view でもよい。child へ入力を送る操作は後続でもよい。
- child view 中でも、どの Pod を見ているか視覚的に分かる。
- status line / title / breadcrumb など。
- child が stopped / unreachable の場合は明確に表示し、attach 失敗を診断する。
- 既存 tool の `ListPods` / `ReadPodOutput` / `SendToPod` / `StopPod` の意味を変えない。
- visibility は parent-child 関係に基づけ、Pod discovery の global list と混ぜない。
## 操作案
詳細 keybinding は実装時に確定する。
候補:
- command mode から `:pods` で child Pod list を開く。
- list 上で Enter すると child view へ一時 attach。
- `Esc` / `b` / command で parent view へ戻る。
- child view から `:send` などで入力する機能は後続 ticket にしてよい。
## 完了条件
- 親 Pod の TUI で spawned child Pod の一覧を表示できる。
- live child Pod を選択すると、その child の snapshot / streaming output を TUI 上で確認できる。
- parent view に戻れる。
- stopped / unreachable child は一覧上で状態が分かり、attach 失敗が診断される。
- ホスト全 Pod ではなく、parent から見える child Pod だけが対象である。
- `cargo fmt --check`
- `cargo check --workspace`
- `cargo test -p tui -p pod -p protocol`
## 範囲外
- ネイティブ GUI クライアント。
- 複数 Pod view の同時分割表示。
- child Pod への full interactive input。
- child Pod の自動再起動。
- host-wide Pod browser。
- Pod discovery tool の visibility model 変更。

View File

@ -1,66 +0,0 @@
# TUI から任意タイミングで Compact を実行する system command
## 背景
Compact は現在、manifest の compaction 設定と token 閾値に基づいて Pod 側で自動実行される。TUI は `CompactStart` / `CompactDone` / `CompactFailed` のイベントを受けて履歴上に表示できるが、ユーザーが TUI から任意のタイミングで Compact を明示実行する導線はない。
一方、TUI の入力欄にはすでに以下の sigil がある。
- `@`: FileRef / clipboard などの添付
- `#`: KnowledgeRef
- `/`: WorkflowRef
そのため、Compact のような Pod / TUI の制御操作を `/compact``#compact` として扱うと、既存の参照・補完体系と衝突する。通常の user message として LLM に送る入力とも明確に区別する必要がある。
## ゴール
TUI から、会話本文を送らずに任意タイミングで Compact を発火できるようにする。あわせて、Compact だけの特例ではなく、将来の TUI / Pod 制御操作にも使える system command の入口を用意する。
## 要件
### System command の入力体系
TUI に、通常の user message / `@` / `#` / `/` とは衝突しない system command の入力体系を追加する。
- `/``#` は使わない
- `@` FileRef とも衝突しない
- 入力された system command は LLM への user message として送られない
- command の発火は UI 上で見分けられる
- 未知 command や引数不正は、Pod に送らず TUI 側でユーザーに診断を出す
記法にするか、専用モードにするか、keybinding から command palette 的に起動するかは本チケット内で確定してよい。ただし既存の submit / completion / paste / chip 化の入力体験を壊さないこと。
### Manual Compact
System command から Compact を明示実行できるようにする。
- Idle 中に実行できる
- Run / Pause / spawn dialog / session picker など、実行できない状態では明確に拒否または無効化する
- 実行時に通常の user message は追加しない
- 既存の Compact lifecycle 表示(`CompactStart` / `CompactDone` / `CompactFailed`)と整合する
- Compact 完了後の session rotation / history restore / status 表示が、auto compact と同じ前提で動く
- compaction 設定が無い、または compactor model が解決できない場合の診断を TUI に出す
### Protocol / Pod 側
必要であれば、client → Pod の typed control method を追加する。
- `Method::Run` に特殊文字列を流す形にはしない
- Compact 実行は Pod 側の既存 compact 経路を使い、auto compact と履歴・store・broadcast のセマンティクスを分岐させない
- concurrent run 中の compact 要求、重複 compact 要求、shutdown 中の要求などの状態遷移を明確に扱う
## 完了条件
- TUI から任意タイミングで Compact を明示実行できる
- Compact 発火に使う system command の記法またはモードが、`@` / `#` / `/` と衝突していない
- system command が LLM への user message として history に混入しない
- 実行不可状態や失敗時の診断が TUI に表示される
- auto compact と同じ lifecycle event / history rotation 前提で表示が更新される
- protocol / Pod / TUI の必要なテストが追加されている
## 範囲外
- Compact の要約品質や prompt の変更
- compaction 閾値・retained token など manifest 設定の再設計
- slash command と WorkflowRef の意味論変更
- system command の豊富なコマンド群追加(本チケットでは Compact を最初の利用者として入口を作る)

View File

@ -1,37 +0,0 @@
# llm-worker: history append を callback 経由の単一経路に閉じる
## 背景
`pod-interrupt-prep-internalize` レビュー過程で、`Worker` の history append API に **callback を踏む経路****踏まない経路** が併存していることが顕在化した。
- callback を踏む経路worker 内部 private: `extend_history_with_callbacks``emit_history_append(&item)` を呼んでから `self.history.push(item)`。`Worker::run` 内部の streaming commit / tool result commit はこの経路。
- callback を**踏まない**経路(外部公開): `Worker::push_item` / `Worker::extend_history` / builder の `with_items``self.history.push` / `extend` するだけで `emit_history_append` を呼ばない。
session-log への永続化は `Pod::wire_history_persistence``Worker::on_history_append` を立て、callback 内で `classify_history_item``LogEntry::AssistantItem` / `LogEntry::ToolResult` として `writer.append_entry` する作りになっている。
結果として「callback 不発火経路で append すると in-memory `worker.history` には載るが session-log の独立エントリにはならない」という非対称が API 契約として残っている。実際 production では `Pod::apply_interrupt_prep` (`crates/pod/src/pod.rs:1438` / `:1441`) がこの不発火経路を踏んでおり、Paused→Run 時の orphan tool_result closure と interrupt system note が session-log に独立行として残らない。
CLAUDE.md の「LLM コンテキスト加工原則」は「新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる」と謳っているが、この「自動的についてくる」が現状の API 契約では保証されていない。**契約として callback バイパスが可能な設計であってはならない**。
## 要件
- `Worker` の history を成長させる経路を、必ず `on_history_append` callback を踏む単一経路に統一する。
- 外部から呼び出せる「callback 不発火な history append」API を廃止する。
- 対象: `Worker::push_item`、`Worker::extend_history`、`WorkerBuilder::with_items` 系
- 内部実装の `extend_history_with_callbacks` 相当を唯一の append プリミティブに格上げする(命名は実装時に整理)
- `Pod::apply_interrupt_prep` を新 API に乗せ替える。乗せ替えの副作用として、Paused→Run 時の orphan tool_result closure / interrupt system note が `LogEntry::ToolResult` / `LogEntry::SystemItem` 系として session-log に独立エントリで記録されるようになる(これは本チケットで肯定的に取り込む変化)。
- 注: system message は `PodInterceptor` 経由で `LogEntry::SystemItem` として典型化されている経路があるので、callback 内のフィルタ (`pod.rs:381-389` の `Role::System` skip) との重複を作らないこと。interrupt system note は callback 単独で書く / interceptor 単独で書く / 両方書かない、のいずれかに整合させる。実装時に確認。
- `worker_state_test.rs` を含む既存テストは、新 API に書き換えるか、builder 段階で history を仕込む正当な用途として整理する。前者を基本とする。
## スコープ外
- `Worker::clear_history` の扱いappend ではなく削除なので別論点)。
- `LogEntry` バリアントの設計変更。
- callback で書かれるエントリの形式・命名の整理(`classify_history_item` の現行挙動を前提として進める)。
- `Notify` / `PodEvent` / `<system-reminder>` 系の history 反映ポリシー全体(`system-reminder` 注入機構の汎用化として別途整理する。本チケットはあくまで「API 契約から callback バイパスを消す」レイヤに閉じる)。
## 完了条件
- `Worker::push_item` / `Worker::extend_history` / `WorkerBuilder::with_items` 等、callback を踏まずに `worker.history` を成長させられる public API がコードベースに存在しない。
- `cargo check --workspace``cargo test -p llm-worker --lib` / `cargo test -p pod` が通る。
- `apply_interrupt_prep` が新 API 経由になり、interrupt 前処理由来の append が session-log の独立エントリとして残ることを `~/.insomnia/sessions/*.jsonl` で目視確認できる(手順をレビュー時の備考に記載)。