Compare commits

...

71 Commits

Author SHA1 Message Date
94f63b1324
merge: sync orchestration before queue 00001KV5W3PJ3 2026-06-18 20:20:44 +09:00
e729e75aaa
ticket: record spawn runtime failure 2026-06-17 18:51:03 +09:00
d32fb3bc3c
ticket: route wasm runtime and panel readiness e2e 2026-06-17 18:49:29 +09:00
bcb8068ebd
ticket: queue 00001KV5W3PHW 2026-06-17 18:46:10 +09:00
a2583b8114
ticket: queue 00001KV62PF32 2026-06-17 18:46:03 +09:00
9d477f37e7
ticket: close plugin tool surface 2026-06-17 14:44:13 +09:00
cce36419ac
merge: integrate orchestration branch 2026-06-17 14:41:20 +09:00
05c6978d80
ticket: correct panel startup readiness metric 2026-06-17 14:41:15 +09:00
0da2b5db7b
ticket: complete plugin tool surface 2026-06-16 01:40:07 +09:00
204d0d022f
merge: plugin tool surface registration 2026-06-16 01:38:46 +09:00
fb39bf38a1
ticket: approve plugin tool schema fix 2026-06-16 01:38:46 +09:00
fb44159261
ticket: record plugin tool schema fix 2026-06-16 01:31:00 +09:00
3413bae7d7
fix: reject nested plugin tool schema errors 2026-06-16 01:30:00 +09:00
d9b986853f
ticket: request plugin tool schema changes 2026-06-16 01:26:44 +09:00
f262815990
ticket: record plugin tool surface implementation 2026-06-16 01:20:13 +09:00
05a9c52217
feat: register plugin tool surfaces 2026-06-16 01:18:54 +09:00
fcae886044
ticket: accept plugin tool surface work 2026-06-16 00:54:32 +09:00
1fdb4cd6f4
ticket: queue 00001KV5W3PHA 2026-06-16 00:53:32 +09:00
15e60dcbe6
merge: integrate orchestration branch 2026-06-16 00:50:08 +09:00
a03b86e749
ticket: complete plugin resolver 2026-06-16 00:30:09 +09:00
f678383aad
merge: plugin package resolver 2026-06-16 00:27:15 +09:00
1337425504
ticket: approve plugin resolver restore fix 2026-06-16 00:27:15 +09:00
37e11e465f
ticket: record plugin resolver restore fix 2026-06-16 00:16:22 +09:00
07978d2df5
fix: persist plugin snapshots for restore 2026-06-16 00:15:04 +09:00
65803c7868
ticket: close completed panel work 2026-06-16 00:09:02 +09:00
a2b991adf8
ticket: request plugin resolver restore fixes 2026-06-16 00:06:02 +09:00
f223bf44ce
ticket: add plugin tool followups 2026-06-16 00:00:14 +09:00
60348708a1
ticket: record plugin resolver fixes 2026-06-15 23:53:30 +09:00
ede7acfdf6
fix: pin plugin resolution metadata 2026-06-15 23:52:13 +09:00
c29d378d4c
ticket: request plugin resolver changes 2026-06-15 23:37:18 +09:00
a89b7ac5de
ticket: complete panel startup latency e2e 2026-06-15 23:31:36 +09:00
6f99ebedcc
merge: panel startup latency e2e 2026-06-15 23:30:16 +09:00
77ace64f87
ticket: record plugin resolver implementation 2026-06-15 23:28:02 +09:00
a03a9da64a
feat: add plugin package resolver 2026-06-15 23:26:46 +09:00
9bad2745f7
fix: measure and defer panel startup reload 2026-06-15 23:24:16 +09:00
4772c4d6a5
ticket: route plugin and panel latency work 2026-06-15 23:01:29 +09:00
425a6c66a8
ticket: queue 00001KV5MRH6D 2026-06-15 22:59:47 +09:00
d8300a0211
ticket: queue 00001KV5R5V2S 2026-06-15 22:59:47 +09:00
49abbd9519
ticket: add panel startup latency e2e 2026-06-15 22:50:38 +09:00
65fa4c06ce
merge: integrate orchestration branch 2026-06-15 22:48:46 +09:00
c3d1490443
ticket: add plugin package resolver 2026-06-15 22:48:26 +09:00
648422c966
ticket: complete panel orchestration overlay 2026-06-15 22:05:29 +09:00
eeb6986f16
merge: panel orchestration overlay 2026-06-15 22:04:30 +09:00
3eef5fe1b5
ticket: approve panel orchestration overlay 2026-06-15 22:04:29 +09:00
01e8cd7fb0
ticket: record panel overlay implementation 2026-06-15 21:57:21 +09:00
e0ddbed1eb
feat: show orchestration ticket overlay in panel 2026-06-15 21:56:17 +09:00
95abdc8d94
ticket: accept panel orchestration overlay 2026-06-15 21:40:53 +09:00
318aa1912b
ticket: queue 00001KV5D7MG5 2026-06-15 21:39:21 +09:00
6572791e50
merge: integrate orchestration branch 2026-06-15 19:48:59 +09:00
665c58deab
ticket: add panel orchestration overlay 2026-06-15 19:48:49 +09:00
c0d8badf55
ticket: complete single-pod text selection 2026-06-15 16:16:06 +09:00
3fa52f2c01
merge: single-pod text selection 2026-06-15 16:11:47 +09:00
73e26c41b5
ticket: approve single-pod selection 2026-06-15 16:11:47 +09:00
314e317a9a
ticket: complete panel row hierarchy 2026-06-15 16:09:15 +09:00
8c00a6e98e
merge: panel row hierarchy 2026-06-15 16:08:23 +09:00
09c7153a04
ticket: approve panel row hierarchy 2026-06-15 16:08:23 +09:00
10c29c5ae4
ticket: record panel row hierarchy implementation 2026-06-15 16:04:17 +09:00
2998f37b5a
ticket: record single-pod selection implementation 2026-06-15 16:02:54 +09:00
f3b435e724
fix: clarify panel ticket row hierarchy 2026-06-15 16:02:31 +09:00
09f5e9d51a
feat: add single-pod text selection 2026-06-15 16:01:13 +09:00
79dda10da3
ticket: accept panel row hierarchy work 2026-06-15 15:52:29 +09:00
1d21aae32c
ticket: complete panel alt-enter newline 2026-06-15 15:51:12 +09:00
0335cad9ff
merge: panel alt-enter newline 2026-06-15 15:50:32 +09:00
dba335f74a
ticket: approve panel alt-enter fix 2026-06-15 15:50:32 +09:00
3001bc6873
ticket: record panel alt-enter implementation 2026-06-15 15:46:37 +09:00
5c33917753
fix: align panel alt-enter composer handling 2026-06-15 15:45:30 +09:00
368249d677
ticket: route queued tui panel work 2026-06-15 15:39:26 +09:00
f0de841360
ticket: queue 00001KV4ZPAD3 2026-06-15 15:37:02 +09:00
f205fb7540
ticket: queue 00001KV4ZDMV1 2026-06-15 15:37:01 +09:00
ba5d0ee605
ticket: queue 00001KV4YAAVY 2026-06-15 15:37:00 +09:00
6d4b700181
ticket: record panel and plugin followups 2026-06-15 15:35:35 +09:00
89 changed files with 8572 additions and 173 deletions

View File

@ -1,8 +1,8 @@
---
title: 'Plugin distribution package format and discovery'
state: 'done'
state: 'closed'
created_at: '2026-06-01T06:49:53Z'
updated_at: '2026-06-14T15:56:45Z'
updated_at: '2026-06-15T06:33:50Z'
queued_by: 'workspace-panel'
queued_at: '2026-06-14T15:40:15Z'
---

View File

@ -0,0 +1,3 @@
Ticket `00001KT0Z4BK8` (`Plugin distribution package format and discovery`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -375,4 +375,24 @@ Cleanup planned:
Reviewer approved, documentation/design implementation branch merged into the orchestration branch, and documentation-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T06:33:50Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T06:33:50Z status: closed -->
## 完了
Ticket `00001KT0Z4BK8` (`Plugin distribution package format and discovery`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -1,8 +1,8 @@
---
title: "Preserve active workflows across compaction"
state: 'done'
state: 'closed'
created_at: "2026-06-07T02:23:28Z"
updated_at: '2026-06-14T16:26:01Z'
updated_at: '2026-06-15T06:33:44Z'
queued_by: 'workspace-panel'
queued_at: '2026-06-14T15:23:07Z'
---

View File

@ -0,0 +1,3 @@
Ticket `00001KTFY8V80` (`Preserve active workflows across compaction`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -414,4 +414,24 @@ Cleanup planned:
Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T06:33:44Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T06:33:44Z status: closed -->
## 完了
Ticket `00001KTFY8V80` (`Preserve active workflows across compaction`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -1,8 +1,8 @@
---
title: 'Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows'
state: 'done'
state: 'closed'
created_at: '2026-06-13T10:54:31Z'
updated_at: '2026-06-14T15:55:36Z'
updated_at: '2026-06-15T06:33:52Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['panel-ux', 'local-role-session-registry', 'pod-session-state']

View File

@ -0,0 +1,3 @@
Ticket `00001KV09WYC6` (`Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -244,4 +244,24 @@ Cleanup planned:
Reviewer approved, implementation branch merged into the orchestration branch, focused validation passed in the Orchestrator worktree, and cleanup is ready. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T06:33:52Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T06:33:52Z status: closed -->
## 完了
Ticket `00001KV09WYC6` (`Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -1,8 +1,8 @@
---
title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない'
state: 'done'
state: 'closed'
created_at: '2026-06-14T14:56:51Z'
updated_at: '2026-06-14T16:38:01Z'
updated_at: '2026-06-15T04:12:40Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics']

View File

@ -0,0 +1,3 @@
Ticket `00001KV3A5CNH` (`Panel: invalid Ticket があっても Ticket 機能全体を無効化しない`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -351,4 +351,24 @@ Cleanup planned:
Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T04:12:40Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T04:12:40Z status: closed -->
## 完了
Ticket `00001KV3A5CNH` (`Panel: invalid Ticket があっても Ticket 機能全体を無効化しない`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -1,8 +1,8 @@
---
title: '対象 TUI/Panel merge commit の挙動を現行 E2E で確認する'
state: 'done'
state: 'closed'
created_at: '2026-06-14T15:24:05Z'
updated_at: '2026-06-14T16:54:05Z'
updated_at: '2026-06-15T06:33:44Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['e2e', 'tui', 'panel', 'regression-evidence']

View File

@ -0,0 +1,3 @@
Ticket `00001KV3BQ7Q3` (`対象 TUI/Panel merge commit の挙動を現行 E2E で確認する`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -235,4 +235,24 @@ Cleanup planned:
Reviewer approved, implementation/evidence branch merged into the orchestration branch, and E2E-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T06:33:44Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T06:33:44Z status: closed -->
## 完了
Ticket `00001KV3BQ7Q3` (`対象 TUI/Panel merge commit の挙動を現行 E2E で確認する`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260615-063825-1","ticket_id":"00001KV4YAAVY","kind":"accepted_plan","accepted_plan":{"summary":"Accept single-Pod View text selection/copy work. Implement mouse drag selection, highlight, Esc clear, y copy+clear, deterministic multi-item extraction, and focused tests without changing Panel row selection semantics.","branch":"impl/00001KV4YAAVY-single-pod-text-selection","worktree":"/home/hare/Projects/yoi/.worktree/00001KV4YAAVY-single-pod-text-selection","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. This single-Pod View work is source/logically disjoint from Panel composer work enough for parallel start."},"author":"yoi-orchestrator","at":"2026-06-15T06:38:25Z"}

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KV4YAAVY",
"kind": "related",
"target": "00001KV072V89",
"note": "different scope: Panel row click selection vs single-Pod View text selection",
"author": "yoi ticket",
"at": "2026-06-15T06:09:16Z"
},
{
"ticket_id": "00001KV4YAAVY",
"kind": "related",
"target": "00001KV10SN02",
"note": "mouse behavior/E2E coverage may need follow-up",
"author": "yoi ticket",
"at": "2026-06-15T06:09:17Z"
}
]
}

View File

@ -0,0 +1,110 @@
---
title: 'single-Pod View Item text をマウスドラッグで選択・コピーできるようにする'
state: 'closed'
created_at: '2026-06-15T06:08:19Z'
updated_at: '2026-06-15T14:59:40Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['tui', 'mouse-input', 'selection', 'clipboard', 'single-pod-view']
queued_by: 'workspace-panel'
queued_at: '2026-06-15T06:37:00Z'
---
## Background
single-Pod TUI の View では、端末ネイティブ選択に頼らず、Yoi 自身が View Item の表示テキストをマウスドラッグで選択できるようにする。
既存の `00001KV072V89``yoi panel` の View item / row click selection であり、この Ticket の対象ではない。この Ticket は Panel ではなく、通常の single-Pod conversation View に表示される Item text の選択・コピーを対象にする。
端末ネイティブ選択は不要。View 内でドラッグしたら自動的に Yoi の selection state に入り、`Esc` で解除、選択したまま `y` で copy & selection clear する。
## Requirements
- 対象は single-Pod TUI の View Item text。
- 主対象 Item は text-like なもの。
- UserItem
- SystemItem
- AssistantItem
- View 内の text-like Item 上で mouse drag すると、自動的に selection state に入る。
- drag start が anchor
- drag current/end が focus
- mouse release 後も selection は保持される
- 選択状態は View 上で highlight される。
- `Esc` で selection を解除する。
- selection がある状態で `y` を押すと、選択テキストを copy し、selection を解除する。
- Item を跨いだ selection を可能にする。
- text-like Item 間を跨ぐ selection は supported behavior とする。
- Item 間の copied text separator は読みやすく deterministic にする。基本は newline / blank line のどちらかを実装時に選び、test で固定する。
- 選択 text は model context / Pod history / session log / Ticket に記録しない。
- composer 入力、scroll、rewind picker、modal/popup、通常 key handling と衝突しない。
- View に mouse selection を妨げる mode は基本的に置かない。
- terminal-native text selection preservation はこの Ticket の goal ではない。
- View の drag 操作は Yoi text selection として扱う。
- 既存 Panel row click selection と混同しない。
- Panel row selection behavior を変更しない。
- single-Pod View Item text selection のみを対象にする。
## Tool / non-text Item handling
Tool 系 Item を selection range に含んだ場合のコピー仕様は未決定であり、実装前または実装中に明示判断する。
候補:
- tool系 Item を non-selectable gap として skip する
- tool系 Item は summary/placeholder text のみコピーする
- tool系 Item に selection が入ったら range boundary をそこで止める
この Ticket の必須範囲は、UserItem / SystemItem / AssistantItem の text-like Item selection である。Tool 系の扱いで設計判断が必要になった場合は、実装報告または decision comment に記録する。
## Copy target
`y` の copy target は実装時に既存 TUI/clipboard abstraction を確認して選ぶ。
優先:
- 既存 clipboard abstraction があればそれを使う。
- 無ければ最小の copy path を追加する。
OSC52 / system clipboard / internal copy buffer の選択は実装時に判断してよいが、以下を満たすこと:
- user-visible に copy 成功/失敗が分かる
- secret-like diagnostics を出さない
- selected text が model/history に混入しない
- tests で copy された text を検証できる
## Acceptance criteria
- single-Pod View の UserItem / SystemItem / AssistantItem text を mouse drag で選択できる。
- 選択範囲が View 上で highlight される。
- mouse release 後も selection が保持される。
- `Esc` で selection が解除される。
- `y` で selected text が copy され、selection が解除される。
- text-like Item を跨いだ selection が deterministic な text として copy される。
- Tool 系 Item を range に含んだ時の扱いが、実装または decision comment で明示されている。
- composer / scroll / rewind picker / modal / normal key handling の既存挙動が壊れない。
- Panel row mouse selection には regression がない。
- selection/copy state は Pod history、model context、session log、Ticket records に残らない。
- Focused tests cover:
- mouse coordinate -> View Item text point mapping
- drag start/update/release selection state
- multi-item text selection extraction
- Esc clear
- y copy + clear
- non-text/tool item handling decision
- Validation: focused `cargo test -p tui ...`, `cargo fmt --check` or `cargo fmt -p tui`, `cargo check -p tui --all-targets`, and `git diff --check`.
## Non-goals
- Panel Ticket/Pod row selection.
- Terminal-native text selection preservation.
- Generic terminal scrollback selection.
- Full rich text/HTML/markdown semantic selection.
- Double-click word selection / triple-click line selection.
- Selection in every TUI widget.
- Tool item rich output copy semantics beyond the explicit decision made for this Ticket.
## Related work
- `00001KV072V89` — Workspace panel の View item をマウスで選択できるようにする。Panel row click selection; not this Ticket's scope.
- `00001KV10SN02` — E2E critical path / mouse behavior coverage.
- `crates/tui/src/single_pod.rs` — single-Pod TUI View / mouse handling area.

View File

@ -0,0 +1,3 @@
Ticket `00001KV4YAAVY` (`single-Pod View Item text をマウスドラッグで選択・コピーできるようにする`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,286 @@
<!-- event: create author: "yoi ticket" at: 2026-06-15T06:08:19Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-15T06:37:00Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T06:38:25Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relation / OrchestrationPlan / Orchestrator workspace state を確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- 対象は single-Pod conversation View の text-like Item selection/copy に限定され、Panel row selection とは scope が分かれている。
- Tool/non-text Item と copy target は implementation-time decision として許容されており、binding invariants / acceptance criteria / escalation conditions が明確。
Evidence checked:
- Ticket body/thread: requirements、copy target、tool/non-text handling、acceptance criteria、non-goals、related work を確認。
- Ticket relations: `00001KV072V89``00001KV10SN02` は related のみで blocker ではない。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`f0de8413` 上。
- Visible Pods: implementation child Pod なし。Intake peer は routing blocker ではない。
- Bounded code map: `crates/tui/src/single_pod.rs`、copy/clipboard abstraction、mouse/selection/key handling tests。
IntentPacket:
Intent:
- single-Pod TUI conversation View で User/System/Assistant など text-like Item の表示テキストを mouse drag で選択し、`y` で copy、`Esc` で clear できるようにする。
Binding decisions / invariants:
- 対象は single-Pod View Item text。Panel Ticket/Pod row selection は変更しない。
- terminal-native text selection preservation は non-goal。View drag は Yoi text selection として扱う。
- selected/copy text、selection state、clipboard diagnostics は Pod history / model context / session log / Ticket records に残さない。
- composer input、scroll、rewind picker、modal/popup、normal key handling と衝突させない。
- bare Panel row mouse selection semantics は regress させない。
- Tool/non-text Item handling と copy target は実装報告または decision comment に明示する。
Requirements / acceptance criteria:
- UserItem / SystemItem / AssistantItem text を drag selection できる。
- drag start/update/release 後に selection state が残り、View 上で highlight される。
- `Esc` で selection clear。
- `y` で selected text を copy し、selection clear。
- text-like Item を跨いだ selection が deterministic separator で copy される。
- non-text/tool item handling decision が明示され、test で固定される。
- copy 成功/失敗が user-visible で、secret-like diagnostics を出さない。
- tests cover coordinate mapping、selection state、multi-item extraction、Esc、copy+clear、non-text/tool handling。
Implementation latitude:
- Existing clipboard abstraction があれば利用。なければ testable な最小 copy path を追加してよい。
- OSC52/system clipboard/internal copy buffer の選択は既存 TUI architecture に合わせる。
- rendering/highlight 表現、selection model の internal shape、separator は実装側判断。ただし deterministic にする。
Escalate if:
- terminal/crossterm event stream で必要な drag/release を識別できない。
- existing rendering model から text coordinate mapping を安全に取れない。
- copy target を追加すると secrets/history/model-context boundary に影響する。
- rewind/modal/composer key handling と共存できない大きな model change が必要。
Validation:
- focused `cargo test -p tui ...` for single_pod selection/copy。
- `cargo check -p tui --all-targets`
- `cargo fmt --check`
- `git diff --check`
- Panel row mouse selection regression test if related mouse plumbing is touched。
Critical risks / reviewer focus:
- selection/copy state の persistence leakage。
- mouse coordinate -> rendered text mapping correctness。
- multi-item extraction ordering/separator。
- composer/scroll/rewind/modal conflict。
- clipboard path safety and testability。
- Panel mouse behavior regression absence。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T06:39:16Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、single-Pod View work は同時に開始する Panel composer work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV4YAAVY at: 2026-06-15T07:02:42Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV4YAAVY`:
Commit:
- `09f5e9d5 feat: add single-pod text selection`
Changed files:
- `crates/tui/src/text_selection.rs`
- New transient selection state, coordinate mapping, extraction, and focused tests.
- `crates/tui/src/app.rs`
- Local non-persistent selection state on `App`.
- `crates/tui/src/ui.rs`
- Selectable history row metadata, highlight rendering, snapshot wiring, selectable/non-text tests.
- `crates/tui/src/single_pod.rs`
- Drag mouse handling, `Esc` clear, `y` copy+clear, OSC52 copy, tests.
- `crates/tui/src/lib.rs`
- Module registration.
- `crates/tui/Cargo.toml`, `Cargo.lock`
- Added direct `base64` dependency for OSC52 clipboard payloads.
Behavior / implementation decisions:
- Selectable Items:
- `UserMessage`
- `SystemMessage`
- `AssistantText`
- Non-text/tool Items:
- tool calls, thinking, stats, greetings, alerts, turn headers, etc. are not selectable/copyable through this path.
- extraction skips non-selectable rows.
- Cross-item selection separator:
- copied text follows rendered row order and preserves selectable blank spacer rows, giving deterministic `\n\n` between adjacent text-like Items.
- Copy target:
- terminal clipboard via OSC52 (`ESC ] 52 ; c ; base64 BEL`), not OS clipboard APIs.
- avoids adding platform clipboard runtime behavior.
- Copy diagnostics:
- actionbar-only sanitized messages:
- success: `copied selected text to terminal clipboard`
- failure: `terminal clipboard write failed`
- no selected content appears in diagnostics.
- Selection persistence:
- selection state/copied text remains TUI-local only.
- not appended to Pod history, model context, session logs, Ticket records, memory, or durable state.
- Mouse tracking:
- single-Pod TUI now enables button-event drag tracking (`?1002h`) but not all-motion tracking (`?1003h`).
- Panel code was not changed.
Validation reported by coder:
- Passed: `cargo fmt --check`
- Passed: `cargo check -p tui --all-targets`
- Passed: `cargo test -p tui text_selection`
- Passed: `cargo test -p tui single_pod::tests`
- Passed: `cargo test -p tui ui::tests::history_rows_mark_text_items_selectable_and_non_text_unselectable`
- Passed: `git diff --check`
Repository status:
- Child implementation worktree clean after commit.
Residual risks / notes:
- OSC52 clipboard success means sequence written/flushed; actual clipboard acceptance depends on terminal/multiplexer policy.
- No real PTY/E2E validation was run; coverage is focused unit-level mouse/key/selection/render extraction behavior.
---
<!-- event: review author: yoi-reviewer-00001KV4YAAVY at: 2026-06-15T07:11:34Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Reviewed child worktree only:
- `/home/hare/Projects/yoi/.worktree/00001KV4YAAVY-single-pod-text-selection`
- diff `368249d6..09f5e9d5`
- Ticket record/thread in the child worktree
- Read-only validation:
- Passed: `git diff --check 368249d6..09f5e9d5`
Implementation review:
- TUI-local selection state was added in `crates/tui/src/text_selection.rs` and wired into single-Pod rendering/input.
- Mouse drag maps terminal coordinates through `HistoryViewport` into rendered history rows.
- Drag start/update/release are handled in `single_pod.rs`; selection remains active after release.
- `Esc` clears selection before normal composer handling.
- bare `y` copies only when a selection exists; otherwise composer input remains available.
- Highlighting is applied through `range_for_row` and `highlight_line_selection`.
- Selectable item boundary is explicit:
- selectable: `UserMessage`, `SystemMessage`, `AssistantText`
- non-selectable: tool calls, thinking, notices, stats, greetings, etc.
- Tool/non-text rows are marked unselectable and extraction skips unselectable rows.
- Cross-item extraction is deterministic, with documented newline/blank-line behavior.
- Copy uses OSC52; selected text is base64 encoded before writing the OSC52 sequence.
- Copy diagnostics are generic/actionbar-local and do not include selected content.
- Selection state lives in TUI-local `App.text_selection`; no path was found that appends selected/copied text to Pod history, model context, session log, Ticket records, memory, or durable state.
- Mouse capture changes are limited to single-Pod fullscreen setup and avoid all-motion `?1003h`; Panel/multi-pod mouse row selection code was not modified.
- Composer, scroll, rewind picker, completion/key routing, and normal key handling interactions were inspected and no blocker found.
Coverage reviewed:
- coordinate mapping
- drag update/release state
- multi-row / separator extraction
- non-selectable row skipping
- Esc clear
- copy helper + clear
- UI selectable/non-selectable marking
- mouse capture mode avoiding all-motion tracking
Non-blocking notes:
- No PTY/E2E validation was run; terminal-specific OSC52/mouse behavior still depends on real terminal behavior.
- OSC52 cannot acknowledge terminal clipboard acceptance; implementation can only report write/flush success/failure.
- There is no explicit byte-size cap on OSC52 payload, but selection is bounded by visible rendered viewport rather than arbitrary full history traversal. Future hard cap/error notice would be reasonable hardening.
- A full fixture test constructing an actual `Block::ToolCall` could strengthen coverage, but the non-text path is explicit and source-inspected.
Validation not rerun by reviewer:
- Cargo validation was not rerun because review scope was read-only and Cargo writes build artifacts. Coder-reported validation was inspected as evidence.
Conclusion:
- Approved. No blocker remains.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T07:15:54Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV4YAAVY-single-pod-text-selection`
- implementation commit: `09f5e9d5 feat: add single-pod text selection`
- merge commit: `3fa52f2c merge: single-pod text selection`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV4YAAVY`.
Additional integration fix:
- Because the implementation added a direct `base64` dependency to `crates/tui/Cargo.toml` and updated `Cargo.lock`, `nix build .#yoi --no-link` first failed with stale `package.nix` `cargoHash`.
- Updated `package.nix` `cargoHash` to the new fixed-output vendor hash:
- `sha256-pIDYnbBs3U8Z3IndgH10rirv8/IdFv1WlgwpCbKXy+M=`
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo fmt --check`
- Passed: `cargo check -p tui --all-targets`
- Passed: `cargo test -p tui text_selection`
- 4 passed
- Passed: `cargo test -p tui single_pod::tests`
- 38 passed
- Passed: `cargo test -p tui ui::tests::history_rows_mark_text_items_selectable_and_non_text_unselectable`
- 1 passed
- Passed: `git diff --check`
- Passed after hash update: `nix build .#yoi --no-link`
Notes:
- OSC52 clipboard acceptance remains terminal/multiplexer dependent; implementation reports write/flush success/failure only.
- No PTY/E2E validation was run for real terminal selection/copy behavior; focused unit coverage and reviewer inspection covered selection/copy state, extraction, and persistence boundaries.
- Orchestrator worktree is clean apart from the pending `package.nix`/Ticket integration commit at the time of recording.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T07:15:54Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation branch merged into the orchestration branch, integration validation passed including Nix package hash refresh, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T14:59:40Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T14:59:40Z status: closed -->
## 完了
Ticket `00001KV4YAAVY` (`single-Pod View Item text をマウスドラッグで選択・コピーできるようにする`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260615-063902-1","ticket_id":"00001KV4ZDMV1","kind":"accepted_plan","accepted_plan":{"summary":"Accept Panel Alt+Enter newline bugfix. Preserve bare Enter semantics and shared composer key handling while adding focused regression coverage.","branch":"impl/00001KV4ZDMV1-panel-alt-enter-newline","worktree":"/home/hare/Projects/yoi/.worktree/00001KV4ZDMV1-panel-alt-enter-newline","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Run in parallel with single-Pod selection work; defer Panel row hierarchy visual work to avoid overlapping Panel rendering edits."},"author":"yoi-orchestrator","at":"2026-06-15T06:39:02Z"}

View File

@ -0,0 +1,85 @@
---
title: 'Panel composer で Alt+Enter 改行を SessionView と揃える'
state: 'closed'
created_at: '2026-06-15T06:27:36Z'
updated_at: '2026-06-15T14:59:39Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['tui-input', 'ux-consistency']
queued_by: 'workspace-panel'
queued_at: '2026-06-15T06:37:01Z'
---
## Background
Panel の Composer でも、SessionView / 通常チャット composer と同じように `Alt+Enter` で改行できるようにする。
既存 Ticket `00001KTNS1AA8` で通常 TUI と workspace Panel の composer key handling を共有する方向が実装・完了済み。thread には shared editing が `Alt+Enter newline` を含む旨も記録されている。
一方、現行の Panel key handling では `KeyCode::Enter` の submit/open 分岐が `composer_edit_action(key)` より先に処理されているため、`Alt+Enter` が `InsertNewline` として到達せず、通常 Enter と同じ挙動になる可能性がある。Panel composer でも SessionView と同じ入力体験に揃える。
## Requirements
- Panel composer で `Alt+Enter` が draft 内の改行挿入として動作する。
- `Alt+Enter` は composer text の submit、Ticket Intake launch、Companion send、selected row open/dispatch を起こさない。
- 通常の bare `Enter` の既存 semantics は維持する。
- composer が空なら selected row / open / dispatch。
- composer に text があるなら current composer target への submit。
- 通常 SessionView 側の `Alt+Enter` 改行挙動を壊さない。
- 既存の shared `composer_keys` 方針を保ち、Panel 専用の ad-hoc key handling を増やしすぎない。
## Acceptance criteria
- `yoi panel` の composer に text がある状態で `Alt+Enter` を押すと、draft に newline が入る。
- composer が空、row selected、Ticket action selected などの Panel 状態でも、`Alt+Enter` は submit/open/dispatch ではなく改行編集として扱われる。
- bare `Enter` の Panel submit/open/dispatch behavior は regress しない。
- focused test で `Alt+Enter` が Panel composer newline を挿入し、submit action を返さないことを確認する。
- 既存 SessionView / normal TUI composer key tests が通る。
## Binding decisions / invariants
- `Alt+Enter` は composer editing key として扱い、Panel action key として扱わない。
- bare `Enter``Alt+Enter` の意味を明確に分ける。
- Panel composer は通常 TUI composer と同じ shared composer key handling 方針に従う。
- Panel row selection / Ticket action semantics、Companion / Ticket Intake target semantics は変更しない。
- bare letter shortcuts は復活させない。
## Implementation latitude
- `composer_edit_action(key)` を Enter submit/open 分岐より前に適用する、または `KeyCode::Enter` 分岐側で modifiers を明示的に見て `Alt+Enter` を除外するなど、実装方法は任せる。
- Existing tests に追加する形でも、新規 focused regression test を作る形でもよい。
- UI hint の更新は必要なら最小限に行う。主目的は key behavior の修正。
## Readiness
- readiness: implementation_ready
- priority: P2 相当
- risk_flags: [tui-input, ux-consistency]
- open_questions: none
## Escalation conditions
- terminal / crossterm が対象環境で `Alt+Enter` を識別できない場合は、実装可能範囲と制約を implementation report に明記する。
- `Alt+Enter` を先に composer edit として処理すると、既存 completion / target switching / row action の前提と衝突する場合は Orchestrator / reviewer に相談する。
- SessionView 側にも同様の問題がある場合は、Panel 固有 bug ではなく shared composer key handling の regression として扱う。
## Validation
- Focused test: Panel composer で `Alt+Enter` が newline を挿入し、submit/open/dispatch しない。
- Focused test: bare `Enter` の既存 Panel behavior が維持される。
- `cargo test -p tui composer_keys`
- `cargo test -p tui` または少なくとも該当 `multi_pod` / Panel focused tests
- `cargo fmt --check`
- `git diff --check`
- 必要に応じて `cargo check --workspace`
## Related work
- `00001KTNS1AA8` Improve workspace panel display and composer key handling
- `crates/tui/src/composer_keys.rs`
- `crates/tui/src/multi_pod.rs`
## Panel handoff
- workspace: `yoi`
- workspace_orchestrator_pod: `yoi-orchestrator`

View File

@ -0,0 +1,3 @@
Ticket `00001KV4ZDMV1` (`Panel composer で Alt+Enter 改行を SessionView と揃える`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,230 @@
<!-- event: create author: LocalTicketBackend at: 2026-06-15T06:27:36Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-15T06:37:01Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T06:39:02Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- 既存 shared composer key handling 方針があり、主な不確実性は Panel の Enter 分岐順序/修飾キー処理の local fix と regression coverage に閉じている。
- 同時 queued の `00001KV4YAAVY` は single-Pod View selection で主対象が異なるため並行開始可能。`00001KV4ZPAD3` は Panel row rendering surface と重なるため待機に回す。
Evidence checked:
- Ticket body/thread: Background、requirements、acceptance criteria、binding decisions、validation、related work を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`f0de8413` 上。
- Visible Pods: implementation child Pod なし。
- Bounded code map: `crates/tui/src/composer_keys.rs`, `crates/tui/src/multi_pod.rs`, relevant Panel composer tests。
IntentPacket:
Intent:
- Workspace Panel composer で `Alt+Enter` を SessionView / shared composer key handling と同じ改行挿入として扱い、submit/open/dispatch を起こさないようにする。
Binding decisions / invariants:
- `Alt+Enter` は composer editing key。Panel action key ではない。
- bare `Enter``Alt+Enter` の意味を明確に分離する。
- Panel composer は shared `composer_keys` 方針に従う。
- Panel row selection / Ticket action semantics、Companion / Ticket Intake target semantics は変更しない。
- bare letter shortcuts は復活させない。
Requirements / acceptance criteria:
- Panel composer に text がある状態で `Alt+Enter` が draft newline を挿入する。
- composer empty / row selected / Ticket action selected でも `Alt+Enter` は submit/open/dispatch しない。
- bare `Enter` の existing submit/open/dispatch behavior は維持される。
- SessionView 側の `Alt+Enter` newline behavior は壊さない。
- focused test で Panel `Alt+Enter` newline and no action を確認する。
Implementation latitude:
- `composer_edit_action(key)` を Enter action 分岐より前に適用するか、Enter 分岐で modifiers を除外するかは実装判断。
- Existing tests への追加または新規 focused regression test のどちらでもよい。
- UI hint update は必要なら最小限。
Escalate if:
- terminal/crossterm が対象環境で `Alt+Enter` を識別できない。
- edit-first 処理が completion / target switching / row action 前提と衝突する。
- SessionView 側にも同根の regression が見つかる。
Validation:
- `cargo test -p tui composer_keys`
- Panel focused tests / `cargo test -p tui multi_pod --lib` など変更箇所に応じた subset。
- `cargo fmt --check`
- `git diff --check`
Critical risks / reviewer focus:
- `Alt+Enter` が action dispatch に落ちないこと。
- bare `Enter` semantics の regression。
- shared composer key handling 方針から外れた ad-hoc 実装になっていないこと。
- Panel target / row selection action identity が変わらないこと。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T06:39:16Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Panel Alt+Enter bugfix は同時に開始する single-Pod View selection work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。Panel visual hierarchy work `00001KV4ZPAD3` は conflict/capacity 理由で queued のまま待機させる。
---
<!-- event: implementation_report author: yoi-coder-00001KV4ZDMV1 at: 2026-06-15T06:46:31Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV4ZDMV1`:
Commit:
- `5c339177 fix: align panel alt-enter composer handling`
Changed files:
- `crates/tui/src/multi_pod.rs`
- Panel now resolves shared `composer_edit_action` modifier actions before Panel `Enter` actions.
- `Alt+Enter` is handled as `InsertNewline` and returns `MultiPodAction::None`, so it cannot open Pods, dispatch Ticket actions, launch Intake, or send Companion messages.
- Added focused Panel tests for:
- non-empty composer inserts newline without Companion send
- blank Pod row selection inserts newline without open
- blank Ticket action selection inserts newline without dispatch
- `crates/tui/src/composer_keys.rs`
- Added explicit shared-keymap coverage for `Alt+Enter -> InsertNewline`.
Validation reported by coder:
- Passed: `cargo test -p tui composer_keys`
- Passed: `cargo test -p tui multi_alt_enter --lib`
- Passed focused bare-Enter regression checks:
- `cargo test -p tui multi_empty_enter_uses_open_action --lib`
- `cargo test -p tui multi_non_empty_enter_reports_companion_unavailable --lib`
- `cargo test -p tui multi_ticket_intake_enter_builds_launch_request_not_direct_send --lib`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Broader suite note:
- Attempted: `cargo test -p tui multi_pod --lib`
- Failed in existing unrelated test:
- `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`
- assertion mismatch: expected target/orchestration path vs `/repo/yoi`
- Coder assessed this as outside the Alt+Enter change path; focused Ticket tests pass.
Repository status:
- Child implementation worktree clean after commit.
---
<!-- event: review author: yoi-reviewer-00001KV4ZDMV1 at: 2026-06-15T06:50:25Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Implementation commit `5c339177` against base `368249d6` in child worktree.
- Diff limited to:
- `crates/tui/src/composer_keys.rs`
- `crates/tui/src/multi_pod.rs`
- Shared composer policy maps `Alt+Enter` to `ComposerEditAction::InsertNewline` through `composer_edit_action`.
- Panel `handle_key` consults `composer_edit_action(key)` before bare `Enter` routing and immediately applies modifier composer actions.
- This prevents `Alt+Enter` from falling through to open / dispatch / Intake launch / Companion send.
- Existing bare `Enter` routing remains structurally unchanged.
- Bare `Enter` remains excluded from shared composer editing.
- Focused Panel tests cover text composer, blank Pod row selection, and blank Ticket action selection.
- Existing bare-Enter regression tests remain present.
Validation performed by reviewer:
- Passed: `git diff --check 368249d6..5c339177`
- Passed: `cargo fmt --check`
- Worktree remained clean.
Validation not run by reviewer:
- Cargo tests were not rerun because review scope was read-only and tests write build artifacts. Coder-reported focused test results were reviewed as evidence.
Broader-suite failure assessment:
- The reported broader `cargo test -p tui multi_pod --lib` failure in an orchestration-root test appears unrelated based on diff scope. The implementation does not touch orchestration worktree/root logic.
Conclusion:
- Approved. No blocker remains.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T06:51:06Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV4ZDMV1-panel-alt-enter-newline`
- implementation commit: `5c339177 fix: align panel alt-enter composer handling`
- merge commit: `0335cad9 merge: panel alt-enter newline`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV4ZDMV1`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p tui composer_keys`
- 4 passed
- Passed: `cargo test -p tui multi_alt_enter --lib`
- 3 passed
- Passed: `cargo test -p tui multi_empty_enter_uses_open_action --lib`
- 1 passed
- Passed: `cargo test -p tui multi_non_empty_enter_reports_companion_unavailable --lib`
- 1 passed
- Passed: `cargo test -p tui multi_ticket_intake_enter_builds_launch_request_not_direct_send --lib`
- 1 passed
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Notes:
- Broader `cargo test -p tui multi_pod --lib` was not used as a blocker because the known failure is an unrelated orchestration-root test outside the Alt+Enter change path.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T06:51:06Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T14:59:39Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T14:59:39Z status: closed -->
## 完了
Ticket `00001KV4ZDMV1` (`Panel composer で Alt+Enter 改行を SessionView と揃える`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1,3 @@
{"id":"orch-plan-20260615-063902-1","ticket_id":"00001KV4ZPAD3","kind":"conflicts_with","related_ticket":"00001KV4ZDMV1","note":"Panel rendering/input surface (`crates/tui/src/multi_pod.rs` / Panel row selection/action display) が `00001KV4ZDMV1` と重なる可能性があるため、Alt+Enter Panel composer work の outcome 後に開始する。","author":"yoi-orchestrator","at":"2026-06-15T06:39:02Z"}
{"id":"orch-plan-20260615-063916-2","ticket_id":"00001KV4ZPAD3","kind":"waiting_capacity_note","note":"`00001KV4YAAVY` と `00001KV4ZDMV1` を並行開始する。`00001KV4ZPAD3` は implementation_ready だが、Panel rendering/input surface の conflict risk と review/integration capacity のため queued のまま待機する。","author":"yoi-orchestrator","at":"2026-06-15T06:39:16Z"}
{"id":"orch-plan-20260615-065209-3","ticket_id":"00001KV4ZPAD3","kind":"accepted_plan","accepted_plan":{"summary":"Accept Panel Ticket/Intake visual hierarchy improvement after Panel Alt+Enter integration. Improve primary/secondary row styling, child-row affordance, selection clarity, and bounded tests without changing lifecycle/registry/action semantics.","branch":"impl/00001KV4ZPAD3-panel-row-hierarchy","worktree":"/home/hare/Projects/yoi/.worktree/00001KV4ZPAD3-panel-row-hierarchy","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Prior conflict with `00001KV4ZDMV1` is resolved because Alt+Enter work is merged/validated/done."},"author":"yoi-orchestrator","at":"2026-06-15T06:52:09Z"}

View File

@ -0,0 +1,117 @@
---
title: 'Panel Ticket / Intake Pod row の視覚階層を改善する'
state: 'closed'
created_at: '2026-06-15T06:32:21Z'
updated_at: '2026-06-15T14:59:39Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['panel-ux', 'tui-layout', 'accessibility', 'row-selection']
queued_by: 'workspace-panel'
queued_at: '2026-06-15T06:37:02Z'
---
## Background
Workspace Panel では、Ticket row が2行表示になり、Ticket に関連する Intake Pod row も Ticket の隣接 row として表示されるようになっている。
しかし現状では、以下の視覚的な区別が弱く、一覧として読んだときに情報の優先順位が掴みにくい。
- Ticket 本体の primary line と detail/gate line の強弱。
- Ticket row と associated Intake Pod row の親子関係。
- canonical state/title、gate/action/reason、Intake Pod status/open 導線の重要度差。
- 選択中 row/group と非選択 row/group の境界。
今回の作業は Panel 表示の visual hierarchy / readability 改善に限定する。Ticket lifecycle、local role/session registry、claim semantics、automatic launch/spawn policy は変更しない。
## Request snapshot
> 前やった Panel の2行レイアウトと、IntakePod の関連表示の件、表示の強弱がついてなくて見辛いから、改善したい。
Panel handoff:
- workspace: `yoi`
- workspace_orchestrator_pod: `yoi-orchestrator`
## Requirements
- Workspace Panel の Ticket 2行 row に視覚的な強弱を付ける。
- 1行目の canonical state + title は primary 情報として読みやすくする。
- 2行目の Ticket id / Gate / Action / reason は secondary 情報として、必要な時に読めるが主情報を邪魔しない表示にする。
- Ticket-associated Intake Pod row が、その Ticket の子/関連 row であると視覚的に分かるようにする。
- indentation、prefix、dim style、role/status chip、group marker など、既存 Panel UI と整合する方法でよい。
- 選択状態では、Ticket 2行 + associated Intake row の関係が崩れない。
- どの logical row / child row が選択されているかが分かる。
- Ticket 本体と Intake Pod row の操作対象が混同されない。
- live / restorable / stale など Intake Pod status の表示は残しつつ、Ticket title/gate より過剰に目立たないようにする。
- narrow terminal / short panel area でも破綻しない。
- truncate / ellipsis / bounded rendering を維持する。
- 色だけに依存しない表示にする。
- terminal theme や monochrome 環境でも、indentation / marker / text label で最低限の関係が分かる。
## Acceptance criteria
- Ticket 2行 row で、primary line と secondary line の視覚的な強弱が確認できる。
- Ticket-associated Intake Pod row が、隣接する Ticket の関連/子 row として認識できる。
- Intake Pod row が Ticket 本体や別 Ticket に見えない。
- selected Ticket row / selected Intake Pod row の見え方が明確で、操作対象が混同されない。
- `ready`, `planning`, `queued/inprogress`, `done/closed`, `ready+waiting` の Ticket row で可読性が維持される。
- Intake Pod の `live` / `restorable` / `stale` status が確認できる。
- pre-Ticket Intake Pod を誤って特定 Ticket の child row のように表示しない。
- mouse click / keyboard selection の既存 semantics を壊さない。
- Focused tests で row rendering contract または ViewModel/row ordering/selection contract が確認される。
## Binding decisions / invariants
- Ticket lifecycle state / relation gate semantics は変更しない。
- persisted `waiting` state や新しい Ticket schema は追加しない。
- local Pod assignment / Pod name / socket / claim state / runtime status を git-tracked Ticket metadata/frontmatter/thread に保存しない。
- automatic polling / automatic Intake spawn は追加しない。
- Ticket と Intake Pod の関係を 1:1 と仮定しない。
- selected arbitrary Pod direct-send UX を復活させない。
- 表示改善のために lifecycle action の authority boundary を緩めない。
## Implementation latitude
- exact な UI 表現は実装側判断でよい。
- dim / bold / color / prefix / indentation / separator / role chip / status chip など。
- 既存 `PanelRowKind::Ticket` / `PanelRowKind::TicketIntakePod` の rendering を調整してもよい。
- 必要なら shared row style helper を整理してよい。
- 色を使う場合でも、marker / label / indentation など非色要素で関係性が分かるようにする。
- Very small terminal で情報を減らす場合、primary state/title と action safety を優先する。
## Readiness
- readiness: implementation_ready
- risk_flags: [panel-ux, tui-layout, accessibility, row-selection]
- blocking open questions: none
## Escalation conditions
- Panel の selection model / keyboard semantics を大きく変える必要が出た場合。
- Ticket row と Intake Pod row の action identity が曖昧になり、誤操作リスクが出る場合。
- 色/装飾だけでは accessibility / terminal theme 上の問題を避けられない場合。
- Row rendering のために local role/session registry や Ticket schema の変更が必要になりそうな場合。
- bounded row rendering を維持できず、大量 Ticket/Pod で一覧が読みにくくなる場合。
## Validation
- `cargo test -p tui workspace_panel --lib`
- 関連箇所を触る場合:
- `cargo test -p tui multi_pod --lib`
- `cargo test -p tui row_hit_testing --lib`
- `cargo test -p tui mouse_click --lib`
- `cargo fmt --check`
- `git diff --check`
- 可能なら実際の `yoi panel` / PTY で目視確認、または既存 Panel E2E の更新。
## Related work
- `00001KV12W2RT` — Panel Ticket rows を2行表示にして gate 情報を分離する
- `00001KV09WYC6` — Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows
- `00001KV3A5CNH` — Panel: invalid Ticket があっても Ticket 機能全体を無効化しない
Code areas:
- `crates/tui/src/workspace_panel.rs`
- `crates/tui/src/multi_pod.rs`
- `crates/tui/src/role_session_registry.rs`

View File

@ -0,0 +1,3 @@
Ticket `00001KV4ZPAD3` (`Panel Ticket / Intake Pod row の視覚階層を改善する`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,302 @@
<!-- event: create author: ticket-intake at: 2026-06-15T06:32:21Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: ticket-intake at: 2026-06-15T06:32:36Z -->
## Intake summary
ユーザー承認に基づき、Panel Ticket 2行 row と Ticket-associated Intake Pod row の視覚階層改善を concrete Ticket として作成した。既存 Ticket `00001KV12W2RT` / `00001KV09WYC6` の follow-up であり、目的は visual hierarchy / readability 改善に限定される。Ticket lifecycle、relation gate semantics、local role/session registry、claim semantics、automatic launch/spawn policy は変更しない。readiness: implementation_ready。risk_flags: [panel-ux, tui-layout, accessibility, row-selection]。blocking open questions はない。
---
<!-- event: state_changed author: ticket-intake at: 2026-06-15T06:32:36Z from: planning to: ready reason: planning_ready field: state -->
## State changed
Intake で要件・受け入れ条件・binding decisions / invariants・implementation latitude・escalation conditions・validation が整理され、ユーザー承認済み。Orchestrator が routing 可能な ready 状態にする。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-15T06:37:02Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T06:39:02Z -->
## Decision
Routing decision: implementation_ready_but_waiting_conflict_capacity
Reason:
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。要件・受け入れ条件・invariants は実装可能な粒度で揃っており、planning に戻す concrete missing information はない。
- ただし同時に `00001KV4ZDMV1` を Panel composer key handling work として accept する。両者は `crates/tui/src/multi_pod.rs` / Panel rendering・input surface に触れる可能性があり、並行すると row rendering / selected row / action identity regression の確認が曖昧になる。
- `00001KV4YAAVY` は single-Pod View selection で並行開始するため、現時点では Panel visual hierarchy work を追加 spawn せず、先行 Panel composer work の outcome を待つ。
Evidence checked:
- Ticket body/thread: visual hierarchy/readability requirements、accessibility、row-selection invariants、validation を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`f0de8413` 上。
- Visible Pods: implementation child Pod なし。これから `00001KV4YAAVY``00001KV4ZDMV1` を acceptance 予定。
Next action:
- `00001KV4ZDMV1` の implementation/review/integration 後、Panel rendering surface を再確認し、unblocked なら `queued -> inprogress` acceptance へ進む。
- planning return ではなく queued のまま waiting とする。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T06:52:09Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Prior waiting reason は `00001KV4ZDMV1` と Panel rendering/input surface が重なる conflict/capacity だった。
- `00001KV4ZDMV1` は reviewer approve、orchestration branch merge、focused validation、Ticket `done` まで完了したため、prior conflict は解消した。
- Ticket body / thread / relations / orchestration plan / current Orchestrator workspace を再確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- 現在 active な `00001KV4YAAVY` は single-Pod View Item text selection/copy であり、Panel Ticket/Intake visual hierarchy と主対象が異なるため並行開始可能。
Evidence checked:
- Ticket body/thread: Ticket 2行 row、associated Intake Pod row、selection/group visibility、accessibility、bounded rendering、validation を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: prior `conflicts_with 00001KV4ZDMV1` と waiting_capacity_note を確認。`00001KV4ZDMV1` 完了により blocker は解消。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`1d21aae3` 上。
- Visible Pods: `yoi-coder-00001KV4YAAVY` running。source/logical surface は single-Pod View selection であり、本 Ticket と直接競合しない。
IntentPacket:
Intent:
- Workspace Panel の Ticket 2行 row と Ticket-associated Intake Pod row の visual hierarchy / readability を改善し、親子関係・primary/secondary 情報・選択対象が読み取りやすい表示にする。
Binding decisions / invariants:
- Ticket lifecycle state / relation gate semantics は変更しない。
- persisted `waiting` state や新しい Ticket schema は追加しない。
- local Pod assignment / Pod name / socket / claim state / runtime status を git-tracked Ticket metadata/frontmatter/thread に保存しない。
- automatic polling / automatic Intake spawn は追加しない。
- Ticket と Intake Pod の関係を 1:1 と仮定しない。
- selected arbitrary Pod direct-send UX を復活させない。
- 表示改善のために lifecycle action authority boundary を緩めない。
- 色だけに依存せず、indentation / marker / label 等で minimum relationship を示す。
Requirements / acceptance criteria:
- Ticket 2行 row で primary line と secondary line の強弱が確認できる。
- Ticket-associated Intake Pod row が隣接 Ticket の child/related row として認識できる。
- Intake Pod row が Ticket 本体や別 Ticket に見えない。
- selected Ticket row / selected Intake Pod row の見え方が明確で、操作対象が混同されない。
- `ready`, `planning`, `queued/inprogress`, `done/closed`, `ready+waiting` の Ticket row で readability が維持される。
- Intake Pod `live` / `restorable` / `stale` status が確認できる。
- pre-Ticket Intake Pod を特定 Ticket child row のように誤表示しない。
- mouse click / keyboard selection semantics を壊さない。
- Focused tests で row rendering contract または ViewModel/row ordering/selection contract を確認する。
Implementation latitude:
- dim / bold / color / prefix / indentation / separator / role chip / status chip 等は実装判断。
- `PanelRowKind::Ticket` / `PanelRowKind::TicketIntakePod` rendering 調整可。
- 必要なら shared row style helper を整理可。
- Very small terminal では primary state/title と action safety を優先して bounded rendering する。
Escalate if:
- Panel selection model / keyboard semantics の大幅変更が必要。
- Ticket row と Intake Pod row の action identity が曖昧になり誤操作リスクが出る。
- 色/装飾だけでは accessibility / terminal theme 上の問題を避けられない。
- Row rendering のために local role/session registry や Ticket schema 変更が必要。
- bounded row rendering を維持できず大量 Ticket/Pod で一覧が読みにくくなる。
Validation:
- `cargo test -p tui workspace_panel --lib`
- 関連箇所に応じて `cargo test -p tui multi_pod --lib` / `cargo test -p tui row_hit_testing --lib` / `cargo test -p tui mouse_click --lib`
- `cargo fmt --check`
- `git diff --check`
- 可能なら `yoi panel` / PTY 目視または既存 Panel E2E 更新。
Critical risks / reviewer focus:
- visual hierarchy improves readability without changing action identity。
- Ticket vs Intake child row distinction remains non-color-dependent。
- selection highlight and child grouping remain understandable。
- lifecycle authority and local role/session registry boundaries unchanged。
- bounded rendering/narrow terminal behavior。
- recently merged Alt+Enter and invalid Ticket placeholder behavior not regressed。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T06:52:19Z from: queued to: inprogress reason: orchestrator_acceptance_after_conflict_resolution field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。先行 `00001KV4ZDMV1` は merge/validation/done 済みで prior conflict/waiting reason は解消。blocking relation / unresolved orchestration-plan blocker はないため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV4ZPAD3 at: 2026-06-15T07:04:06Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV4ZPAD3`:
Commit:
- `f3b435e7 fix: clarify panel ticket row hierarchy`
Changed files:
- `crates/tui/src/multi_pod.rs`
- `crates/tui/src/workspace_panel.rs`
Visual hierarchy decisions:
- Ticket rows now use a clearer two-line structure:
- primary line uses selected `▶ ` marker and carries state + title
- secondary line uses `meta` label and selected `│ ` marker, making id/gate/action information clearly secondary
- Ticket-associated Intake Pod rows now render as adjacent child rows:
- non-selected child marker: ` └ `
- selected child marker: ` ▶ `
- `live` / `restorable` / `stale` remains visible in status column
- title is `Intake Pod: <name>` without embedding tree glyphs in the ViewModel title
- Selected Intake Pod status line now says `selected Intake Pod ...` instead of generic selected Pod wording.
- No Ticket lifecycle, relation gate, action authority, persisted schema, polling, or spawn semantics were changed.
Focused tests added/updated:
- Ticket row rendering asserts primary vs secondary line structure.
- Intake child row rendering asserts single-line child indentation/marker/status/title placement.
- Selected Intake Pod target status asserts it is visually described as Intake-related, not generic Ticket or Pod.
Validation reported by coder:
- Passed: `cargo test -p tui workspace_panel --lib`
- Passed: `cargo test -p tui panel_ticket --lib`
- Passed: `cargo test -p tui selected_ticket_intake_child_status --lib`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
- Ran: `cargo test -p tui multi_pod --lib`
- Failed in pre-existing/unrelated workspace context tests:
- `tests::workspace_panel_spawns_orchestrator_with_workspace_context`
- `tests::workspace_panel_spawns_panel_orchestrator_with_workspace_context`
- Assertion mismatch concerns expected `/workspace` vs temp test workspace paths, not row rendering/selection changes.
Residual risks:
- No interactive `yoi panel` PTY session was run; visual evidence is covered by focused render/string tests.
- Full `multi_pod` suite remains blocked by unrelated existing failures noted above.
---
<!-- event: review author: yoi-reviewer-00001KV4ZPAD3 at: 2026-06-15T07:08:12Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Reviewed diff `79dda10d..f3b435e7`.
- Changed files limited to:
- `crates/tui/src/multi_pod.rs`
- `crates/tui/src/workspace_panel.rs`
- Worktree was clean and on expected branch/commit:
- `impl/00001KV4ZPAD3-panel-row-hierarchy`
- `f3b435e7 fix: clarify panel ticket row hierarchy`
Acceptance review:
- Ticket two-line rows now have clearer hierarchy:
- primary line uses selected marker `▶ ` plus state/title;
- detail line uses `│ meta ` when selected and `meta` label for secondary metadata.
- Ticket-associated Intake Pod rows now render as child rows with indentation plus `└` / indented `▶` and `Intake Pod: <pod>` labeling.
- Relationship is not color-only; indentation/marker/label communicate hierarchy.
- Selected Ticket vs selected Intake Pod are distinguishable through marker placement, label, color/style, and status text.
- `live`, `restorable`, and `stale` Intake status remains visible.
- Pre-Ticket Intake Pod behavior appears preserved; existing tests still assert pre-ticket sessions are not emitted as `TicketIntakePod` child rows.
- Mouse/keyboard action semantics appear unchanged; changes are limited to row rendering/status text.
- Ticket lifecycle transitions, relation gates, registry persistence, polling/spawn behavior, and Ticket schema were not changed.
- Invalid Ticket placeholder behavior remains non-actionable.
- Alt+Enter handling was not touched.
Focused coverage observed:
- Ticket primary/secondary row rendering contract updated.
- Non-selected alignment/truncation tests updated for `meta`.
- Waiting gate readability remains covered.
- New child-row rendering test covers indentation, single-line behavior, status column, title label, and selected child marker.
- New selected Intake child status test confirms it is not presented as a generic Ticket or generic Pod.
- Existing adjacency/pre-ticket exclusion test remains present.
Validation performed by reviewer:
- Passed: `git diff --check 79dda10d..f3b435e7`
- Passed: `cargo fmt --check`
Validation not run by reviewer:
- Cargo tests were not rerun because review scope was read-only and tests write build artifacts. Coder-reported focused test results were inspected as evidence.
Broader failure assessment:
- The reported broader `cargo test -p tui multi_pod --lib` failure appears unrelated based on diff scope; changes are confined to Panel row rendering/status presentation and tests around that contract, with no workspace-context/runtime setup logic changes.
Conclusion:
- Approved. No blocker remains.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T07:09:01Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV4ZPAD3-panel-row-hierarchy`
- implementation commit: `f3b435e7 fix: clarify panel ticket row hierarchy`
- merge commit: `8c00a6e9 merge: panel row hierarchy`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV4ZPAD3`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p tui workspace_panel --lib`
- 16 passed
- Passed: `cargo test -p tui panel_ticket --lib`
- 4 passed
- Passed: `cargo test -p tui selected_ticket_intake_child_status --lib`
- 1 passed
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Notes:
- Broader `cargo test -p tui multi_pod --lib` remains affected by unrelated workspace-context tests and was not used as a blocker.
- Ticket/Intake visual hierarchy improved without lifecycle/action/schema/polling/spawn semantic changes.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T07:09:01Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T14:59:39Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T14:59:39Z status: closed -->
## 完了
Ticket `00001KV4ZPAD3` (`Panel Ticket / Intake Pod row の視覚階層を改善する`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260615-124038-1","ticket_id":"00001KV5D7MG5","kind":"accepted_plan","accepted_plan":{"summary":"Accept Panel orchestration Ticket state overlay work. Implement read-only, source-qualified overlay from configured orchestration worktree with path/branch/repository safety checks and duplicate action gating.","branch":"impl/00001KV5D7MG5-panel-orchestration-overlay","worktree":"/home/hare/Projects/yoi/.worktree/00001KV5D7MG5-panel-orchestration-overlay","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Work is isolated to Panel/Ticket config/worktree overlay code and current workspace has no active child implementation Pods."},"author":"yoi-orchestrator","at":"2026-06-15T12:40:38Z"}

View File

@ -0,0 +1,107 @@
---
title: 'Panel に orchestration worktree の Ticket state overlay を表示する'
state: 'closed'
created_at: '2026-06-15T10:29:00Z'
updated_at: '2026-06-15T14:59:40Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['panel', 'ticket-state', 'orchestration', 'worktree', 'git-branch', 'read-only-overlay']
queued_by: 'workspace-panel'
queued_at: '2026-06-15T12:39:21Z'
---
## Background
Workspace Panel は現在、開いている workspace / branch の `.yoi/tickets` を Ticket state の authority として表示している。これは branch-local Ticket 正本として正しいが、Orchestrator が dedicated orchestration worktree / branch 上で Ticket を `inprogress -> done` に進めた場合、current workspace branch 側には merge されるまで反映されない。
その結果、Panel では `queued` のままに見える一方、orchestration branch では `inprogress``done` まで進んでいる、という乖離が起きる。
この Ticket では current branch の Ticket state を上書きせず、configured orchestration worktree の Ticket state を read-only overlay として読み、Panel に source-qualified progress として表示する。
## Requirements
- Panel の primary Ticket state は current workspace branch の `.yoi/tickets` のままにする。
- Configured orchestration worktree / branch を read-only overlay source として読む。
- `.yoi/ticket.config.toml``[orchestration]` 設定を使う。
- `branch`
- `worktree_dir`
- `worktree_name`
- Expected orchestration worktree path を current workspace root から解決する。
- default は `<workspace>/.worktree/orchestration`
- branch default は `orchestration`
- Overlay worktree を読む前に safety checks を行う。
- path が存在する
- git worktree である
- current repository と同じ git common dir / same repository である
- expected branch 上にいる
- canonical top-level が expected path と一致する
- Overlay は read-only とする。
- Panel が overlay Ticket file を変更しない。
- overlay state を current branch の Ticket file に自動反映しない。
- current branch の `state` を orchestration branch の `state` で上書きしない。
- Ticket identity は canonical Ticket id で join する。
- current branch に存在する Ticket に対して、同じ id の overlay Ticket があれば overlay state を表示する。
- overlay にしか存在しない Ticket の表示は実装時に方針を決めてよいが、current workspace Ticket list を勝手に増やす場合は source 表示を明確にする。
- Overlay 表示は source-qualified にする。
- 例: `orchestration: inprogress`
- 例: `orchestration: done · merge pending`
- 例: `local: queued · orchestration: done`
- Overlay state が current state より進んでいる場合、Panel action/gate は safety 側に倒す。
- current branch が `queued` でも overlay が `inprogress` / `done` なら、Queue/Start を再提示しない。
- overlay `done` なら merge/review/close pending として扱う導線を表示する。
- exact action wording は実装側で調整してよい。
- Overlay が読めない場合は、current branch state 表示にフォールバックする。
- branch mismatch / invalid worktree / missing worktree は bounded diagnostic として表示してよい。
- 誤った worktree の Ticket state を表示しない。
## Display guidance
Panel Ticket row は current branch の canonical state と overlay progress を分けて表示する。
例:
```text
queued E2E: close remaining critical-path gaps after panel harness
00001KV10SN02 | local: queued · orchestration: done · merge pending
```
または:
```text
ready Extend pod::feature API...
00001KTR81P9X | orchestration: inprogress · yoi-orchestrator
```
重要なのは、line 1 の canonical state を current branch state として残し、line 2 / gate / detail に orchestration overlay を表示すること。
## Acceptance criteria
- Workspace Panel ViewModel が configured orchestration worktree の `.yoi/tickets` を read-only overlay として読み込める。
- Overlay source は expected path / branch / same repository safety checks を通過した場合のみ使われる。
- Current branch の Ticket state は overlay state で上書きされない。
- current branch `queued` + overlay `inprogress` の Ticket が、Panel で overlay progress として表示される。
- current branch `queued` + overlay `done` の Ticket が、Panel で `orchestration: done` / merge pending 相当として表示され、Queue/Start action を再提示しない。
- overlay unavailable / branch mismatch / missing worktree の場合、Panel は panic せず current branch 表示にフォールバックする。
- Tests cover:
- overlay join by Ticket id
- queued local + inprogress overlay display
- queued local + done overlay display/action gating
- overlay branch mismatch ignored
- missing overlay worktree fallback
- no mutation to current branch Ticket files
- Validation: focused `tui` / workspace panel tests, `cargo fmt --check` or `cargo fmt -p tui`, `cargo check -p tui --all-targets`, and `git diff --check`.
## Non-goals
- Automatically merging orchestration branch state into current branch.
- Closing current branch Tickets based on overlay state.
- Starting/stopping Orchestrator Pods.
- Changing Ticket lifecycle state schema.
- Replacing git merge/review evidence with runtime overlay state.
- Showing arbitrary unrelated worktree Ticket state.
## Related work
- Orchestration worktree branch/path configuration: `00001KV0X254D`
- Panel two-line Ticket rows and gate display: `00001KV12W2RT`
- Panel visual hierarchy follow-up: `00001KV4ZPAD3`

View File

@ -0,0 +1,3 @@
Ticket `00001KV5D7MG5` (`Panel に orchestration worktree の Ticket state overlay を表示する`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -0,0 +1,271 @@
<!-- event: create author: "yoi ticket" at: 2026-06-15T10:29:00Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-15T12:39:21Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T12:40:38Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relation / OrchestrationPlan / Orchestrator workspace state / related closed Ticket context を確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- `00001KV0X254D``.yoi/ticket.config.toml` の orchestration branch 設定が実装済みであり、`00001KV12W2RT` / `00001KV4ZPAD3` で Panel Ticket row / hierarchy 表示の前提が整っている。
- Risk は panel / ticket-state / orchestration / worktree / git-branch / read-only-overlay だが、Ticket 本文に safety checks・read-only invariant・fallback・action gating が明記されているため、残る不確実性は implementation tactic に閉じている。
Evidence checked:
- Ticket body/thread: overlay source、safety checks、display guidance、action gating、fallback、acceptance criteria、non-goals を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: 既存 record なし。
- Related context: `00001KV0X254D` closedorchestration branch config、`00001KV12W2RT` closedtwo-line Ticket row/gate display
- Current config: `.yoi/ticket.config.toml` を確認。現 checkout には `[orchestration]` section がないため、default `<workspace>/.worktree/orchestration` / branch `orchestration` fallback を扱う必要がある。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`318aa191` 上。
- Visible Pods: implementation child Pod なし。
IntentPacket:
Intent:
- Workspace Panel の canonical current-branch Ticket state を上書きせず、configured orchestration worktree / branch の Ticket state を read-only overlay として読み、source-qualified progress と action gating を表示する。
Binding decisions / invariants:
- Primary Ticket state authority は current workspace branch の `.yoi/tickets` のまま。
- Overlay は read-only。overlay Ticket files を変更せず、current branch Ticket files に自動反映しない。
- current branch `state` を overlay state で上書きしない。
- Overlay source は expected path / branch / same repository / canonical top-level safety checks 通過時のみ使う。
- 誤った worktree / unrelated repository / branch mismatch の Ticket state を表示しない。
- Ticket identity join は canonical Ticket id。
- Overlay progress is source-qualified例: `orchestration: inprogress`, `local: queued · orchestration: done`)。
- Overlay が current state より進んでいる場合、Queue/Start 等の duplicate action は safety 側で抑止する。
- Runtime overlay は merge/review/close authority の代替ではない。
Requirements / acceptance criteria:
- Panel ViewModel が configured orchestration worktree `.yoi/tickets` を read-only overlay として読み込める。
- `[orchestration]` config の `branch` / `worktree_dir` / `worktree_name` と defaults を使って expected path/branch を解決する。
- current branch state は overlay state で変更されない。
- local `queued` + overlay `inprogress` を Panel に overlay progress として表示する。
- local `queued` + overlay `done``orchestration: done` / merge pending 相当として表示し、Queue/Start を再提示しない。
- overlay unavailable / branch mismatch / missing worktree は panic せず current branch 表示へ fallback し、必要なら bounded diagnostic を出す。
- Tests cover overlay id join、queued+inprogress、queued+done action gating、branch mismatch ignore、missing worktree fallback、current branch Ticket files non-mutation。
Implementation latitude:
- Overlay-only Ticket の表示有無は実装判断。ただし表示する場合は source を明確にする。
- Exact wording / gate/action label は既存 Panel row hierarchy に合わせて調整してよい。
- Safety check helper の配置は existing Ticket config / Panel orchestration worktree code に合わせる。
- Heavy full doctor を毎 render 必須にしない。
Escalate if:
- Ticket config model に `worktree_dir` / `worktree_name` が存在せず、新 schema の exact authority が必要になる。
- same-repository / git-common-dir validation が current abstractions で安全に書けない。
- overlay state を読むために current workspace以外の arbitrary path authority が必要になる。
- action gating が lifecycle authority と衝突する。
- overlay-only Tickets の表示方針が product decision を要する。
Validation:
- `cargo test -p tui workspace_panel --lib` or focused overlay tests。
- Relevant ticket config / orchestration worktree tests if config parsing changes。
- `cargo check -p tui --all-targets`
- `cargo fmt --check`
- `git diff --check`
Critical risks / reviewer focus:
- read-only overlay invariant。
- same-repository / branch / canonical path safety checks。
- source-qualified display; no state overwrite。
- duplicate Queue/Start action suppression when overlay has progressed。
- fallback diagnostics boundedness。
- no accidental merge/close authority substitution。
- row hierarchy and invalid Ticket tolerance regressions。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T12:40:45Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Orchestrator workspace は clean。implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV5D7MG5 at: 2026-06-15T12:57:12Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV5D7MG5`:
Commit:
- `e0ddbed1 feat: show orchestration ticket overlay in panel`
Changed files:
- `crates/tui/src/workspace_panel.rs`
- `crates/tui/src/multi_pod.rs`
Implemented behavior:
- Panel ViewModel attempts to read a read-only orchestration worktree Ticket state overlay from configured/default `[orchestration]` settings.
- Overlay loading validates:
- expected worktree path
- expected branch
- same Git common-dir/repository
- canonical Git top-level equals expected worktree path
- Overlay state is joined by canonical Ticket id only.
- Local/current branch `.yoi/tickets` remains primary authority and is not mutated or overwritten.
- Rows with progressed overlay state show source-qualified state such as:
- `local: queued · orchestration: inprogress`
- `local: queued · orchestration: done`
- Progressed overlay state suppresses duplicate queue/start-like actions:
- overlay `inprogress` is treated as active work / read-only progress
- overlay `done` is treated as merge/review pending and does not expose queue/start again
- Orchestrator work-set derivation accounts for overlay in-progress/done guards to avoid duplicate start routing.
Tests added/covered:
- overlay joins by Ticket id
- local `queued` + overlay `inprogress` display/action behavior
- local `queued` + overlay `done` merge-pending/action gating
- branch mismatch ignored
- missing worktree fallback
- local Ticket file not mutated by overlay reading
Validation reported by coder:
- Passed: `cargo test -p tui workspace_panel --lib`
- Passed: `cargo check -p tui --all-targets`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Repository status:
- Child implementation worktree clean after commit.
Residual blockers:
- None reported.
---
<!-- event: review author: yoi-reviewer-00001KV5D7MG5 at: 2026-06-15T13:04:06Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Inspected implementation diff `95abdc8d..e0ddbed1`.
- Changed files reviewed:
- `crates/tui/src/workspace_panel.rs`
- `crates/tui/src/multi_pod.rs`
- Worktree remained clean after read-only checks.
Acceptance review:
- Current branch remains primary authority:
- local Ticket summaries are loaded from the current workspace backend first;
- overlay data is joined by canonical Ticket id and stored separately in `PanelTicketInfo.orchestration_overlay`;
- `workflow_state` remains local/current-branch state and is not overwritten by overlay.
- Overlay is read-only:
- overlay loading uses `LocalTicketBackend::list_partial()` against the orchestration worktree ticket root;
- no overlay writes or current-branch Ticket mutation path was found;
- test coverage includes local Ticket files not being mutated by overlay read.
- Safety checks are appropriately conservative:
- expected configured/default orchestration path;
- path existence/directory check;
- canonical top-level comparison;
- expected branch check;
- same git common-dir check against current workspace.
- Branch mismatch and missing worktree fall back safely.
- Default orchestration config behavior is covered when no `[orchestration]` section is present.
- Canonical Ticket id join is covered; unrelated overlay Tickets do not affect local rows.
- Source-qualified display is present, e.g. `local: queued · orchestration: inprogress/done`.
- Duplicate Queue/Start suppression is implemented for local `queued` with overlay `inprogress` or `done`.
- `multi_pod` queued-work attention also suppresses already-progressed overlay work.
- Overlay `done` / `closed` remains merge/review-pending information and does not grant close/merge/lifecycle authority.
Validation performed by reviewer:
- Passed: `git diff --check 95abdc8d..e0ddbed1`
- Passed: `cargo fmt --check`
- Passed: `git status --short` clean
Validation not run by reviewer:
- `cargo test -p tui workspace_panel --lib`
- `cargo check -p tui --all-targets`
Reason: review scope was read-only and Cargo writes build artifacts. Coder-reported passing results for those commands were inspected as evidence.
Risk assessment:
- Low residual risk. The main behavior is ViewModel-tested and safety checks are conservative.
- Minor UI risk: compact status column may truncate long source-qualified overlay text, but the full model value and disabled/detail text carry overlay progress context.
Conclusion:
- Approved. No blocker found.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T13:05:20Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV5D7MG5-panel-orchestration-overlay`
- implementation commit: `e0ddbed1 feat: show orchestration ticket overlay in panel`
- merge commit: `eeb6986f merge: panel orchestration overlay`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV5D7MG5`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p tui workspace_panel --lib`
- 21 passed
- Passed: `cargo check -p tui --all-targets`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Notes:
- Overlay is read-only and source-qualified.
- Current branch Ticket state remains primary authority and is not overwritten.
- Missing worktree / branch mismatch / unrelated repo safety paths are covered by focused tests.
- Queue/Start-like duplicate actions are suppressed when overlay shows `inprogress` or `done`.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T13:05:20Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-15T14:59:40Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-15T14:59:40Z status: closed -->
## 完了
Ticket `00001KV5D7MG5` (`Panel に orchestration worktree の Ticket state overlay を表示する`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260615-140109-1","ticket_id":"00001KV5MRH6D","kind":"accepted_plan","accepted_plan":{"summary":"Accept Panel startup latency E2E measurement/improvement work. Measure first visible render via real yoi PTY E2E, separate background/full-ready waits, improve startup path where safe, and record before/after evidence.","branch":"impl/00001KV5MRH6D-panel-startup-latency","worktree":"/home/hare/Projects/yoi/.worktree/00001KV5MRH6D-panel-startup-latency","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Can run in parallel with Plugin resolver work because source surfaces are disjoint."},"author":"yoi-orchestrator","at":"2026-06-15T14:01:09Z"}

View File

@ -0,0 +1,105 @@
---
title: 'Panel 起動遅延の待ち要因を E2E 計測で特定し改善する'
state: 'done'
created_at: '2026-06-15T12:40:33Z'
updated_at: '2026-06-15T14:31:28Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['panel', 'tui', 'e2e', 'latency', 'runtime-observation']
queued_by: 'workspace-panel'
queued_at: '2026-06-15T13:59:47Z'
---
## Background
`yoi panel` 起動時に最大 7 秒程度かかることがある。TUI が表示されている裏で具体的に何を待っていて遅いのかを特定し、改善したい。さらに E2E で起動時間を測定し、E2E テストとして起動時間の基準を設けたい。
過去に `00001KTFMMZP0` で Panel transition / first draw の非同期化を実施済みだが、現在も startup latency が観測されている。`00001KV3BQ7Q3` では Panel/TUI の user-visible behavior を現行 E2E で確認済みだが、今回の startup latency の分解・改善・基準化は別の concrete work item として扱う。
## Requirements
- `yoi panel` 起動時の startup path を計測し、どの処理をどの順序で待っているかを特定する。
- 例: snapshot load、Ticket backend scan、Pod metadata/socket observation、Orchestrator/Companion observation、background reload barrier、terminal setup、first draw、E2E observer setup など。
- TUI が表示されている裏で待っている処理と、初回表示前に同期的に待っている処理を区別する。
- 実ユーザーに影響する起動 latency を改善する。
- E2E で `yoi panel` 起動時間を測定できる scenario / helper / observer を追加または更新する。
- E2E テストに、少なくとも以下のような基準を設ける。
- first visible frame / initial panel render が bounded に出ること。
- 必要なら full ready / background reload complete も別 metric として測る。
- 「最大 7 秒程度」の現象が E2E fixture で再現しない場合も、何を保証できたか、何が live/manual gap として残るかを明示する。
- 改善後の実装報告に、計測結果 before / after、待ち要因、変更点、残る gap を記録する。
## Acceptance criteria
- `yoi panel` startup E2E が実 `yoi` binary + PTY 経路で起動時間を測定している。
- E2E は単に process が起動することだけでなく、少なくとも **初回 visible panel/render 到達までの時間** を assertion している。
- 起動時間 budget が E2E test に明示されている。
- 提案値: fixture PTY 上の first visible panel/render は `1500 ms` 以内。
- full ready / background reload complete を測る場合は、別 budget として設定し、first visible budget と混同しない。
- 既存環境差・CI/fixture のばらつきに対して、過度に flaky でない threshold / retry / observer 設計になっている。
- 実装報告に以下が記録されている。
- 計測対象 command / test name。
- before / after の測定結果。
- startup path の主要 wait point。
- どの wait を削減・非同期化・遅延実行したか。
- E2E が保証する範囲と、保証しない live/manual gap。
- 既存 Panel/TUI E2E と fixture-local HOME / XDG / runtime / workspace isolation を壊さない。
- no-provider / no-network 前提を維持する。
- 既存の Ticket workflow / Pod restore/spawn authority / Orchestrator queue semantics を変更しない。
## Binding decisions / invariants
- focused/unit test やコードレビューだけで startup latency 改善を確認済み扱いにしない。
- E2E pass と manual/live terminal confirmation を混同しない。
- `first visible render``all background work complete` を同じ metric として扱わない。
- 起動を速く見せるために、Ticket state / Pod state / Orchestrator state の authority を偽って表示しない。
- background reload / observation は完了後に正しい state / diagnostics を反映する。
- 起動時間計測のために provider/network/secret 依存を導入しない。
- broad TUI runtime rewrite や scheduler/lease 導入は今回の前提にしない。必要になった場合は escalation する。
## Implementation latitude
- 計測 instrumentation の入れ方は実装者判断でよい。
- E2E-only observer / event。
- `e2e-test` feature gate 配下の timing marker。
- PTY output marker。
- structured diagnostic event。
- exact wait point の分解方法は実装者判断でよいが、実装報告で説明可能にする。
- threshold はまず `first visible render <= 1500 ms` を提案値とする。
- 実測上、fixture 環境で妥当でない場合は、実装報告で理由を示して調整してよい。
- ただし 7 秒級の regression を許す threshold にはしない。
- startup latency 改善は、既存の `PendingReload` / background observation / loading state を活かしてよい。
- full ready までの時間が本質的に長い場合は、first visible と full ready を分けた上で、full ready の wait reason を表示・記録する改善でもよい。
## Readiness
- readiness: implementation_ready
- risk_flags: [panel, tui, e2e, latency, runtime-observation]
## Escalation conditions
- 7 秒 latency が E2E fixture では再現せず、live terminal / 実 workspace 固有の manual validation が必要な場合。
- 起動遅延の主因が Ticket storage corruption、大量 Pod metadata、外部 provider、filesystem stalls など fixture 外条件に依存する場合。
- startup path の改善に Pod authority / Ticket workflow semantics / Orchestrator lifecycle semantics の変更が必要になる場合。
- stable な timing test を作るには現行 E2E harness の設計変更が必要な場合。
- threshold を設けると flaky になり、別の observer / benchmark style が必要と判断される場合。
## Validation
- `cargo test -p yoi-e2e --features e2e`
- 必要に応じて対象 E2E scenario の narrow run
- E2E 追加・更新時:
- `cargo test -p yoi-e2e --features e2e --no-run`
- `cargo fmt --check`
- `git diff --check`
- 変更範囲に応じて:
- `cargo check -p yoi-e2e -p yoi -p tui`
- `cargo test -p tui` または focused panel/tui tests
## Related work
- `00001KTFMMZP0``Make workspace panel transitions non-blocking`
- `00001KV3BQ7Q3``対象 TUI/Panel merge commit の挙動を現行 E2E で確認する`
- Panel handoff context:
- workspace: `yoi`
- workspace_orchestrator_pod: `yoi-orchestrator`

View File

@ -0,0 +1,218 @@
<!-- event: create author: yoi-intake at: 2026-06-15T12:40:33Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-15T13:59:47Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T14:01:09Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- Work item は Panel startup latency の measurement / E2E budget / concrete wait-point improvement に限定され、Ticket workflow / Pod authority / Orchestrator queue semantics を変更しない invariant が明確。
- 同時 queued の Plugin resolver work とは source surface が大きく異なるため並行開始可能。
Evidence checked:
- Ticket body/thread: startup wait points、first visible vs full ready distinction、E2E acceptance criteria、binding decisions、escalation conditions、validation を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`425a6c66` 上。
- Visible Pods: implementation child Pod なし。
- Related context: `00001KTFMMZP0` prior non-blocking transition work、`00001KV3BQ7Q3` Panel/TUI E2E behavior evidence work は closed/done context として参照。
IntentPacket:
Intent:
- `yoi panel` startup path を E2E/fixture PTY で計測し、first visible render と background/full-ready wait を分けて可視化し、実ユーザーに効く startup latency を改善・基準化する。
Binding decisions / invariants:
- focused/unit/code review だけで startup latency 改善済み扱いにしない。
- E2E pass と manual/live terminal confirmation を混同しない。
- `first visible render``all background work complete` を同一 metric にしない。
- 起動を速く見せるために Ticket / Pod / Orchestrator state authority を偽らない。
- Background reload / observation 完了後は正しい state / diagnostics を反映する。
- Provider/network/secret dependency を導入しない。
- Broad TUI runtime rewrite / scheduler / lease 導入は non-goal。
Requirements / acceptance criteria:
- Real `yoi` binary + PTY path の E2E で startup time を測る。
- 少なくとも initial visible panel/render 到達時間を assert する。
- First visible budget を明示する(提案: <= 1500ms; 実測で妥当でない場合は理由付き調整)。
- Full ready/background reload complete を測るなら別 metric/budget として扱う。
- Before/after 測定結果、major wait point、削減/非同期化/遅延実行した wait、E2E が保証する範囲、live/manual gap を実装報告に記録する。
- Existing E2E fixture-local HOME/XDG/runtime/workspace isolation and no-provider/no-network を維持する。
Implementation latitude:
- E2E-only observer / timing marker / PTY output marker / structured diagnostic event の選択は実装判断。
- Wait point breakdown の粒度は実装判断だが報告で説明可能にする。
- If fixture does not reproduce 7s latency, record what is guaranteed and what remains live/manual gap。
- Startup improvement can use existing PendingReload / background observation / loading state。
Escalate if:
- 7s latency is live/workspace-specific and not fixture reproducible。
- Stable timing test requires broader harness redesign。
- Wait cause is storage corruption / huge metadata / provider / filesystem stalls outside fixture。
- Fix requires Pod authority / Ticket workflow / Orchestrator lifecycle semantic changes。
- Meaningful latency threshold cannot be made non-flaky。
Validation:
- `cargo test -p yoi-e2e --features e2e` or narrow scenario plus full if feasible。
- `cargo test -p yoi-e2e --features e2e --no-run`
- `cargo fmt --check`
- `git diff --check`
- Depending changes: `cargo check -p yoi-e2e -p yoi -p tui` and focused tui tests。
Critical risks / reviewer focus:
- E2E actually measures first visible render, not just process start。
- first visible vs full ready distinction。
- threshold not overly flaky and not permissive of 7s regression。
- no authority/state falsification for speed。
- fixture isolation / no-provider / no-network。
- before/after evidence and residual live gap clarity。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T14:01:19Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Routing decision と accepted implementation/evidence plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Panel startup latency work は同時に開始する Plugin resolver work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: hare at: 2026-06-15T14:20:26Z -->
## Implementation report
### E2E measurements (real `target/debug/yoi` + PTY)
| Scenario | Before | After | Budget | Notes |
| --- | ---: | ---: | ---: | --- |
| Fixture first visible panel render (`panel_full_ready_has_separate_startup_budget`) | 20.342 ms | 20.384 ms | 1500 ms | Warm fixture did not reproduce 7 s latency; after path keeps first draw separate from reload work. |
| Fixture full-ready rows (`panel_full_ready_has_separate_startup_budget`) | 120.576 ms | 120.552 ms | 5000 ms | Separate metric; fixture full-ready remains well below budget after the first-frame deferral. |
| Held-reload ordering (`panel_first_visible_render_arrives_before_background_reload`) | `background_task_started` before `panel_ready` | `panel_ready` before held `background_task_started`; first visible 20.396 ms | 1500 ms | Guarantees initial visible frame is not blocked by reload/observation. |
| Pre-change ad-hoc Panel run from existing fixture artifact | `background_task_started@76 ms`, `panel_ready@80 ms`, full rows `@182 ms` | latest held/full tests above | n/a | Used to identify wait ordering; fixture still did not reproduce live 7 s. |
### Wait points identified
- Synchronous before first draw: CLI/process startup, `run_panel` workspace/cwd setup, `load_app` construction of an empty/loading `WorkspacePanelViewModel`, raw-mode/bracketed-paste/alternate-screen/mouse setup, and the first `terminal.draw`.
- Previously scheduled before first draw: initial Panel reload/observation task (`PendingReload::start(Ensure { ... })`), which can scan Tickets/Pods/orchestrator state and perform socket/status probing before fixture rows are fully ready.
- Background/full-ready after first draw: `load_multi_pod_snapshot`, Ticket list/detail loading, Pod metadata/status checks, orchestrator lifecycle observation, row selection/re-render, and background diagnostics.
### Changes
- Added PTY E2E startup coverage for `yoi panel`:
- `panel_first_visible_render_arrives_before_background_reload` asserts `panel_ready` arrives within 1500 ms and before the held reload task starts.
- `panel_full_ready_has_separate_startup_budget` asserts first visible render within 1500 ms and full fixture rows within 5 s as a separate metric.
- Deferred the initial Panel reload start until after the first loading frame is drawn, preserving later background reload correctness.
- Kept Panel/terminal mouse capture to SGR + normal tracking (`?1006h` + `?1000h`) and avoided drag-capture (`?1002h`/`?1003h`) so existing PTY tests can confirm no drag-capture regression.
### Guaranteed scope / residual gaps
- Guaranteed by E2E fixture: real binary, PTY, first visible frame budget, held-reload ordering, separate full-ready row budget, no provider/network/secret dependency.
- Residual live/manual gap: the reported ~7 s live Panel startup did not reproduce in this fixture. This change prevents initial reload/observation from blocking or contending with the first visible frame, but live-terminal confirmation is still needed if the remaining cause is workspace-specific (for example a large real Ticket/Pod set or slow live socket/status probe).
---
<!-- event: review author: yoi-reviewer-00001KV5MRH6D at: 2026-06-15T14:31:11Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Reviewed child worktree diff `4772c4d6..9bad2745`.
- Reviewed changed files:
- `.yoi/tickets/00001KV5MRH6D/item.md`
- `.yoi/tickets/00001KV5MRH6D/thread.md`
- `crates/tui/src/multi_pod.rs`
- `crates/tui/src/single_pod.rs`
- `tests/e2e/tests/panel.rs`
- Worktree was clean and at expected branch/commit.
Acceptance review:
- E2E uses real `yoi panel` process under PTY fixture, not unit-only coverage.
- First visible render is explicitly measured and asserted via `panel_first_visible_render_arrives_before_background_reload` with a 1500ms budget.
- Full-ready/background work is separately measured via `panel_full_ready_has_separate_startup_budget` with its own budget and ready-row assertion.
- The implementation defers the initial background reload until after the first loading frame is queued/drawn; it does not fake Ticket/Pod/Orchestrator state.
- The tests use a held reload barrier to prove first visible rendering happens before background reload unblocks.
- Background reload correctness is preserved by waiting for rows/actions after the barrier.
- The reported before/after evidence and thread update correctly explain that fixture E2E did not reproduce the live ~7s delay and that live/manual confirmation remains a residual gap.
- Existing no-provider/no-network style E2E isolation is preserved; no provider/secret/network dependency was introduced.
- The mouse capture adjustment avoids drag/full-motion capture modes while preserving SGR normal mouse tracking, matching recent Panel mouse invariants.
- No Ticket workflow, Pod restore/spawn authority, Orchestrator queue, scheduler, or lease semantics were changed.
Validation performed by reviewer:
- Passed: `git diff --check 4772c4d6..9bad2745`
- Passed: `cargo fmt --check`
- Worktree stayed clean.
Validation not run by reviewer:
- E2E/cargo test commands were not rerun because review scope was read-only and they write build artifacts. Coder-reported validation was inspected as evidence.
Conclusion:
- Approved. No blocker remains.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T14:31:28Z -->
## Implementation report
Integration report:
Merged implementation/evidence branch into orchestration branch:
- branch: `impl/00001KV5MRH6D-panel-startup-latency`
- implementation commit: `9bad2745 fix: measure and defer panel startup reload`
- merge commit: `6f99ebed merge: panel startup latency e2e`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV5MRH6D`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo fmt --check`
- Passed: `cargo test -p yoi-e2e --features e2e --no-run`
- Passed: `cargo test -p yoi-e2e --features e2e`
- yoi-e2e lib: 1 passed
- panel E2E: 5 passed
- rewind E2E: 1 passed
- Passed: `cargo check -p yoi-e2e -p yoi -p tui`
- Passed: `cargo test -p tui single_pod_mouse_capture_avoids_drag_and_all_motion_modes`
- 1 passed
- Passed: `git diff --check`
Evidence outcome:
- First visible render is asserted within 1500ms through real `yoi panel` PTY fixture.
- Full-ready/background row availability is asserted separately within 5s.
- Initial reload/observation is deferred until after first loading frame; state/diagnostics are not faked or skipped.
- Fixture did not reproduce the live ~7s startup delay, so live/manual confirmation remains a documented gap.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T14:31:28Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation/evidence branch merged into the orchestration branch, and E2E-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260615-140041-1","ticket_id":"00001KV5R5V2S","kind":"accepted_plan","accepted_plan":{"summary":"Accept Plugin package discovery and explicit enablement resolver implementation. Build typed discovery/manifest/resolver data without runtime execution or contribution registration, with fail-closed diagnostics and focused tests.","branch":"impl/00001KV5R5V2S-plugin-enable-resolver","worktree":"/home/hare/Projects/yoi/.worktree/00001KV5R5V2S-plugin-enable-resolver","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Can run in parallel with Panel startup E2E work because source surfaces are disjoint."},"author":"yoi-orchestrator","at":"2026-06-15T14:00:41Z"}

View File

@ -0,0 +1,149 @@
---
title: 'Plugin: package discovery and explicit enablement resolver'
state: 'done'
created_at: '2026-06-15T13:40:15Z'
updated_at: '2026-06-15T15:30:00Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['plugin', 'package-loading', 'discovery', 'enablement', 'capability-boundary', 'startup-restore']
queued_by: 'workspace-panel'
queued_at: '2026-06-15T13:59:47Z'
---
## Background
Plugin を利用可能にする最初の実装として、Plugin package を安全に発見・検査し、明示設定に基づいて enablement target として resolve できるようにする。
この Ticket では Plugin code の実行、Tool / Hook / Service / Ingress の登録、WASM runtime、`https` / `fs` host API 実装は行わない。package discovery と explicit enablement resolver を先に固め、後続の Plugin runtime / surface 実装が同じ解決結果を使えるようにする。
既存設計では以下を分離する。
```text
package discovery != enablement != runtime initialization != contribution registration
```
package が存在するだけで実行・登録されてはならない。Yoi は package を read-only に発見し、profile / config 側の明示 enablement entry と照合できるところまでをこの Ticket の範囲とする。
## Scope
- Plugin package discovery
- Plugin package archive / directory safety validation
- `plugin.toml` manifest parsing
- Plugin package identity resolution
- Explicit enablement entry resolution
- Resolved Plugin metadata / diagnostics surface
- Startup / snapshot restore で再現可能な resolved input の保持または再構築方針
## Package locations
最初の discovery source は以下を対象にする。
- User plugin store:
- `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/*.yoi-plugin`
- Workspace plugin store:
- `<workspace>/.yoi/plugins/*.yoi-plugin`
実装上、`.yoi-plugin` を archive として扱うか directory として扱うかは、既存設計 `00001KT0Z4BK8` を確認して合わせる。未実装部分がある場合は、最小実装を明示し、後続 Ticket で拡張できる構造にする。
## Requirements
- Plugin package discovery は read-only とする。
- 発見した package を実行しない。
- Tool / Hook / Service / Ingress を登録しない。
- package の存在だけで Plugin が有効化されない。
- `plugin.toml` を package root から読み込む。
- 必須 metadata を parse する。
- unsupported / incompatible api version は fail closed にする。
- Package safety checks を行う。
- path traversal を reject する。
- package root 外への symlink / escape を reject する。
- bounded file count / total size / manifest size を設ける。
- deterministic digest を計算する。
- malformed package は diagnostic に残すが、Pod startup 全体を不要に壊さない。
- Plugin identity を source-qualified に扱う。
- `user:<id>`
- `project:<id>`
- `builtin:<id>`
- Unqualified id が複数 source に一致する場合は ambiguous として fail closed にする。
- Profile / config 側の explicit enablement entry を読む。
- exact config location / shape は既存 Profile / plugin design に合わせ、必要なら最小の typed structure を追加する。
- enablement entry は package ref、version / version constraint、digest pin、enabled surfaces、grant request / grant reference を表現できるようにする。
- Enablement resolver は discovered package と enablement entry を照合する。
- missing package
- duplicate package id
- ambiguous ref
- version mismatch
- digest mismatch
- incompatible api version
- requested surface unsupported
- grant missing / unsupported
を diagnostic として区別できるようにする。
- Resolved Plugin は後続の runtime initialization / contribution registration に渡せる typed data として表す。
- package identity
- source
- package path
- digest
- manifest metadata
- enabled surfaces
- effective grants / unresolved grants
- diagnostics
- Startup / snapshot restore で同じ resolved Plugin set を再現できること。
- runtime-only mutable state に依存しない。
- 必要な場合は Pod metadata / snapshot に resolved digest 等を含めるか、startup 時に deterministic に再解決する。
- Diagnostics は model-visible context に勝手に差し込まない。
- user-visible / logs / snapshot など既存の診断面に合わせる。
- secret-like path / auth / file content を出さない。
## Non-goals
- Plugin code execution。
- WASM runtime 実装。
- Tool / Hook / Service / Ingress の実登録。
- `https` host API 実装。
- `fs` host API 実装。
- external network / filesystem side effect の実行。
- plugin package manager / install / update / registry 実装。
- package signature / trust chain 実装。
- MCP bridge との統合。
## Acceptance criteria
- User / workspace plugin store から `.yoi-plugin` package を発見できる。
- Valid package の `plugin.toml` を parse し、typed manifest と deterministic digest を得られる。
- Invalid package は fail closed し、bounded diagnostic として報告される。
- Package が存在するだけでは Tool / Hook / Service / Ingress は登録されない。
- Profile / config の explicit enablement entry なしでは Plugin は有効化されない。
- Enablement entry と discovered package を照合し、resolved Plugin metadata を生成できる。
- `user:<id>` / `project:<id>` / `builtin:<id>` の source-qualified identity を扱える。
- ambiguous unqualified id は fail closed する。
- version mismatch / digest mismatch / incompatible api version / missing package が区別可能な diagnostic になる。
- Startup / restore 時に resolved Plugin set が deterministic に再現できる。
- Tests cover:
- valid user package discovery
- valid workspace package discovery
- package without enablement is not active
- explicit enablement resolves package
- duplicate / ambiguous id fail closed
- digest mismatch fail closed
- path traversal / root escape rejected
- unsupported api version rejected
- malformed manifest diagnostic
- no contribution registration from discovery-only path
- Validation: focused tests for plugin discovery/resolver, `cargo fmt --check`, relevant `cargo check` / `cargo test`, `git diff --check`, and `nix build .#yoi` if runtime resources / packaging / dependency graph change.
## Implementation notes
- Prefer adding a small typed Plugin package/resolver module rather than embedding resolver logic inside runtime launch code.
- Keep terminology aligned with the design record:
- Plugin runtime
- Plugin surface
- Plugin host API
- Do not reintroduce `contribution category` as user-facing terminology.
- Host APIs remain intentionally narrow. If this Ticket needs to mention APIs, use `https` and `fs`, not `web`.
- Discovery and enablement should integrate with the existing Profile / feature contribution direction without giving plugins ambient workspace filesystem authority.
## Related work
- `00001KSXRQ4G8` — Plugin runtime / surface / minimal host API model design.
- `00001KT0Z4BK8` — Plugin package / discovery format design.
- `00001KTR81P9X``pod::feature` API / plugin host substrate follow-up.

View File

@ -0,0 +1,527 @@
<!-- event: create author: "yoi ticket" at: 2026-06-15T13:40:15Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-15T13:59:47Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T14:00:41Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- Prior Plugin package design `00001KT0Z4BK8` は done 済みで、本 Ticket はその設計を踏まえた discovery + explicit enablement resolver の最初の実装として具体化されている。
- Risk flags は plugin / package-loading / discovery / enablement / capability-boundary / startup-restore だが、non-goals と fail-closed / read-only / no-registration invariants が明確で、残る不確実性は typed module/config/resolver design の実装戦術に閉じている。
Evidence checked:
- Ticket body/thread: scope、requirements、non-goals、acceptance criteria、implementation notes、related work を確認。
- Ticket relations: blocker なし。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`425a6c66` 上。
- Visible Pods: implementation child Pod なし。
- Related design context: `00001KT0Z4BK8` donePlugin package/discovery design
IntentPacket:
Intent:
- Plugin package discovery と explicit enablement resolver を typed module として実装し、package presence / discovery / enablement / runtime initialization / contribution registration を明確に分離する。
Binding decisions / invariants:
- Discovery は read-only。package の存在だけで execution / Tool / Hook / Service / Ingress registration を行わない。
- Explicit enablement entry がなければ Plugin は active にならない。
- Source-qualified identity (`user:<id>`, `project:<id>`, `builtin:<id>`) を扱い、ambiguous unqualified id は fail closed。
- Package safety checks は path traversal / root escape / bounded count/size / manifest size / deterministic digest を含む。
- unsupported/incompatible API version、digest mismatch、version mismatch、missing package、duplicate/ambiguous id、unsupported surface/grant は区別可能な diagnostic にする。
- Diagnostics を model-visible context に勝手に差し込まない。
- Plugin code execution / WASM runtime / actual Tool/Hook/Service/Ingress registration / MCP bridge は non-goal。
- No ambient workspace filesystem authority を plugin package discovery から発生させない。
Requirements / acceptance criteria:
- User store `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/*.yoi-plugin` と workspace store `<workspace>/.yoi/plugins/*.yoi-plugin` から package を発見できる。
- Valid package root の `plugin.toml` を parse し typed manifest と deterministic digest を得る。
- Invalid package は startup 全体を不要に壊さず bounded diagnostic で fail closed。
- Package without enablement is not active。
- Explicit enablement resolves package to typed resolved Plugin metadata。
- Tests cover valid user/workspace discovery、discovery-only inactive、explicit enablement、duplicate/ambiguous fail-closed、digest mismatch、path traversal/root escape、unsupported api、malformed manifest、no contribution registration。
Implementation latitude:
- Small typed module/crate-local module を追加してよい。runtime launch code に resolver logic を埋め込まない。
- `.yoi-plugin` archive vs directory minimal implementation は prior design に合わせる。必要なら最小サポート範囲を明示し後続拡張可能にする。
- Exact config/profile shape は既存 Profile / manifest design に合わせて最小 typed structure を追加してよい。
- Startup/restore reproducibility は deterministic re-resolution か resolved digest metadata 保持のどちらかを実装判断。ただし runtime-only mutable state 依存は不可。
Escalate if:
- Profile/manifest authority semantics、Pod restore semantics、secret handling、MCP enablement model を変える必要がある。
- Package archive implementation needs signature/trust/install/update/registry semantics。
- Arbitrary external filesystem/network authority が必要になる。
- Runtime registration/WASM execution なしでは acceptance を満たせないことが判明する。
Validation:
- focused tests for plugin discovery/resolver。
- `cargo fmt --check`
- relevant `cargo check` / `cargo test`
- `git diff --check`
- `nix build .#yoi` if dependencies, runtime resources, packaging, or Cargo.lock changes matter。
Critical risks / reviewer focus:
- discovery vs enablement vs runtime/registration separation。
- fail-closed package safety / diagnostics。
- source-qualified identity and ambiguous refs。
- no contribution registration / no side effects from discovery。
- startup/restore determinism。
- secret-like diagnostic redaction。
- Plugin permission/grant requests not confused with actual grants。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T14:01:19Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Plugin resolver work は同時に開始する Panel startup latency work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV5R5V2S at: 2026-06-15T14:27:54Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV5R5V2S`:
Commit:
- `a03a9da6 feat: add plugin package resolver`
Changed files:
- `crates/manifest/src/plugin.rs`
- New typed plugin discovery/resolution module.
- `crates/manifest/src/lib.rs`
- Added `plugins` config to `PodManifest`.
- Added parsing test for `[[plugins.enabled]]`.
- `crates/manifest/src/config.rs`
- Added `plugins` to `PodManifestConfig` and merge/materialization path.
- `crates/manifest/src/profile.rs`
- Added profile/Lua config support for plugin enablement config.
- `crates/pod/src/spawn/tool.rs`
- Preserves plugin config when deriving child manifest config.
- `crates/manifest/Cargo.toml`
- Added `sha2` dependency for deterministic package digests.
- `Cargo.lock`
- Updated manifest crate dependency metadata.
- `package.nix`
- Updated `cargoHash`.
Resolver/config shape:
- Config supports explicit enablement through `[[plugins.enabled]]`:
- `id = "project:<id>" | "user:<id>" | "builtin:<id>"`
- optional `digest = "sha256:<hex>"`
- optional `surfaces = ["hook", ...]`
- optional `[plugins.enabled.config]`
- optional grants, currently fail closed when authority-bearing/non-empty.
- Discovery stores:
- User: `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/*.yoi-plugin`
- Workspace: `<workspace>/.yoi/plugins/*.yoi-plugin`
Implemented behavior:
- Discovery is read-only and returns typed `DiscoveredPluginPackage` metadata only.
- Resolution only activates packages explicitly listed in config and returns typed `ResolvedPlugin` metadata.
- No runtime initialization, code execution, WASM runtime, hook/tool/service/ingress registration, network, package-manager, or MCP behavior was added.
- Source-qualified ids are required; unqualified ids fail closed as ambiguous.
- Unsupported plugin API versions fail closed.
- Digest pins are checked against deterministic package content digests.
- Traversal/root escape/symlink escape checks are enforced.
- Package count, package size, manifest size, entry count, per-file size, and expanded size are bounded.
- Non-empty authority-bearing grants fail closed, so plugins do not receive ambient workspace filesystem authority.
Diagnostics:
- Added typed diagnostic kinds for missing, duplicate, ambiguous, version, digest, API, surface, grant, malformed, traversal, bounds, and I/O.
- Diagnostics avoid full package path/content disclosure and use bounded/sanitized messages.
Validation reported by coder:
- Passed: `cargo fmt --check`
- Passed: `cargo test -p manifest plugin`
- Passed: `cargo test -p manifest`
- Passed: `cargo check`
- Passed: `cargo test -p pod spawn_pod`
- Passed: `git diff --check`
- Passed: `nix build .#yoi`
Residual risks / notes:
- Current archive reader intentionally supports a constrained stored-ZIP profile and fails closed on unsupported compression methods. Broader `.zip` compression support can be added later if package tooling requires it.
---
<!-- event: review author: yoi-reviewer-00001KV5R5V2S at: 2026-06-15T14:37:12Z status: request_changes -->
## Review: request changes
Review result: request_changes
Evidence checked:
- Child worktree only:
- `/home/hare/Projects/yoi/.worktree/00001KV5R5V2S-plugin-enable-resolver`
- branch/head `impl/00001KV5R5V2S-plugin-enable-resolver` at `a03a9da6`
- Diff files:
- `crates/manifest/src/plugin.rs`
- `crates/manifest/src/config.rs`
- `crates/manifest/src/lib.rs`
- `crates/manifest/src/profile.rs`
- `crates/pod/src/spawn/tool.rs`
- `crates/manifest/Cargo.toml`
- `Cargo.lock`
- `package.nix`
- `docs/design/plugin-packages.md`
- Ticket record:
- `.yoi/tickets/00001KV5R5V2S/item.md`
- `.yoi/tickets/00001KV5R5V2S/thread.md`
Read-only validation performed:
- Passed: `git diff --check 4772c4d6..a03a9da6`
Positive findings:
- Discovery/resolution code is isolated in `crates/manifest/src/plugin.rs`.
- No runtime registration, WASM execution, Tool/Hook/Service/Ingress contribution path was found.
- User/workspace stores are represented.
- Discovery checks include store containment, symlink escape rejection, path normalization, count/size/manifest bounds, duplicate normalized path rejection, and deterministic digest.
- Package presence alone does not activate anything; resolution requires `plugins.enabled`.
- Manifest/profile/child-spawn config plumbing preserves `plugins` config.
Required changes:
1. Version mismatch support is missing.
- Ticket requires enablement entries to express package version/version constraint and requires version mismatch to be a distinct diagnostic.
- `PluginEnablementConfig` currently has `id`, `digest`, `surfaces`, `grants`, `config`, but no version/version constraint field.
- `resolve_enabled_plugins` never compares enablement against `package.manifest.version`.
- `PluginPackageManifest` has `version`, but it is only validated non-empty.
- `PluginDiagnosticKind::Version` currently appears to be used for unsupported API version, so package-version mismatch and API incompatibility are not clearly separated.
Required fix:
- Add a typed version/version requirement field to enablement config, or explicitly documented minimal exact-version field if constraints are deferred.
- Compare it to `manifest.version` during resolution.
- Emit a distinct version-mismatch diagnostic separate from incompatible API version.
- Add tests for version mismatch fail-closed behavior.
2. Startup/restore determinism is not satisfied.
- Ticket requires deterministic startup/restore behavior for the resolved plugin set.
- Implementation preserves authoring config, but no resolved plugin metadata/digest recording or deterministic restore re-resolution path was found.
- Unpinned enablement can resolve to a different package if mutable user/workspace store changes before restore.
- The design doc also states restore should use a resolved plan, not fresh discovery choosing newer packages.
Required fix:
- Either persist resolved plugin identity/digest metadata into resolved manifest/session metadata used for restore, or define and implement deterministic re-resolution semantics that cannot silently change a restored plugin set.
- Add focused test or validation evidence for the chosen restore/reproducibility path.
- If intentionally deferred, Ticket acceptance/report must be updated before approval because current acceptance still requires it.
3. Bounded diagnostic truncation can panic on valid UTF-8.
- `bounded_message` slices a Rust `String` at byte offset 240.
- It is used for TOML parse errors from untrusted plugin manifests.
- If byte 240 falls inside a multibyte UTF-8 character, slicing panics instead of producing a bounded diagnostic.
Required fix:
- Truncate on character boundary using safe helper logic.
- Add malformed manifest test with long multibyte content proving diagnostics remain bounded and non-panicking.
- Consider reducing raw TOML-error content leakage because diagnostics should avoid secret-like path/content leakage.
Additional concern:
- Design doc examples use `schema_version`, but implemented parser requires `api_version`.
- Align schema naming before merge to avoid contradictory package-author guidance.
Conclusion:
- Changes requested. Do not integrate until these blockers are fixed and covered.
---
<!-- event: implementation_report author: yoi-coder-00001KV5R5V2S at: 2026-06-15T14:53:21Z -->
## Implementation report
Follow-up implementation report after reviewer request_changes:
New commit:
- `ede7acfd fix: pin plugin resolution metadata`
Resolved blockers:
1. Version mismatch support
- Added typed exact-version requirement:
- `PluginExactVersion`
- `PluginEnablementConfig.version: Option<PluginExactVersion>`
- Resolution now compares `plugins.enabled.version` against `package.manifest.version`.
- Version mismatch fails closed with `PluginDiagnosticKind::Version`.
- Unsupported plugin schema/API version now emits `PluginDiagnosticKind::Api`, distinct from package version mismatch.
- Added focused test:
- `exact_version_mismatch_fails_closed_with_distinct_diagnostic`
2. Startup/restore determinism
- Added runtime resolved-plan metadata to `PluginConfig`:
- `resolved: Vec<ResolvedPluginRecord>`
- `diagnostics: Vec<PluginDiagnostic>`
- Added `resolve_plugin_config_for_startup`.
- Fresh startup resolves enabled plugins and records identity/path/label/digest/version/manifest/enabled surfaces/grants/config into manifest plugin config.
- Restore/snapshot configs with an existing resolved plan are reused as-is and do not silently select newer mutable store contents.
- Wired startup resolution into pod manifest resolution in `crates/pod/src/entrypoint.rs`.
- Preserved resolved plugin plan through config merge.
- Added focused test:
- `resolved_plan_pins_unpinned_enablement_for_restore`
3. Safe bounded diagnostics
- Replaced byte slicing in `bounded_message` with char-boundary truncation.
- TOML parse diagnostics no longer embed raw untrusted manifest content; diagnostic reports bounded parse message with optional byte span.
- Added focused multibyte malformed manifest test:
- `malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted`
4. Schema naming alignment
- Plugin package manifest parser now uses `schema_version`, matching `docs/design/plugin-packages.md`.
- Updated tests/helpers accordingly.
- Updated design doc enablement example to include implemented exact `version` field and describe deferred richer constraints.
Validation reported by coder:
- Passed: `cargo fmt --check`
- Passed: `cargo test -p manifest plugin`
- Passed: `cargo test -p manifest`
- Passed: `cargo check`
- Passed: `cargo test -p pod spawn_pod`
- Passed: `git diff --check`
- Passed: `nix build .#yoi`
Repository status:
- Child implementation worktree clean after follow-up commit.
Residual note:
- Version constraints are intentionally minimal exact-version matching for this Ticket. Richer semantic/range constraints remain deferred and documented as follow-up scope.
---
<!-- event: review author: yoi-reviewer-00001KV5R5V2S-r2 at: 2026-06-15T15:05:55Z status: request_changes -->
## Review: request changes
Review result: request_changes
Evidence checked:
- Child worktree only:
- `/home/hare/Projects/yoi/.worktree/00001KV5R5V2S-plugin-enable-resolver`
- HEAD `ede7acfd fix: pin plugin resolution metadata`
- base `4772c4d6`
- Relevant files:
- `crates/manifest/src/plugin.rs`
- `crates/pod/src/entrypoint.rs`
- `crates/pod/src/pod.rs`
- `docs/design/plugin-packages.md`
Validation performed by reviewer:
- Passed: `git diff --check 4772c4d6..HEAD`
- Passed: `cargo fmt --check`
- `git status --short` clean
Validation not run:
- `cargo test`, `cargo check`, and `nix build` were not rerun because reviewer scope was read-only and those commands write build artifacts. Coder-reported results were treated as evidence only.
Remaining blockers:
1. Startup/restore determinism is incomplete for non-profile / spawn-config launches.
Positive pieces exist:
- `apply_plugin_resolution_plan()` resolves plugins during manifest resolution.
- `resolve_plugin_config_for_startup()` skips fresh discovery when `plugins.resolved` or `plugins.diagnostics` already exists.
- focused helper test confirms a restored resolved plan does not pick newer mutable store contents.
Blocker:
- resolved manifest snapshot is only persisted to Pod metadata when `self.manifest.profile.is_some()`.
- Restore uses saved snapshot if present, otherwise falls back to freshly resolved current manifest.
- A named Pod started from `--manifest` or spawn config can resolve plugin metadata at startup but fail to persist that resolved plan, then later restore can silently re-resolve against newer mutable store contents unless authoring config had digest pin.
Required fix:
- Persist the resolved manifest snapshot for plugin-resolved manifests regardless of `manifest.profile`, or otherwise make non-profile restore deterministically use the original resolved plan.
- Add focused test/validation for the runtime persistence path, not only the helper behavior.
2. Documentation examples still appear broader than the implemented strict parser.
Resolved part:
- `schema_version` naming is now consistent in implementation/tests/minimal docs.
Remaining mismatch:
- docs illustrative `plugin.toml` still includes fields/sections rejected by current `#[serde(deny_unknown_fields)]` parser, such as `[package]` and `[permissions]`.
- docs describe `runtime.kind = "declarative"` as an initial value while implementation rejects runtime kinds other than `"wasm"`.
Required fix:
- Either mark those fields/sections as future/aspirational and provide a minimal currently-valid manifest example, or extend the parser to accept the documented first-pass fields safely.
Prior blockers resolved:
- Version mismatch support is resolved with exact-version field and distinct `Version` vs `Api` diagnostics.
- Safe bounded diagnostics are mostly resolved with UTF-8-boundary truncation and redacted TOML parse diagnostics.
- Discovery/enablement/no-registration boundaries look good.
Conclusion:
- Changes requested. Do not integrate until the non-profile/spawn restore determinism path and docs/parser mismatch are fixed and covered.
---
<!-- event: implementation_report author: yoi-coder-00001KV5R5V2S at: 2026-06-15T15:16:14Z -->
## Implementation report
Second follow-up implementation report after reviewer request_changes:
New commit:
- `07978d2d fix: persist plugin snapshots for restore`
Resolved remaining blockers:
1. Non-profile / spawn-config restore determinism
- Changed Pod metadata snapshot persistence so resolved manifests are snapshotted when either:
- `manifest.profile.is_some()` (existing profile behavior), or
- `manifest.plugins.has_resolved_plan()` (new plugin-resolved manifest behavior).
- Added helper path:
- `pod_metadata_for_manifest`
- `should_persist_resolved_manifest_snapshot`
- A named Pod whose plugin enablement is resolved at startup now persists resolved plugin metadata/digest plan in `resolved_manifest_snapshot` even without a profile.
- Restore therefore uses the original resolved plan instead of silently rediscovering mutable stores.
Focused runtime persistence test added:
- `plugin_resolved_manifest_snapshot_is_persisted_without_profile`
- verifies a non-profile manifest does not snapshot by default;
- adds resolved plugin metadata;
- verifies runtime metadata snapshot is persisted;
- verifies restored snapshot contains the original plugin digest/version.
2. Docs/parser alignment
- Updated `docs/design/plugin-packages.md` with a minimal currently-valid strict `plugin.toml` example:
- top-level `schema_version`, `id`, `name`, `version`, `description`, `surfaces`;
- `[[hooks]]` with `id` and `file`.
- Clarified referenced files must exist in the package archive.
- Clarified optional `[runtime]` currently accepts only:
- `kind = "wasm"`
- `entry = "..."`
- `abi = "yoi-plugin-wasm-1"`
- Marked `[package]`, `[permissions]`, richer `contributions`, and `runtime.kind = "declarative"` as future/aspirational and intentionally rejected by strict parser for now.
- Added parser-alignment test:
- `currently_documented_manifest_shape_is_accepted`
Validation reported by coder:
- Passed: `cargo fmt --check`
- Passed: `cargo test -p manifest plugin`
- Passed: `cargo test -p manifest`
- Passed: `cargo test -p pod plugin_resolved_manifest_snapshot_is_persisted_without_profile`
- Passed: `cargo test -p pod spawn_pod`
- Passed: `cargo check`
- Passed: `git diff --check`
- Passed: `nix build .#yoi`
Repository status:
- Child implementation worktree clean after follow-up commit.
---
<!-- event: review author: yoi-reviewer-00001KV5R5V2S-r3 at: 2026-06-15T15:27:07Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Third-pass target worktree:
- `/home/hare/Projects/yoi/.worktree/00001KV5R5V2S-plugin-enable-resolver`
- HEAD includes `07978d2d fix: persist plugin snapshots for restore`
- Non-profile / spawn-config restore determinism:
- `pod_metadata_for_manifest` persists `resolved_manifest_snapshot` when either profile is present or `manifest.plugins.has_resolved_plan()` is true.
- Pending and active metadata writes use the helper.
- Restore path prefers `metadata.resolved_manifest_snapshot` when present and falls back only when absent.
- Existing profile behavior is preserved.
- Focused test `plugin_resolved_manifest_snapshot_is_persisted_without_profile` exercises profile-none metadata construction with resolved plugin records and verifies identity/version/digest are retained.
- Docs/parser alignment:
- `docs/design/plugin-packages.md` now includes a minimal strict currently-valid `plugin.toml` example using `schema_version = 1`, `[runtime] kind = "wasm"`, and currently accepted hook metadata.
- Future/unsupported `[package]`, `[permissions]`, richer contributions, and `runtime.kind = "declarative"` are explicitly marked future/unsupported rather than current parser surface.
- Parser/tests align with current schema/runtime shape.
- Prior acceptance spot-check:
- Discovery remains inactive without explicit enablement.
- Version mismatch and API incompatibility diagnostics are distinct.
- Diagnostics are bounded/sanitized.
- Authority grants fail closed.
- No Tool/Hook/Service/Ingress registration plumbing was added.
Validation performed by reviewer:
- Passed: `cargo fmt --check`
- Passed: `git diff --check 4772c4d6..HEAD`
Validation not rerun by reviewer:
- `cargo test`, `cargo check`, and `nix build .#yoi` were not rerun due read-only review scope. Coder-reported successful heavier validation was used as supporting evidence.
Risk assessment:
- Residual risk is low. Snapshot persistence is in the central Pod metadata helper and restore already prefers persisted snapshots.
- Remaining coverage is unit/integration-level rather than full E2E process restore, matching current project E2E boundary and not a blocker for this Ticket.
Conclusion:
- Approved. No remaining blocker found.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T15:30:00Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV5R5V2S-plugin-enable-resolver`
- implementation commits:
- `a03a9da6 feat: add plugin package resolver`
- `ede7acfd fix: pin plugin resolution metadata`
- `07978d2d fix: persist plugin snapshots for restore`
- merge commit: `f678383a merge: plugin package resolver`
Reviewer result:
- Initial review requested changes for version mismatch support, restore determinism, UTF-8-safe diagnostics, and docs/parser mismatch.
- Second review requested changes for non-profile/spawn restore determinism and docs/parser mismatch.
- Third-pass review `approve` recorded from `yoi-reviewer-00001KV5R5V2S-r3`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo fmt --check`
- Passed: `cargo test -p manifest plugin`
- 19 passed
- Passed: `cargo test -p manifest`
- 54 passed
- Passed: `cargo test -p pod plugin_resolved_manifest_snapshot_is_persisted_without_profile`
- 1 passed
- Passed: `cargo test -p pod spawn_pod`
- 7 passed
- Passed: `cargo check`
- Passed: `git diff --check`
- Passed: `nix build .#yoi --no-link`
Outcome:
- Plugin package discovery and explicit enablement resolver are implemented as typed manifest-layer functionality.
- Discovery remains inactive/read-only without explicit enablement.
- Resolved plugin metadata/digests can be snapshotted for restore determinism, including non-profile/plugin-resolved manifests.
- No runtime Tool/Hook/Service/Ingress registration or plugin execution path was added.
- Package safety, source-qualified identity, exact version pins, digest pins, bounded diagnostics, and strict docs/parser alignment are covered by focused tests.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T15:30:00Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused plus packaging validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260615-155415-1","ticket_id":"00001KV5W3PHA","kind":"accepted_plan","accepted_plan":{"summary":"Accept Plugin Tool surface registration boundary work. Register enabled Plugin Tool schemas into ToolRegistry with origin metadata, duplicate/schema diagnostics, feature/profile hiding, and runtime-missing safe errors without implementing WASM execution.","branch":"impl/00001KV5W3PHA-plugin-tool-surface","worktree":"/home/hare/Projects/yoi/.worktree/00001KV5W3PHA-plugin-tool-surface","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Dependency `00001KV5R5V2S` is done and current workspace is clean."},"author":"yoi-orchestrator","at":"2026-06-15T15:54:15Z"}

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KV5W3PHA",
"kind": "depends_on",
"target": "00001KV5R5V2S",
"note": "Tool surface registration consumes resolved Plugin package metadata.",
"author": "yoi ticket",
"at": "2026-06-15T14:50:28Z"
},
{
"ticket_id": "00001KV5W3PHA",
"kind": "related",
"target": "00001KSXRQ4G8",
"note": "Plugin runtime/surface/host API design record.",
"author": "yoi ticket",
"at": "2026-06-15T14:50:28Z"
}
]
}

View File

@ -0,0 +1,87 @@
---
title: 'Plugin: register enabled Tool surface from packages'
state: 'closed'
created_at: '2026-06-15T14:48:59Z'
updated_at: '2026-06-17T05:44:04Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['plugin', 'tool-registry', 'model-visible-schema', 'capability-boundary', 'profile-config']
queued_by: 'workspace-panel'
queued_at: '2026-06-15T15:53:32Z'
---
## Background
Plugin package discovery / explicit enablement resolver の次に、enabled Plugin package から Tool surface を読み取り、通常の `ToolRegistry` に登録できるようにする。
この Ticket の目的は、Plugin package 由来の Tool 定義が Yoi の既存 Tool 経路に安全に乗る境界を作ること。Plugin code execution / WASM runtime はまだ行わない。Tool が model-visible schema として見えるか、enablement なしでは出ないか、invalid / duplicate な Tool 定義が fail closed になるかを先に固める。
## Requirements
- `00001KV5R5V2S` の resolved Plugin metadata を入力として扱う。
- Enabled Plugin package の manifest から Tool surface 定義を読み取る。
- tool name
- description
- input schema
- effect / side-effect metadata
- plugin origin metadata
- Plugin Tool definition を既存 `ToolRegistry` 登録経路に載せる。
- model-visible schema は通常 Tool と同じ原則に従う。
- feature/profile config で disabled なら schema surface から消える。
- Tool metadata に Plugin origin を保持する。
- plugin id / ref
- package source: user / project / builtin
- package digest
- package version / api version
- surface: tool
- Duplicate Tool name は fail closed にする。
- builtin Tool / other Plugin Tool との衝突を検出する。
- どちらが勝つかを曖昧にしない。
- Invalid input schema / unsupported schema shape は fail closed にする。
- Package が discovered されただけでは Tool を登録しない。
- explicit enablement が必要。
- Tool call / result は後続 runtime Ticket で実装する。
- この Ticket では未実行 Tool として registration boundary を作る。
- 実行できない状態を user-visible diagnostic として安全に扱う。
- Diagnostics は bounded にする。
- registered
- skipped: not enabled
- rejected: duplicate name
- rejected: invalid schema
- rejected: unsupported surface/api
- rejected: missing runtime executor
## Acceptance criteria
- Enabled Plugin package の Tool definition が `ToolRegistry` に登録され、model-visible tools に現れる。
- Enablement がない Plugin package の Tool は model-visible tools に現れない。
- Duplicate Tool name は登録されず、diagnostic で理由が分かる。
- Invalid input schema は登録されず、diagnostic で理由が分かる。
- Registered Plugin Tool の metadata から plugin origin / digest / source が追跡できる。
- Feature/profile flag により Plugin Tool surface を非表示にできる。
- Tool call がまだ実行できない場合も panic せず、安全な unavailable/runtime-missing error になる。
- Tests cover:
- enabled package Tool registration
- package without enablement does not register
- duplicate Plugin Tool name rejected
- builtin Tool name collision rejected
- invalid schema rejected
- plugin origin metadata retained
- disabled feature/profile removes schema surface
- Validation: focused plugin/tool-registry tests, `cargo fmt --check`, relevant `cargo check` / `cargo test`, `git diff --check`.
## Non-goals
- Plugin code execution.
- WASM runtime.
- `https` / `fs` host API.
- Service / Ingress surface.
- External side effects.
- Permission grant enforcement beyond registration-time shape checks.
## Related work
- `00001KV5R5V2S` — Plugin package discovery and explicit enablement resolver.
- `00001KV5W3PHW` — Plugin Tool execution with minimal WASM runtime.
- `00001KV5W3PJ3` — Plugin permission grant enforcement.
- `00001KSXRQ4G8` — Plugin runtime / surface / host API model design.

View File

@ -0,0 +1,17 @@
Merged orchestration implementation into develop and validated.
Implementation delivered Plugin Tool surface registration from enabled Plugin packages:
- feature-gated Plugin Tool registration through the existing ToolRegistry/model-visible schema path
- Plugin origin metadata on Tool metadata
- discovery-only packages remain inactive without explicit enablement
- duplicate/colliding Tool names and invalid schemas fail closed
- recursive schema validation rejects nested invalid schema nodes before registration
- runtime execution remains an unavailable/runtime-missing stub; WASM runtime and grant enforcement remain follow-up Tickets
Validation on develop after merge:
- cargo test -p pod plugin::tests --no-default-features
- cargo test -p manifest plugin --no-default-features
- cargo check -p pod -p manifest -p llm-worker
- cargo build -p yoi
- target/debug/yoi ticket doctor
- nix build .#yoi --no-link

View File

@ -0,0 +1,436 @@
<!-- event: create author: "yoi ticket" at: 2026-06-15T14:48:59Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-15T15:53:32Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T15:54:15Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。
- Outgoing dependency `00001KV5R5V2S``done` で、typed relation blocker は解消済み。
- 本 Ticket は resolved Plugin metadata を入力にした Tool surface registration boundary の実装であり、Plugin code execution / WASM runtime / permission grant enforcement は non-goal として明確。
- capability-boundary / model-visible-schema / tool-registry risk は高いが、acceptance criteria と fail-closed invariants が具体的で、残る不確実性は typed metadata / registry integration tactic に閉じている。
Evidence checked:
- Ticket body/thread: requirements、acceptance criteria、non-goals、related work を確認。
- Ticket relations: depends_on `00001KV5R5V2S` は done。incoming dependency from runtime Ticket `00001KV5W3PHW` は本 Ticket の blocker ではない。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`1fdb4cd6` 上。
- Visible Pods: implementation child Pod なし。
IntentPacket:
Intent:
- Enabled Plugin package の manifest Tool surface を読み取り、既存 `ToolRegistry` の model-visible schema 経路に安全に登録できる registration boundary を作る。ただし Tool call execution / WASM runtime はまだ実装しない。
Binding decisions / invariants:
- Discovery-only package は Tool schema surface に出さない。explicit enablement が必要。
- Tool registration は existing `ToolRegistry` 原則に従い、feature/profile config disabled なら model-visible schema から消える。
- Plugin Tool metadata に originplugin id/ref、source、digest、version/api、surfaceを保持する。
- Duplicate Tool name は fail closed。builtin Tool / other Plugin Tool との衝突を曖昧に勝たせない。
- Invalid/unsupported input schema は fail closed。
- Runtime executor missing の Tool call は panic せず安全な unavailable/runtime-missing error を返す。
- Plugin code execution / WASM runtime / host API / permission grant enforcement / Service/Ingress/MCP bridge は non-goal。
- Permission declarations/grants を実効権限として扱わない。registration-time shape checks だけ。
Requirements / acceptance criteria:
- Enabled Plugin package の Tool definition が `ToolRegistry` に登録され、model-visible tools に現れる。
- Enablement がない Plugin package の Tool は model-visible tools に現れない。
- Duplicate Tool name / builtin collision は登録されず diagnostic で理由が分かる。
- Invalid input schema は登録されず diagnostic で理由が分かる。
- Registered Plugin Tool metadata から plugin origin / digest / source を追跡できる。
- Feature/profile flag により Plugin Tool surface を非表示にできる。
- Tool call が未実行状態でも panic せず unavailable/runtime-missing error。
- Tests cover enabled registration、no enablement inactive、duplicate Plugin Tool name、builtin collision、invalid schema、origin metadata、disabled feature/profile surface hiding。
Implementation latitude:
- Plugin manifest Tool definition schema の最小 shape は既存 design / resolver の strict parser に合わせてよい。
- Existing ToolRegistry contribution path に合わせた adapter / feature contribution module を追加してよい。
- Runtime-missing handler は minimal stub でよいが、model-visible registration と call failure behavior は明確にテストする。
- Diagnostics の storage/reporting surface は existing resolver diagnostics と整合させる。
Escalate if:
- ToolRegistry API が origin metadata を保持できず public API redesign が必要。
- Feature/profile config で Plugin Tool surface を安全に hide できない。
- JSON schema validation requires broader schema engine choice。
- Runtime-missing Tool stub が model/provider protocol に悪影響を与える。
- Permission grants / WASM executor / host API を実装しないと registration boundary が成立しない。
Validation:
- focused plugin/tool-registry tests。
- relevant `cargo test` / `cargo check`
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi` if dependencies, Cargo.lock, package resources, or packaging correctness change。
Critical risks / reviewer focus:
- model-visible schema gating。
- duplicate/builtin collision fail closed。
- no enablement → no schema。
- origin metadata traceability。
- no execution/registration side effects beyond schema contribution。
- unavailable runtime call safety。
- permission declaration vs effective grant separation。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T15:54:24Z from: queued to: inprogress reason: orchestrator_acceptance_dependency_done field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。dependency `00001KV5R5V2S` は done で relation blocker は解消済み。Orchestrator workspace は clean。implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV5W3PHA at: 2026-06-15T16:20:05Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV5W3PHA`:
Commit:
- `05a9c522 feat: register plugin tool surfaces`
Changed files:
- `crates/manifest/src/plugin.rs`
- `crates/manifest/src/lib.rs`
- `crates/manifest/src/config.rs`
- `crates/llm-worker/src/tool.rs`
- `crates/pod/src/feature.rs`
- `crates/pod/src/feature/plugin.rs`
- `crates/pod/src/controller.rs`
- `crates/pod/src/pod.rs`
Implemented behavior:
- Added minimal Plugin package `tools` manifest surface parsing:
- `name`
- `description`
- `input_schema`
- Added `feature.plugins` profile/manifest gate, default disabled.
- Added Plugin Tool feature adapter:
- only enabled resolved Plugin packages with enabled Tool surface register schemas;
- package discovery alone does not register Tools;
- disabled profile feature suppresses Plugin Tool schema surface.
- Registered Plugin Tool definitions into existing ToolRegistry/model-visible schema path.
- Added host-side `ToolOrigin` metadata on `ToolMeta`:
- plugin id/ref
- source
- digest
- package version/API
- `surface = "tool"`
- Added runtime-missing stub executor:
- Tool schema is visible when registered;
- actual calls return safe unavailable/runtime-missing error;
- no Plugin code execution or WASM runtime added.
- Added registration-time fail-closed checks:
- duplicate Plugin Tool names;
- collision with already registered builtin Tool names;
- invalid/unsupported input schemas.
- Adjusted feature install ordering so builtin/pending tools are flushed before Plugin feature registration, allowing Plugin collisions against already registered Tool names to be diagnosed instead of reaching later duplicate panic.
Diagnostics behavior:
- Duplicate Tool contributions produce feature install diagnostics and skip duplicate registration.
- Invalid Plugin input schema produces feature install diagnostics and registers no stub for that invalid Tool.
- Runtime calls before executor support return safe unavailable/runtime-missing `ToolError`.
Validation reported by coder:
- Passed: `cargo test -p manifest plugin --no-default-features`
- Passed: `cargo test -p pod plugin::tests --no-default-features`
- Passed: `cargo check -p pod -p manifest -p llm-worker`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
- Attempted broader validation: `cargo test -p pod --no-default-features`
- Failed in existing prompt text assertions unrelated to Plugin Tool surface implementation:
- `prompt::tests::default_subagent_prompt_matches_resource`
- `prompt::tests::subagent_prompt_treats_paths_as_data`
Not run:
- `nix build .#yoi` — no dependency, `Cargo.lock`, resource, or packaging changes.
Residual risks / blockers:
- Plugin executor is intentionally runtime-missing stub; actual WASM/runtime execution remains for later Ticket.
- Input schema validation is intentionally a narrow model-visible shape check, not a full JSON Schema engine. Unsupported composition/reference keywords are rejected fail-closed.
---
<!-- event: review author: yoi-reviewer-00001KV5W3PHA at: 2026-06-15T16:26:34Z status: request_changes -->
## Review: request changes
Review result: request_changes
Blocker:
- Invalid nested Plugin Tool schemas can still become model-visible.
Evidence:
- `crates/pod/src/feature/plugin.rs` validates root-level schema shape and recursively rejects only selected unsupported keywords.
- It does not validate that nested schema nodes are valid JSON Schema objects.
- Example invalid schema that appears to pass current validation:
```toml
input_schema = {
type = "object",
properties = { query = "not-a-schema" },
required = ["query"],
additionalProperties = false
}
```
Why this is a blocker:
- `properties.query = "not-a-schema"` is not a valid schema node.
- Current checks can allow it into ToolRegistry / model-visible tool schema path.
- This violates the acceptance criteria that invalid input schema is rejected with diagnostic and the critical focus on model-visible schema gating.
Required changes:
- Strengthen Plugin Tool schema validation before registration so invalid nested schemas fail closed with diagnostics.
- Add regression tests showing nested invalid schema nodes do not register a Plugin Tool.
- Prefer defining/enforcing an explicit supported subset recursively, for example:
- validate property schemas;
- validate allowed `type` values;
- validate `required` string/unique array;
- validate or reject `items` / `additionalProperties` recursively;
- reject unknown/problematic keywords unless explicitly supported.
Positive evidence checked:
- Plugin tool registration is feature-gated by `feature_config.plugins.enabled`.
- Discovery-only packages do not register tools because registration uses resolved enabled Plugin records/surfaces.
- Registered Plugin Tools preserve `ToolOrigin::Plugin` metadata.
- Builtin collisions and duplicate Plugin Tool names are diagnosed/rejected.
- Runtime execution is a safe unavailable/runtime-missing stub.
- No Plugin code execution, WASM runtime, host API, Service/Ingress/MCP bridge, or permission grant enforcement was added.
- `ToolOrigin` is not exposed directly to model-visible schema.
Validation performed by reviewer:
- Passed: `git diff --check fcae8860..05a9c522`
- Passed: `cargo fmt --check`
- Worktree remained clean.
Validation not run:
- `cargo test` / `cargo check` were not rerun because reviewer scope was read-only.
Conclusion:
- Changes requested. Do not integrate until nested schema validation is fail-closed and covered.
---
<!-- event: implementation_report author: yoi-coder-00001KV5W3PHA at: 2026-06-15T16:30:49Z -->
## Implementation report
Follow-up implementation report after reviewer request_changes:
New commit:
- `3413bae7 fix: reject nested plugin tool schema errors`
Changed file:
- `crates/pod/src/feature/plugin.rs`
Schema validation behavior updated:
- Replaced shallow root-only validation with recursive validation of an explicit supported subset.
- Every schema node must be a JSON object with supported string `type`.
- Supported `type` values:
- `object`
- `array`
- `string`
- `number`
- `integer`
- `boolean`
- `null`
- Supported keywords:
- `type`
- `title`
- `description`
- `properties`
- `required`
- `additionalProperties`
- `items`
- `enum`
Recursive/fail-closed behavior:
- `properties.*` values are recursively validated as schema objects.
- `items` is only allowed on array schemas and recursively validates a schema object.
- `additionalProperties` is only allowed on object schemas and must be boolean or recursively valid schema object.
- Non-object nested schema nodes reject registration.
- Unknown/problematic keywords reject registration.
- Unsupported `type` values reject registration.
- `required` must be an array of unique strings.
- `required` entries must be declared in `properties` when `properties` exists.
- `enum` must be non-empty array with unique entries.
Regression tests added:
- Direct validation rejects:
- `properties.query = "not-a-schema"`
- invalid recursive `items`
- invalid recursive `additionalProperties`
- duplicate `required` entries
- Registration test confirms nested invalid schema does not register a Plugin Tool and emits an `invalid input_schema` diagnostic with nested path.
Validation reported by coder:
- Passed: `cargo test -p pod plugin::tests --no-default-features`
- Passed: `cargo test -p manifest plugin --no-default-features`
- Passed: `cargo check -p pod -p manifest -p llm-worker`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Repository status:
- Child implementation worktree clean after follow-up commit.
---
<!-- event: review author: yoi-reviewer-00001KV5W3PHA-r2 at: 2026-06-15T16:38:37Z status: approve -->
## Review: approve
Review result: approve
Second-pass blocker verification: resolved.
Evidence checked:
- Child worktree:
- `/home/hare/Projects/yoi/.worktree/00001KV5W3PHA-plugin-tool-surface`
- branch `impl/00001KV5W3PHA-plugin-tool-surface`
- HEAD `3413bae7 fix: reject nested plugin tool schema errors`
- Fix diff `05a9c522..3413bae7`.
- Relevant implementation:
- `crates/pod/src/feature/plugin.rs`
- `crates/pod/src/feature.rs`
- `crates/pod/src/controller.rs`
- `crates/llm-worker/src/tool.rs`
- `crates/llm-worker/src/tool_server.rs`
- `crates/manifest/src/plugin.rs`
Blocker-specific findings:
- Nested invalid schemas are now rejected recursively.
- `validate_schema_node` requires every schema node to be a JSON object with supported string `type`.
- `properties` child schemas are recursively validated.
- `items` is only accepted on array schemas and recursively validated.
- `additionalProperties` is only accepted on object schemas and must be boolean or recursively valid schema object.
- `required` must be a unique string array and, when `properties` exists, entries must be declared.
- `enum` must be non-empty and unique.
- Unknown keywords are rejected before registration.
- The prior concrete failure case `properties.query = "not-a-schema"` now fails with path-bearing diagnostic such as `$.properties.query`.
- Regression test `nested_invalid_input_schema_does_not_register_plugin_tool` asserts pending tool registration remains empty and diagnostics include `invalid input_schema`.
- Invalid schemas return `FeatureInstallError::InvalidToolManifest`, recorded as install diagnostic by feature registry.
- No evidence found that invalid nested schemas become model-visible after the fix.
Overall acceptance spot-check:
- Enabled Plugin Tool definitions are wired through normal `FeatureRegistryBuilder` / `ToolRegistry` path.
- Discovery-only or non-tool packages remain inactive through manifest surface filtering.
- Duplicate/builtin collisions are handled by feature registry duplicate checks against registered/pending tool names.
- `ToolOrigin` metadata is preserved on `ToolMeta`.
- Feature/profile disabled plugin support hides Plugin Tool modules through `plugin_tool_features_if_enabled`.
- Runtime-missing calls use `PluginRuntimeMissingTool` and return safe unavailable error.
- No Plugin code execution, WASM runtime, host API, Service/Ingress/MCP bridge, or permission grant enforcement was added.
Validation performed by reviewer:
- Passed: `git diff --check fcae8860..HEAD`
- Passed: `cargo fmt --check`
Validation not rerun by reviewer:
- `cargo test` / `cargo check` were not rerun because reviewer scope was read-only. Coder-reported passing results were inspected as evidence.
Risk assessment:
- Residual risk is mainly integration-level because reviewer did not run cargo tests.
- The fix is focused and directly covers the prior blocker with recursive validation and regression coverage.
Conclusion:
- Approved. No remaining blocker found.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T16:39:57Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV5W3PHA-plugin-tool-surface`
- implementation commits:
- `05a9c522 feat: register plugin tool surfaces`
- `3413bae7 fix: reject nested plugin tool schema errors`
- merge commit: `204d0d02 merge: plugin tool surface registration`
Reviewer result:
- Initial review requested changes for nested invalid Plugin Tool schema validation.
- Second-pass review `approve` recorded from `yoi-reviewer-00001KV5W3PHA-r2`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p pod plugin::tests --no-default-features`
- Passed: `cargo test -p manifest plugin --no-default-features`
- 15 passed
- Passed: `cargo check -p pod -p manifest -p llm-worker`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Notes:
- Broader `cargo test -p pod --no-default-features` was not rerun because coder had already reported existing unrelated prompt text assertion failures outside this change path.
- Plugin Tool registration is feature-gated and enablement-gated.
- Runtime execution remains an unavailable/runtime-missing stub; no Plugin code execution or WASM runtime was added.
- Recursive schema validation now rejects nested invalid schema nodes before model-visible registration.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T16:39:58Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved after requested schema validation fix, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-17T05:44:04Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-17T05:44:04Z status: closed -->
## 完了
Merged orchestration implementation into develop and validated.
Implementation delivered Plugin Tool surface registration from enabled Plugin packages:
- feature-gated Plugin Tool registration through the existing ToolRegistry/model-visible schema path
- Plugin origin metadata on Tool metadata
- discovery-only packages remain inactive without explicit enablement
- duplicate/colliding Tool names and invalid schemas fail closed
- recursive schema validation rejects nested invalid schema nodes before registration
- runtime execution remains an unavailable/runtime-missing stub; WASM runtime and grant enforcement remain follow-up Tickets
Validation on develop after merge:
- cargo test -p pod plugin::tests --no-default-features
- cargo test -p manifest plugin --no-default-features
- cargo check -p pod -p manifest -p llm-worker
- cargo build -p yoi
- target/debug/yoi ticket doctor
- nix build .#yoi --no-link
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260617-094831-1","ticket_id":"00001KV5W3PHW","kind":"accepted_plan","accepted_plan":{"summary":"Accept minimal WASM Plugin Tool runtime. Implement no-ambient-authority module execution for enabled Plugin Tools with bounded input/output/errors, timeout/cancellation, deterministic module selection, and focused tests.","branch":"impl/00001KV5W3PHW-plugin-wasm-tool-runtime","worktree":"/home/hare/Projects/yoi/.worktree/00001KV5W3PHW-plugin-wasm-tool-runtime","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Can run in parallel with Panel E2E readiness fix because source surfaces are distinct."},"author":"yoi-orchestrator","at":"2026-06-17T09:48:31Z"}

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KV5W3PHW",
"kind": "depends_on",
"target": "00001KV5W3PHA",
"note": "WASM Tool runtime requires Plugin Tool surface registration.",
"author": "yoi ticket",
"at": "2026-06-15T14:50:28Z"
},
{
"ticket_id": "00001KV5W3PHW",
"kind": "related",
"target": "00001KSXRQ4G8",
"note": "Plugin runtime/surface/host API design record.",
"author": "yoi ticket",
"at": "2026-06-15T14:50:28Z"
}
]
}

View File

@ -0,0 +1,92 @@
---
title: 'Plugin: execute Plugin Tool with minimal WASM runtime'
state: 'inprogress'
created_at: '2026-06-15T14:48:59Z'
updated_at: '2026-06-17T09:50:53Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['plugin', 'wasm', 'tool-runtime', 'sandbox', 'capability-boundary', 'cancellation']
queued_by: 'workspace-panel'
queued_at: '2026-06-17T09:46:10Z'
---
## Background
Plugin Tool surface registration の後続として、enabled Plugin Tool を最小 WASM runtime で実行できるようにする。
この Ticket のゴールは、Plugin package を enable し、manifest 由来の Tool を model-visible に登録し、その Tool call を sandboxed WASM module に渡して bounded な Tool result として返す最小体験を作ること。`https` / `fs` host API、Service / Ingress、長時間 background process は扱わない。
## Requirements
- Registered Plugin Tool の invocation を Plugin runtime に route する。
- Minimal WASM runtime を追加する。
- module load
- tool input JSON の受け渡し
- tool output JSON の受け取り
- structured error handling
- Runtime は ambient authority を持たない。
- ambient filesystem なし
- ambient network なし
- ambient environment variables なし
- host API imports は明示的に許可されたものだけ
- この Ticket では host API import は最小にする。
- tool input / output のための必要最小限のみ。
- `https` / `fs` は実装しない。
- Bounded execution を実装する。
- timeout
- cancellation
- input size bound
- output size bound
- error/diagnostic size bound
- Invalid Plugin output は fail closed にする。
- malformed JSON
- schema mismatch
- oversize output
- non-terminating execution
- Tool call / result は通常 Tool history 経路に乗る。
- hidden context injection をしない。
- Plugin stdout/stderr 相当を無制限に history に入れない。
- Runtime error は safe structured Tool error として返す。
- panic しない。
- secret-like host path / env / raw memory dump を出さない。
- Runtime lifecycle は Pod startup / restore と整合させる。
- package digest / runtime config に基づいて deterministic に module を選ぶ。
- runtime-only mutable state に依存して Tool availability を決めない。
## Acceptance criteria
- Sample Plugin package の Tool を WASM runtime 経由で実行できる。
- Tool input JSON が WASM module に渡り、Tool output JSON が通常 Tool result として返る。
- Tool result は通常の history / permission / trace 経路に残る。
- Plugin Tool が ambient filesystem / network / env にアクセスできない。
- Timeout する Plugin execution は中断され、安全な Tool error になる。
- Oversize output / malformed output / schema mismatch は fail closed する。
- Cancellation が Worker / Tool execution の cancellation と整合する。
- Runtime diagnostics は bounded で、selected secret/path/env を漏らさない。
- Tests cover:
- successful WASM Plugin Tool execution
- malformed output rejected
- oversize output rejected
- timeout / cancellation
- missing runtime module diagnostic
- no ambient fs/network/env by default
- tool result history path remains ordinary tool result
- Validation: focused plugin runtime tests, `cargo fmt --check`, relevant `cargo check` / `cargo test`, `git diff --check`, and `nix build .#yoi` if dependencies / packaging change.
## Non-goals
- `https` host API.
- `fs` host API.
- Service surface.
- Ingress surface.
- Long-running Plugin daemon/process lifecycle.
- Plugin package manager / registry.
- Signature/trust-chain verification.
- Broad WASI surface beyond the explicitly needed imports.
## Related work
- `00001KV5R5V2S` — Plugin package discovery and explicit enablement resolver.
- `00001KV5W3PHA` — Plugin Tool surface registration.
- `00001KV5W3PJ3` — Plugin permission grant enforcement.
- `00001KSXRQ4G8` — Plugin runtime / surface / host API model design.

View File

@ -0,0 +1,130 @@
<!-- event: create author: "yoi ticket" at: 2026-06-15T14:48:59Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-17T09:46:10Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-17T09:48:31Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。
- `depends_on 00001KV5W3PHA``closed/done` 済みで、typed relation blocker は解消済み。
- Incoming dependent `00001KV5W3PJ3` は本 Ticket の blocker ではなく、runtime path の grant enforcement 後続として扱う。
- 本 Ticket は minimal WASM runtime による Plugin Tool execution の最初の実装であり、host API / fs / https / Service / Ingress / MCP / long-running process を明確に non-goal としている。
- sandbox / tool-runtime / capability-boundary / cancellation risk は高いが、timeout、bounds、ambient authorityなし、structured error、deterministic module selection が Ticket に明記されているため、implementation_ready と判断する。
Evidence checked:
- Ticket body/thread: requirements、acceptance criteria、non-goals、validation、risk flags を確認。
- Ticket relations: outgoing `depends_on 00001KV5W3PHA` は done/closed。related design `00001KSXRQ4G8` は blocker ではない。incoming `00001KV5W3PJ3` は dependent。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`bcb8068e` 上。
- Visible Pods: implementation child Pod なし。
IntentPacket:
Intent:
- Registered/enabled Plugin Tool call を minimal sandboxed WASM runtime に route し、bounded input/output/error と通常 Tool history 経路で安全に結果を返す。
Binding decisions / invariants:
- Runtime は ambient filesystem / network / environment authority を持たない。
- Host API imports は tool input/output に必要な最小限のみ。`fs` / `https` は実装しない。
- Plugin stdout/stderr相当や raw memory dump を無制限に history/model-visible context に入れない。
- Tool call/result は通常 Tool history 経路を使い、hidden context injection をしない。
- Timeout / cancellation / input size / output size / diagnostic size bounds を実装する。
- Malformed JSON / schema mismatch / oversize output / non-terminating execution は fail closed。
- Runtime module selection は package digest/runtime config に基づき deterministic。runtime-only mutable state に依存しない。
- Permission grants / host API authority / fs/network は後続 Ticket。ここでは minimal no-authority runtime execution のみ。
Requirements / acceptance criteria:
- Enabled Plugin Tool invocation が Plugin runtime に route される。
- Minimal WASM module load、tool input JSON delivery、tool output JSON receipt、structured error handling が実装される。
- Ambient authority なしで実行される。
- Bounds と timeout/cancellation が効く。
- Invalid output は safe Tool error。
- Successful Plugin Tool result は通常 Tool result として返る。
- Runtime missing/malformed module/load failure は safe structured Tool error。
- Tests cover success、malformed output、oversize output、timeout/cancellation、input bound、missing module、no ambient fs/network/env、normal Tool history path。
Implementation latitude:
- WASM runtime crate/engine selectionは既存 deps/packaging constraintsに合わせる。新 dependency が必要なら `Cargo.lock` / `package.nix` / `nix build .#yoi` を更新・検証する。
- Guest ABI は minimal JSON input/output ABI でよい。将来拡張可能に docs/code comment で境界を明示する。
- Real package archive fixture or synthetic resolved plugin fixture は実装判断。ただし deterministic digest/module selection は test で固定する。
Escalate if:
- WASM engine dependency / sandbox model が project-wide design decision を要する。
- Cancellation/timeout cannot be made reliable without broader runtime changes。
- Host API import semantics must be added beyond input/output。
- ToolRegistry / Worker protocol needs broad API change。
- Security boundary cannot be enforced within existing process model。
Validation:
- focused plugin WASM runtime tests。
- existing Plugin Tool registration tests。
- `cargo check` / relevant `cargo test`
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi` if dependencies/Cargo.lock/package changes occur。
Critical risks / reviewer focus:
- actual sandbox/ambient authority isolation。
- timeout/cancellation reliability。
- bounded outputs/errors/history。
- deterministic module selection from resolved plugin metadata。
- no hidden context injection。
- runtime missing/load/invalid-output safe errors。
- dependency/packaging correctness。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-17T09:49:19Z from: queued to: inprogress reason: orchestrator_acceptance_dependency_done field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。dependency `00001KV5W3PHA` は done/closed で relation blocker は解消済み。Plugin WASM runtime work は同時開始する Panel E2E readiness work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-17T09:50:53Z -->
## Implementation report
Implementation start failure / recovery note:
`queued -> inprogress` acceptance と worktree 作成は完了したが、Coder Pod spawn に失敗した。
Created worktree:
- `/home/hare/Projects/yoi/.worktree/00001KV5W3PHW-plugin-wasm-tool-runtime`
- branch: `impl/00001KV5W3PHW-plugin-wasm-tool-runtime`
Failure:
- `SpawnPod` failed before child Pod creation.
- Error: `failed to spawn /home/hare/Projects/yoi/target/debug/yoi (deleted) pod: No such file or directory (os error 2)`
Interpretation:
- Runtime binary path for this long-lived Orchestrator process points at a deleted executable. This is an operational/runtime issue, not a Ticket requirement issue.
- No Coder Pod was started and no implementation commit exists in the child worktree yet.
Recovery needed:
- Refresh/restart the Orchestrator runtime or provide a valid Pod runtime command so `SpawnPod` can launch children again.
- After recovery, reuse the existing child worktree/branch if clean and resume Coder spawn for this in-progress Ticket.
State handling:
- Ticket remains `inprogress` because acceptance and worktree side effects already happened; it is not silently returned to `queued`.
---

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KV5W3PJ3",
"kind": "depends_on",
"target": "00001KV5W3PHW",
"note": "Grant enforcement must cover runtime execution path.",
"author": "yoi ticket",
"at": "2026-06-15T14:50:28Z"
},
{
"ticket_id": "00001KV5W3PJ3",
"kind": "related",
"target": "00001KSXRQ4G8",
"note": "Plugin runtime/surface/host API design record.",
"author": "yoi ticket",
"at": "2026-06-15T14:50:28Z"
}
]
}

View File

@ -0,0 +1,98 @@
---
title: 'Plugin: enforce Plugin permission grants'
state: 'ready'
created_at: '2026-06-15T14:48:59Z'
updated_at: '2026-06-15T14:50:28Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['plugin', 'permission', 'grant-enforcement', 'capability-boundary', 'tool-execution']
---
## Background
Plugin package discovery / Tool registration / Tool runtime が揃った後、Plugin manifest の requested permissions と Profile/config 側の granted permissions を照合し、登録・実行・host API 利用の境界で fail closed にする。
この Ticket では `https` / `fs` host API の実装自体は行わない。ただし後続の host API 実装が同じ grant model を使えるよう、grant の型と enforcement point を明確にする。
## Requirements
- Plugin manifest の requested permissions を typed data として読む。
- enabled surfaces
- tool names / namespaces
- Tool effect / external_write metadata
- future host APIs: `https`, `fs`
- Profile/config 側の granted permissions を typed data として読む。
- package ref / digest / version と結びつける。
- source-qualified identity に対応する。
- Requested permissions と granted permissions を照合する。
- requested but not granted は fail closed。
- unsupported grant / unknown permission kind は fail closed。
- overly broad ambiguous grant は fail closed または明示 diagnostic。
- Enforcement points を実装する。
- Plugin package enablement resolution
- Tool surface registration
- Plugin Tool execution
- future host API call dispatch
- Tool metadata の effect を existing permission / PreToolCall path と整合させる。
- external side effect がある Tool は metadata と permission gate に反映する。
- external write は `Outbound` surface ではなく Tool metadata として扱う。
- Denied reason を diagnostic / trace で確認できるようにする。
- grant missing
- grant digest mismatch
- requested surface not granted
- requested tool not granted
- host API not granted
- external_write not granted
- Grant enforcement は model-visible context に隠し情報を差し込まない。
- Package が存在するだけ、または Tool registration だけで execution authority を得ない。
## Initial grant model guidance
最初の grant model は狭く始める。
```text
surfaces.tool
tool names / namespaces
external_write flag
host_api.https
host_api.fs
```
この Ticket では `https` / `fs` の API 実行は non-goal。ただし requested/granted の型と denied diagnostic は先に扱えるようにする。
## Acceptance criteria
- Grant なしの Plugin Tool は実行されず、safe diagnostic になる。
- Granted Tool だけが登録または実行可能になる。
- Requested surface が grant に含まれない場合は fail closed する。
- Requested external_write が grant に含まれない場合は fail closed する。
- Digest/version/source mismatch の grant は使われない。
- Unknown permission kind / unsupported grant は fail closed する。
- Denied reason が diagnostic / trace で確認できる。
- Existing PreToolCall / Tool permission path と矛盾しない。
- Tests cover:
- no grant denies Plugin Tool execution
- grant allows specific Plugin Tool
- unrelated package grant does not apply
- digest mismatch denies
- requested surface missing denies
- external_write missing denies
- unknown permission kind fails closed
- denial reason is bounded and safe
- Validation: focused plugin permission tests, `cargo fmt --check`, relevant `cargo check` / `cargo test`, `git diff --check`.
## Non-goals
- Implementing `https` host API.
- Implementing `fs` host API.
- Service / Ingress surface.
- Plugin package manager / install/update.
- Signature/trust-chain enforcement.
- Broad policy UI.
## Related work
- `00001KV5R5V2S` — Plugin package discovery and explicit enablement resolver.
- `00001KV5W3PHA` — Plugin Tool surface registration.
- `00001KV5W3PHW` — Plugin Tool execution with minimal WASM runtime.
- `00001KSXRQ4G8` — Plugin runtime / surface / host API model design.

View File

@ -0,0 +1,7 @@
<!-- event: create author: "yoi ticket" at: 2026-06-15T14:48:59Z -->
## 作成
LocalTicketBackend によって作成されました。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260617-094906-1","ticket_id":"00001KV62PF32","kind":"accepted_plan","accepted_plan":{"summary":"Accept Panel startup latency E2E readiness correction. Add data-backed rows-ready event/helper/assertions using concrete fixture Ticket rows, keeping first-frame metric separate.","branch":"impl/00001KV62PF32-panel-rows-ready-e2e","worktree":"/home/hare/Projects/yoi/.worktree/00001KV62PF32-panel-rows-ready-e2e","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Can run in parallel with Plugin WASM runtime because source surfaces are distinct."},"author":"yoi-orchestrator","at":"2026-06-17T09:49:06Z"}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KV62PF32",
"kind": "related",
"target": "00001KV5MRH6D",
"note": "Corrects startup readiness premise from first frame to data-backed row render.",
"author": "yoi ticket",
"at": "2026-06-15T16:44:41Z"
}
]
}

View File

@ -0,0 +1,71 @@
---
title: 'Panel startup latency E2E を一覧データ描画完了基準に修正する'
state: 'inprogress'
created_at: '2026-06-15T16:44:06Z'
updated_at: '2026-06-17T09:50:53Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['panel', 'e2e', 'startup-latency', 'readiness-metric', 'ticket-list-rendering']
queued_by: 'workspace-panel'
queued_at: '2026-06-17T09:46:03Z'
---
## Background
既存の Panel startup latency E2E は、`panel_ready` を主要な startup readiness として扱っている。しかし現在の `panel_ready``terminal.draw(|f| draw(f, app))` の初回完了直後に emit される first visible frame であり、Ticket / Pod / Orchestrator 一覧データが取得され、Panel rows として実際に描画されたことを意味しない。
ユーザーが問題にしている startup latency は、空または loading の初回 frame ではなく、Panel が実用可能な一覧データを表示するまでの時間である。したがって、既存 E2E の前提は不十分であり、startup latency E2E の readiness 基準を「一覧データ取得後の row render」に修正する必要がある。
この Ticket は、first frame の高速化を否定するものではない。first frame metric は補助 metric として残してよい。ただし startup latency の主 assertion / 報告値は、fixture の期待 Ticket / Pod / Orchestrator rows が ViewModel に反映され、描画後の row metadata / screen output として観測できたタイミングを基準にする。
## Requirements
- Panel startup latency E2E の readiness 定義を明確化する。
- `panel_first_frame`: 初回 visible draw。loading / empty frame でもよい。
- `panel_rows_ready` または同等: 一覧データが取得され、期待 row が描画された状態。
- Startup latency の主 budget / assertion は `panel_rows_ready` 相当に置く。
- `panel_ready` / first frame を「一覧 ready」として扱わない。
- Fixture は期待される一覧データを具体的に持つ。
- 少なくとも fixture Ticket id / title / state の row が描画されたことを assert する。
- 必要に応じて Pod row / orchestration overlay row も fixture に含める。
- `rows_rendered.len() >= N` のような弱い条件だけに依存しない。
- 期待 Ticket id / title / state / row kind など、実データ反映を確認する。
- E2E event naming / diagnostics を誤解しにくくする。
- `panel_ready` が残る場合は first frame として説明する。
- 一覧 ready 用の別 event / helper / metric 名を追加する。
- Background reload / observation が遅い場合でも、first frame と rows ready を別々に測る。
- first frame が速いことと、一覧 ready が速いことを混同しない。
- Existing Panel behavior を変更する場合は、loading frame 後に reload を開始する設計を維持してよいが、latency E2E の成功条件を loading frame だけで満たさないようにする。
- E2E report / Ticket implementation report では、before/after 数値が何を測っているかを明記する。
- first visible frame
- fixture rows ready
- full/background observation completion if measured
## Acceptance criteria
- Panel startup latency E2E が、fixture の具体的な Ticket row が描画されたタイミングを readiness として測定する。
- `panel_ready` / first visible frame だけでは startup latency E2E の主 assertion が通らない。
- Fixture row readiness は `len >= 2` などの抽象条件ではなく、期待 Ticket id / title / state 等で確認される。
- E2E output / helper 名 / comments から、first frame と rows ready の違いが分かる。
- Background reload を hold するテストがある場合、first frame は先に出てもよいが、rows ready はデータ反映まで完了しないことを確認できる。
- Existing Panel mouse / row selection E2E に regression がない。
- Tests cover:
- first frame event does not imply list readiness
- fixture Ticket row rendered readiness
- delayed reload delays rows-ready metric but not necessarily first-frame metric
- row readiness fails when expected fixture row is absent
- Validation: `cargo test -p yoi-e2e --features e2e panel`, relevant `cargo check`, `cargo fmt --check`, `git diff --check`, and `nix build .#yoi` if runtime/package/dependency changes occur.
## Non-goals
- Solving all live workspace startup latency causes in this Ticket.
- Replacing Panel ViewModel architecture.
- Requiring every Pod socket / Orchestrator observation to fully settle before any UI is shown.
- Removing first visible frame metric entirely.
- Changing Ticket lifecycle semantics.
## Related work
- `00001KV5MRH6D` — Panel startup latency E2E / first visible frame separation work.
- `00001KV5D7MG5` — Panel orchestration worktree Ticket state overlay.
- `00001KV4ZPAD3` — Panel Ticket / Intake Pod row visual hierarchy.

View File

@ -0,0 +1,126 @@
<!-- event: create author: "yoi ticket" at: 2026-06-15T16:44:06Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-17T09:46:03Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-17T09:49:06Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。blocking relation はなく、planning に戻す concrete missing information はない。
- Related `00001KV5MRH6D` は done で、first-frame latency E2E の前提を修正する follow-up として scope が明確。
- 本 Ticket は Panel startup readiness metric の E2E correction に限定され、Panel ViewModel architecture / lifecycle semantics / complete live latency fix は non-goal として明確。
- 同時 queued の Plugin WASM runtime work とは source surface が異なるため並行開始可能。
Evidence checked:
- Ticket body/thread: first frame vs rows ready distinction、fixture row assertion、tests、acceptance criteria、validation を確認。
- Ticket relations: outgoing `related 00001KV5MRH6D` のみで blocker なし。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`bcb8068e` 上。
- Visible Pods: implementation child Pod なし。
IntentPacket:
Intent:
- Panel startup latency E2E の primary readiness を `panel_ready` / first frame から、fixture の具体的 Ticket/Pod/Orchestrator row が取得・描画された `panel_rows_ready` 相当に修正する。
Binding decisions / invariants:
- `panel_ready` は first visible frame として扱い、一覧 data-ready と混同しない。
- Primary startup latency assertion/report は fixture row render readiness に置く。
- Weak condition (`rows_rendered.len() >= N`) だけで pass にしない。
- Expected Ticket id/title/state/row kind 等の実データ反映を確認する。
- Background reload が hold されている間、first frame は出ても rows-ready は出ないことを確認する。
- Existing Panel behavior / Ticket lifecycle / ViewModel architecture は必要以上に変えない。
- E2E pass と manual/live validation を混同しない。
Requirements / acceptance criteria:
- E2E が concrete fixture Ticket row render timing を rows-ready readiness として測る。
- first frame だけでは main startup assertion が通らない。
- Helper/event names/comments で first frame と rows ready の違いが分かる。
- Delayed reload delays rows-ready metric but not necessarily first-frame metric。
- Row readiness fails if expected fixture row is absent。
- Existing Panel mouse / row selection E2E regression なし。
- Report includes what before/after numbers measure: first frame, rows ready, full/background completion if measured。
Implementation latitude:
- Existing `panel_ready` event can remain as first-frame event; add new `panel_rows_ready` event/helper/observer as needed。
- Fixture data may use Ticket id/title/state; Pod/orchestration overlay rows optional unless useful。
- Timing budget may reuse or adjust prior threshold, but must not permit 7s regression as pass without explanation。
- If current harness cannot observe row metadata reliably, add e2e-test-only observer rather than relying on screen string heuristics alone。
Escalate if:
- Current E2E harness cannot distinguish first frame from data-backed rows without broad redesign。
- Stable timing threshold for rows-ready is too flaky。
- Fixture row readiness depends on live workspace/global state rather than isolated fixture。
- Fix would require Panel architecture/lifecycle semantics outside Ticket scope。
Validation:
- `cargo test -p yoi-e2e --features e2e panel` or relevant narrow scenario。
- `cargo test -p yoi-e2e --features e2e --no-run`
- `cargo check -p yoi-e2e -p yoi -p tui` if touched。
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi` if runtime/package/dependency changes occur。
Critical risks / reviewer focus:
- row readiness is data-backed, not first frame。
- expected fixture row specificity。
- delayed reload ordering proves distinction。
- no regression to Panel mouse / row selection E2E。
- E2E isolation / no-provider / no-network。
- report does not overclaim live startup fix。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-17T09:49:19Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Routing decision と accepted implementation/evidence plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Panel readiness E2E work は同時開始する Plugin WASM runtime work と主対象が異なるため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-17T09:50:53Z -->
## Implementation report
Implementation start failure / recovery note:
`queued -> inprogress` acceptance と worktree 作成は完了したが、Coder Pod spawn に失敗した。
Created worktree:
- `/home/hare/Projects/yoi/.worktree/00001KV62PF32-panel-rows-ready-e2e`
- branch: `impl/00001KV62PF32-panel-rows-ready-e2e`
Failure:
- `SpawnPod` failed before child Pod creation.
- Error: `failed to spawn /home/hare/Projects/yoi/target/debug/yoi (deleted) pod: No such file or directory (os error 2)`
Interpretation:
- Runtime binary path for this long-lived Orchestrator process points at a deleted executable. This is an operational/runtime issue, not a Ticket requirement issue.
- No Coder Pod was started and no implementation commit exists in the child worktree yet.
Recovery needed:
- Refresh/restart the Orchestrator runtime or provide a valid Pod runtime command so `SpawnPod` can launch children again.
- After recovery, reuse the existing child worktree/branch if clean and resume Coder spawn for this in-progress Ticket.
State handling:
- Ticket remains `inprogress` because acceptance and worktree side effects already happened; it is not silently returned to `queued`.
---

2
Cargo.lock generated
View File

@ -1798,6 +1798,7 @@ dependencies = [
"serde",
"serde_ignored",
"serde_json",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.18",
"toml",
@ -3958,6 +3959,7 @@ dependencies = [
name = "tui"
version = "0.1.0"
dependencies = [
"base64",
"client",
"crossterm 0.28.1",
"llm-worker",

View File

@ -127,6 +127,31 @@ impl From<String> for ToolOutput {
// ToolMeta - Immutable Meta Information
// =============================================================================
/// Origin metadata for a registered tool.
///
/// This metadata is intentionally not part of the provider-facing tool schema.
/// It lets host layers audit where a model-visible tool definition came from
/// while keeping execution and permission semantics in the normal Worker path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolOrigin {
/// Origin kind, for example `plugin` or `builtin`.
pub kind: String,
/// Package-local plugin id.
pub plugin_id: String,
/// Source-qualified plugin/package reference when `kind == "plugin"`.
pub plugin_ref: String,
/// Plugin source such as `user`, `project`, or `builtin`.
pub source: String,
/// Resolved package digest.
pub digest: String,
/// Resolved package version.
pub package_version: String,
/// Plugin API/schema version declared by the package.
pub package_api_version: u32,
/// Surface that contributed this tool. Plugin tools use `tool`.
pub surface: String,
}
/// Tool meta information (fixed at registration, immutable)
///
/// Generated from `ToolDefinition` factory and does not change after registration with Worker.
@ -139,6 +164,8 @@ pub struct ToolMeta {
pub description: String,
/// JSON Schema for arguments
pub input_schema: Value,
/// Optional host-side origin metadata. This is not exposed to the LLM.
pub origin: Option<ToolOrigin>,
}
impl ToolMeta {
@ -148,6 +175,7 @@ impl ToolMeta {
name: name.into(),
description: String::new(),
input_schema: Value::Object(Default::default()),
origin: None,
}
}
@ -162,6 +190,12 @@ impl ToolMeta {
self.input_schema = schema;
self
}
/// Set host-side origin metadata.
pub fn origin(mut self, origin: ToolOrigin) -> Self {
self.origin = Some(origin);
self
}
}
// =============================================================================

View File

@ -12,6 +12,7 @@ protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_ignored = "0.1.14"
sha2 = "0.10"
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }

View File

@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig,
PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig,
@ -52,6 +53,10 @@ pub struct PodManifestConfig {
/// disabled after cascade merge.
#[serde(default)]
pub feature: FeatureConfigPartial,
/// Explicit plugin package enablement entries. Discovery/resolution is a
/// separate step and does not run during config merge.
#[serde(default)]
pub plugins: PluginConfig,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
@ -79,6 +84,8 @@ pub struct FeatureConfigPartial {
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
}
impl FeatureConfigPartial {
@ -94,6 +101,7 @@ impl FeatureConfigPartial {
other.ticket_orchestration,
FeatureFlagConfigPartial::merge,
),
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
}
}
}
@ -147,6 +155,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.ticket_orchestration
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
plugins: value
.plugins
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
}
}
}
@ -194,6 +206,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
pods: Some(value.pods.into()),
ticket: Some(value.ticket.into()),
ticket_orchestration: Some(value.ticket_orchestration.into()),
plugins: Some(value.plugins.into()),
}
}
}
@ -444,6 +457,7 @@ impl PodManifestConfig {
PermissionConfigPartial::merge,
),
feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins),
compaction: merge_option(
self.compaction,
upper.compaction,
@ -463,6 +477,16 @@ impl SkillsConfig {
}
}
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
let upper_has_resolved_plan = upper.has_resolved_plan();
base.enabled.extend(upper.enabled);
if upper_has_resolved_plan {
base.resolved = upper.resolved;
base.diagnostics = upper.diagnostics;
}
base
}
impl WebConfig {
fn merge(self, upper: Self) -> Self {
Self {
@ -827,6 +851,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
session,
permissions,
feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins,
compaction,
web: cfg.web,
memory: cfg.memory,
@ -873,6 +898,7 @@ mod tests {
delegation_scope: ScopeConfig::default(),
permissions: None,
feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(),
session: None,
compaction: None,
web: None,

View File

@ -2,6 +2,7 @@ mod config;
pub mod defaults;
mod model;
pub mod paths;
pub mod plugin;
mod profile;
mod scope;
@ -57,6 +58,10 @@ pub struct PodManifest {
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
#[serde(default)]
pub feature: FeatureConfig,
/// Explicit plugin package enablement. Discovery remains read-only; only
/// source-qualified entries listed here may resolve to active plugin metadata.
#[serde(default)]
pub plugins: plugin::PluginConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// Memory subsystem configuration. Presence of `[memory]` configures memory
@ -102,6 +107,8 @@ pub struct FeatureConfig {
pub ticket: TicketFeatureConfig,
#[serde(default)]
pub ticket_orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
}
impl Default for FeatureConfig {
@ -113,6 +120,7 @@ impl Default for FeatureConfig {
pods: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(),
ticket_orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
}
}
}
@ -867,6 +875,37 @@ model_id = "claude-sonnet-4-20250514"
assert!(PodManifest::from_toml(toml).is_err());
}
#[test]
fn parse_plugin_enablement_config() {
let toml = format!(
"{MINIMAL_REQUIRED}\n\
[[plugins.enabled]]\n\
id = \"project:example\"\n\
version = \"0.1.0\"\n\
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
surfaces = [\"hook\"]\n\n\
[plugins.enabled.config]\n\
greeting = \"hello\"\n"
);
let manifest = PodManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.plugins.enabled.len(), 1);
let enabled = &manifest.plugins.enabled[0];
assert_eq!(enabled.id, "project:example");
assert_eq!(
enabled.version.as_ref().map(|version| version.0.as_str()),
Some("0.1.0")
);
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]);
assert_eq!(
enabled
.config
.as_ref()
.and_then(|value| value.get("greeting"))
.and_then(|value| value.as_str()),
Some("hello")
);
}
#[test]
fn parse_max_turns() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ use crate::config::{
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
};
use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{
MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths,
@ -626,6 +627,7 @@ fn resolve_lua_profile_value(
session: profile.session,
permissions: profile.permissions,
feature: profile.feature,
plugins: profile.plugins,
compaction,
web: profile.web,
memory: profile.memory,
@ -687,6 +689,8 @@ struct ProfileConfig {
#[serde(default)]
feature: FeatureConfigPartial,
#[serde(default)]
plugins: PluginConfig,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,

View File

@ -634,66 +634,74 @@ where
),
);
}
let _feature_install_report = pod.install_features(feature_registry);
let worker = pod.worker_mut();
// Memory tools require both explicit feature exposure and memory storage
// configuration. This keeps resident-memory config separate from the
// model-visible Memory*/Knowledge* tool surface.
if feature_config.memory.enabled {
let mem = memory_config.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.memory].enabled = true requires a [memory] configuration section",
)
})?;
let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root);
let query_cfg = memory::tool::QueryConfig::from(mem);
worker.register_tool(memory::tool::read_tool_with_usage(
layout.clone(),
session_id_for_usage,
));
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
feature_config.plugins.enabled,
&pod.manifest().plugins,
) {
feature_registry = feature_registry.with_module(module);
}
// Pod-orchestration tools (SpawnPod + the four comm tools) share
// the Pod-scoped `SpawnedPodRegistry` (also consumed by the main
// loop's `PodEvent` handler). Expose them only behind the explicit
// profile feature and require delegation authority up front so enabling
// the surface cannot imply broad child scope by accident.
if feature_config.pods.enabled {
if spawner_manifest.delegation_scope.allow.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.pods].enabled = true requires non-empty [[delegation_scope.allow]]",
{
let worker = pod.worker_mut();
// Memory tools require both explicit feature exposure and memory storage
// configuration. This keeps resident-memory config separate from the
// model-visible Memory*/Knowledge* tool surface.
if feature_config.memory.enabled {
let mem = memory_config.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.memory].enabled = true requires a [memory] configuration section",
)
})?;
let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root);
let query_cfg = memory::tool::QueryConfig::from(mem);
worker.register_tool(memory::tool::read_tool_with_usage(
layout.clone(),
session_id_for_usage,
));
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
}
// Pod-orchestration tools (SpawnPod + the four comm tools) share
// the Pod-scoped `SpawnedPodRegistry` (also consumed by the main
// loop's `PodEvent` handler). Expose them only behind the explicit
// profile feature and require delegation authority up front so enabling
// the surface cannot imply broad child scope by accident.
if feature_config.pods.enabled {
if spawner_manifest.delegation_scope.allow.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.pods].enabled = true requires non-empty [[delegation_scope.allow]]",
));
}
worker.register_tool(spawn_pod_tool(
spawner_name.clone(),
spawner_socket,
runtime_base.clone(),
workspace_root.clone(),
cwd.clone(),
spawned_registry.clone(),
self_parent_socket,
spawner_manifest,
scope_handle,
prompts,
));
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
worker.register_tool(stop_pod_tool(spawned_registry.clone()));
let discovery =
PodDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry);
worker.register_tool(list_pods_tool(discovery.clone()));
worker.register_tool(restore_pod_tool(discovery.clone()));
worker.register_tool(send_to_peer_pod_tool(discovery));
}
worker.register_tool(spawn_pod_tool(
spawner_name.clone(),
spawner_socket,
runtime_base.clone(),
workspace_root.clone(),
cwd.clone(),
spawned_registry.clone(),
self_parent_socket,
spawner_manifest,
scope_handle,
prompts,
));
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
worker.register_tool(stop_pod_tool(spawned_registry.clone()));
let discovery =
PodDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry);
worker.register_tool(list_pods_tool(discovery.clone()));
worker.register_tool(restore_pod_tool(discovery.clone()));
worker.register_tool(send_to_peer_pod_tool(discovery));
}
let _feature_install_report = pod.install_features(feature_registry);
pod.attach_tracker(tracker);
Ok(fs_for_view)
}

View File

@ -7,6 +7,7 @@ use clap::{CommandFactory, FromArgMatches, Parser};
use manifest::{
Permission, PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ScopeConfig, ScopeRule, paths,
plugin::{PluginDiscoveryOptions, resolve_plugin_config_for_startup},
};
use pod_store::{CombinedStore, FsPodStore, PodMetadataStore};
use session_store::{FsStore, SegmentId, Store};
@ -184,9 +185,15 @@ where
apply_profile_launch_policy(&mut manifest, &workspace_root, cli.ticket_role.as_deref())?;
}
apply_session_restore_overrides(&mut manifest, cli)?;
apply_plugin_resolution_plan(&mut manifest, &workspace_root);
Ok((manifest, loader))
}
fn apply_plugin_resolution_plan(manifest: &mut PodManifest, workspace_root: &Path) {
let options = PluginDiscoveryOptions::new(workspace_root);
manifest.plugins = resolve_plugin_config_for_startup(&manifest.plugins, &options);
}
fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> {
if let Some(pod_name) = cli.pod.as_deref() {
manifest.pod.name = pod_name.to_string();

View File

@ -1290,15 +1290,36 @@ impl FeatureRegistryBuilder {
hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport {
let mut pending_tools = Vec::new();
let report = self.install_into_pending(&mut pending_tools, hook_builder);
worker.tool_server_handle().flush_pending();
let registered_tool_names = worker
.tool_server_handle()
.tool_definitions_sorted()
.into_iter()
.map(|definition| (definition.name, FeatureId::builtin("preexisting-tool")))
.collect();
let report = self.install_into_pending_with_registered(
&mut pending_tools,
hook_builder,
registered_tool_names,
);
worker.register_tools(pending_tools);
report
}
#[allow(dead_code)]
pub(crate) fn install_into_pending(
self,
pending_tools: &mut Vec<ToolDefinition>,
hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport {
self.install_into_pending_with_registered(pending_tools, hook_builder, HashMap::new())
}
fn install_into_pending_with_registered(
self,
pending_tools: &mut Vec<ToolDefinition>,
hook_builder: &mut HookRegistryBuilder,
mut installed_tool_names: HashMap<String, FeatureId>,
) -> FeatureRegistryInstallReport {
let descriptors: Vec<_> = self
.modules
@ -1307,7 +1328,6 @@ impl FeatureRegistryBuilder {
.collect();
let mut service_registry = FeatureServiceRegistry::default();
let mut reports = Vec::with_capacity(self.modules.len());
let mut installed_tool_names = HashMap::new();
let mut seen_features = HashSet::new();
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
@ -1455,6 +1475,7 @@ pub enum FeatureInstallError {
}
pub mod builtin;
pub mod plugin;
#[cfg(test)]
mod tests {

View File

@ -0,0 +1,671 @@
//! Plugin package contributions for model-visible Tool schemas.
//!
//! This module registers *enabled* plugin package tool surface definitions as
//! unavailable Tool stubs. It deliberately does not execute plugin code or grant
//! plugin permissions; the runtime/WASM executor belongs to a later boundary.
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use llm_worker::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
};
use manifest::plugin::{PluginConfig, PluginSurface, ResolvedPluginRecord};
use serde_json::Value;
use super::{
FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule,
FeatureRuntimeKind, ToolContribution, ToolDeclaration,
};
/// Build Feature modules for enabled plugin packages when the profile exposes
/// the plugin Tool surface feature.
pub fn plugin_tool_features_if_enabled(
feature_enabled: bool,
config: &PluginConfig,
) -> Vec<PluginToolFeature> {
if !feature_enabled {
return Vec::new();
}
plugin_tool_features(config)
}
/// Build Feature modules for enabled plugin packages that declare Tool surfaces.
pub fn plugin_tool_features(config: &PluginConfig) -> Vec<PluginToolFeature> {
config
.resolved
.iter()
.filter(|record| record.enabled_surfaces.contains(&PluginSurface::Tool))
.filter(|record| !record.manifest.tools.is_empty())
.cloned()
.map(PluginToolFeature::new)
.collect()
}
#[derive(Clone, Debug)]
pub struct PluginToolFeature {
record: ResolvedPluginRecord,
feature_id: FeatureId,
}
impl PluginToolFeature {
pub fn new(record: ResolvedPluginRecord) -> Self {
let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity))
.expect("source-qualified plugin identity yields non-empty feature id");
Self { record, feature_id }
}
pub fn origin(&self) -> ToolOrigin {
ToolOrigin {
kind: "plugin".into(),
plugin_id: self.record.manifest.id.clone(),
plugin_ref: self.record.identity.to_string(),
source: self.record.identity.source.to_string(),
digest: self.record.digest.clone(),
package_version: self.record.version.clone(),
package_api_version: self.record.manifest.schema_version,
surface: "tool".into(),
}
}
}
impl FeatureModule for PluginToolFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor =
FeatureDescriptor {
id: self.feature_id.clone(),
runtime: FeatureRuntimeKind::ExternalPlugin,
display_name: self.record.manifest.name.clone(),
version: self.record.manifest.version.clone(),
description: self.record.manifest.description.clone().unwrap_or_else(|| {
format!("Plugin tool surface from {}", self.record.identity)
}),
tools: Vec::new(),
hooks: Vec::new(),
background_tasks: Vec::new(),
provides_services: Vec::new(),
requires_services: Vec::new(),
protocol_providers: Vec::new(),
};
for tool in &self.record.manifest.tools {
descriptor = descriptor.with_tool(ToolDeclaration::new(
tool.name.clone(),
tool.description.clone(),
));
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
validate_declared_tool_names(&self.record)?;
let origin = self.origin();
for tool in &self.record.manifest.tools {
validate_tool_name(&tool.name).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin `{}` tool `{}` has invalid name: {reason}",
self.record.identity, tool.name
))
})?;
validate_input_schema(&tool.input_schema).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin `{}` tool `{}` has invalid input_schema: {reason}",
self.record.identity, tool.name
))
})?;
context.tools().register(ToolContribution::new(
tool.name.clone(),
plugin_runtime_missing_definition(
tool.name.clone(),
tool.description.clone(),
tool.input_schema.clone(),
origin.clone(),
),
))?;
}
Ok(())
}
}
fn plugin_runtime_missing_definition(
name: String,
description: String,
input_schema: Value,
origin: ToolOrigin,
) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(name.clone())
.description(description.clone())
.input_schema(input_schema.clone())
.origin(origin.clone()),
Arc::new(PluginRuntimeMissingTool {
name: name.clone(),
origin: origin.clone(),
}) as Arc<dyn Tool>,
)
})
}
struct PluginRuntimeMissingTool {
name: String,
origin: ToolOrigin,
}
#[async_trait]
impl Tool for PluginRuntimeMissingTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
Err(ToolError::ExecutionFailed(format!(
"plugin tool runtime missing/unavailable for `{}` from `{}` (digest {}, package {} api {})",
self.name,
self.origin.plugin_ref,
self.origin.digest,
self.origin.package_version,
self.origin.package_api_version
)))
}
}
fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> {
let mut seen = HashSet::new();
for tool in &record.manifest.tools {
if !seen.insert(tool.name.as_str()) {
return Err(FeatureInstallError::DuplicateToolName {
tool: tool.name.clone(),
first_feature: format!("{} (same plugin package)", record.identity),
duplicate_feature: record.identity.to_string(),
});
}
}
Ok(())
}
fn validate_tool_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("name must not be empty");
}
if name.len() > 128 {
return Err("name is longer than 128 bytes");
}
if name.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err("name must not contain whitespace or control characters");
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SupportedSchemaType {
Object,
Array,
String,
Number,
Integer,
Boolean,
Null,
}
impl SupportedSchemaType {
fn parse(value: &str) -> Option<Self> {
match value {
"object" => Some(Self::Object),
"array" => Some(Self::Array),
"string" => Some(Self::String),
"number" => Some(Self::Number),
"integer" => Some(Self::Integer),
"boolean" => Some(Self::Boolean),
"null" => Some(Self::Null),
_ => None,
}
}
}
fn validate_input_schema(schema: &Value) -> Result<(), String> {
let ty = validate_schema_node(schema, "$", true)?;
if ty != SupportedSchemaType::Object {
return Err("root schema type must be `object`".into());
}
Ok(())
}
fn validate_schema_node(
schema: &Value,
path: &str,
root: bool,
) -> Result<SupportedSchemaType, String> {
let Value::Object(map) = schema else {
return Err(format!("{path}: schema node must be a JSON object"));
};
for key in map.keys() {
if !is_supported_schema_keyword(key) {
return Err(format!("{path}: unsupported schema keyword `{key}`"));
}
}
let ty = match map.get("type") {
Some(Value::String(value)) => SupportedSchemaType::parse(value)
.ok_or_else(|| format!("{path}: unsupported schema type `{value}`"))?,
Some(_) => return Err(format!("{path}: type must be a string")),
None if root => return Err("root schema must declare type = `object`".into()),
None => return Err(format!("{path}: schema node must declare type")),
};
if let Some(title) = map.get("title") {
if !title.is_string() {
return Err(format!("{path}: title must be a string"));
}
}
if let Some(description) = map.get("description") {
if !description.is_string() {
return Err(format!("{path}: description must be a string"));
}
}
let properties = map.get("properties");
if let Some(properties) = properties {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: properties is only supported for object schemas"
));
}
let Some(properties) = properties.as_object() else {
return Err(format!("{path}: properties must be a JSON object"));
};
for (name, child_schema) in properties {
validate_schema_node(child_schema, &format!("{path}.properties.{name}"), false)?;
}
}
if let Some(required) = map.get("required") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: required is only supported for object schemas"
));
}
let Some(required) = required.as_array() else {
return Err(format!("{path}: required must be an array"));
};
let mut seen = HashSet::new();
for entry in required {
let Some(name) = entry.as_str() else {
return Err(format!("{path}: required entries must be strings"));
};
if !seen.insert(name) {
return Err(format!("{path}: required entries must be unique"));
}
if let Some(properties) = properties.and_then(Value::as_object) {
if !properties.contains_key(name) {
return Err(format!(
"{path}: required entry `{name}` is not declared in properties"
));
}
}
}
}
if let Some(additional) = map.get("additionalProperties") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: additionalProperties is only supported for object schemas"
));
}
match additional {
Value::Bool(_) => {}
Value::Object(_) => {
validate_schema_node(additional, &format!("{path}.additionalProperties"), false)?;
}
_ => {
return Err(format!(
"{path}: additionalProperties must be boolean or schema object"
));
}
}
}
if let Some(items) = map.get("items") {
if ty != SupportedSchemaType::Array {
return Err(format!("{path}: items is only supported for array schemas"));
}
validate_schema_node(items, &format!("{path}.items"), false)?;
}
if let Some(enum_values) = map.get("enum") {
let Some(enum_values) = enum_values.as_array() else {
return Err(format!("{path}: enum must be an array"));
};
if enum_values.is_empty() {
return Err(format!("{path}: enum must not be empty"));
}
for (index, value) in enum_values.iter().enumerate() {
if enum_values
.iter()
.skip(index + 1)
.any(|other| other == value)
{
return Err(format!("{path}: enum entries must be unique"));
}
}
}
Ok(ty)
}
fn is_supported_schema_keyword(key: &str) -> bool {
matches!(
key,
"type"
| "title"
| "description"
| "properties"
| "required"
| "additionalProperties"
| "items"
| "enum"
)
}
#[cfg(test)]
mod tests {
use super::*;
use manifest::plugin::{PluginPackageManifest, SourceQualifiedPluginId};
use serde_json::json;
fn tool(name: &str) -> manifest::plugin::PluginToolManifest {
manifest::plugin::PluginToolManifest {
name: name.into(),
description: format!("{name} tool"),
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
}
}
fn record(tools: Vec<manifest::plugin::PluginToolManifest>) -> ResolvedPluginRecord {
record_with_identity("project:example", tools)
}
fn record_with_identity(
identity: &str,
tools: Vec<manifest::plugin::PluginToolManifest>,
) -> ResolvedPluginRecord {
let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap();
ResolvedPluginRecord {
identity: parsed_identity.clone(),
source: parsed_identity.source,
package_path: std::path::PathBuf::from("/tmp/example.zip"),
package_label: "example.zip".into(),
digest: "sha256:abc".into(),
version: "0.1.0".into(),
manifest: PluginPackageManifest {
schema_version: 1,
id: "example".into(),
name: "Example".into(),
version: "0.1.0".into(),
description: None,
surfaces: vec![PluginSurface::Tool],
runtime: None,
hooks: Vec::new(),
tools,
},
enabled_surfaces: vec![PluginSurface::Tool],
grants: manifest::plugin::PluginGrantConfig::default(),
config: None,
}
}
fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize {
report
.reports
.iter()
.map(|feature_report| feature_report.skipped.len())
.sum()
}
fn has_diagnostic(report: &super::super::FeatureRegistryInstallReport, needle: &str) -> bool {
report.reports.iter().any(|feature_report| {
feature_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(needle))
})
}
#[test]
fn rejects_invalid_root_schema() {
let schema = json!({"type":"string"});
assert!(
validate_input_schema(&schema)
.unwrap_err()
.contains("type must be `object`")
);
}
#[test]
fn rejects_unsupported_schema_keyword() {
let schema = json!({"type":"object","oneOf":[]});
assert!(
validate_input_schema(&schema)
.unwrap_err()
.contains("unsupported schema keyword")
);
}
#[test]
fn rejects_invalid_nested_property_schema_node() {
let schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let error = validate_input_schema(&schema).unwrap_err();
assert!(error.contains("$.properties.query"));
assert!(error.contains("schema node must be a JSON object"));
}
#[test]
fn rejects_invalid_recursive_schema_members() {
let duplicate_required = json!({
"type":"object",
"properties":{"query":{"type":"string"}},
"required":["query", "query"]
});
assert!(
validate_input_schema(&duplicate_required)
.unwrap_err()
.contains("required entries must be unique")
);
let invalid_items = json!({
"type":"object",
"properties":{"values":{"type":"array", "items":"not-a-schema"}}
});
assert!(
validate_input_schema(&invalid_items)
.unwrap_err()
.contains("$.properties.values.items")
);
let invalid_additional = json!({
"type":"object",
"additionalProperties":{"type":"unsupported"}
});
assert!(
validate_input_schema(&invalid_additional)
.unwrap_err()
.contains("unsupported schema type")
);
}
#[test]
fn accepts_object_tool_schema() {
validate_input_schema(&json!({
"type":"object",
"properties":{
"query":{"type":"string", "description":"Search text"},
"limit":{"type":"integer", "enum":[1, 5, 10]},
"tags":{"type":"array", "items":{"type":"string"}}
},
"required":["query"],
"additionalProperties":{"type":"string"}
}))
.unwrap();
}
#[test]
fn origin_retains_plugin_metadata() {
let feature = PluginToolFeature::new(record(Vec::new()));
let origin = feature.origin();
assert_eq!(origin.kind, "plugin");
assert_eq!(origin.plugin_id, "example");
assert_eq!(origin.plugin_ref, "project:example");
assert_eq!(origin.source, "project");
assert_eq!(origin.digest, "sha256:abc");
assert_eq!(origin.package_version, "0.1.0");
assert_eq!(origin.package_api_version, 1);
assert_eq!(origin.surface, "tool");
}
#[test]
fn enabled_plugin_tool_registers_model_visible_schema_and_origin() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.install_into_pending(&mut pending, &mut hooks);
assert!(
report
.reports
.iter()
.all(|feature_report| feature_report.diagnostics.is_empty()),
"{:#?}",
report.reports
);
assert_eq!(report.installed_tool_names(), vec!["PluginSearch"]);
assert_eq!(pending.len(), 1);
let (meta, _) = pending[0]();
assert_eq!(meta.name, "PluginSearch");
assert_eq!(meta.input_schema["type"], "object");
let origin = meta.origin.expect("plugin origin metadata");
assert_eq!(origin.plugin_ref, "project:example");
assert_eq!(origin.digest, "sha256:abc");
assert_eq!(origin.source, "project");
assert_eq!(origin.surface, "tool");
}
#[test]
fn package_without_enabled_tool_surface_registers_no_schema() {
let mut config = PluginConfig::default();
let mut disabled = record(vec![tool("PluginSearch")]);
disabled.enabled_surfaces.clear();
config.resolved.push(disabled);
assert!(plugin_tool_features(&config).is_empty());
}
#[test]
fn disabled_profile_feature_registers_no_schema() {
let mut config = PluginConfig::default();
config.resolved.push(record(vec![tool("PluginSearch")]));
assert!(plugin_tool_features_if_enabled(false, &config).is_empty());
assert_eq!(plugin_tool_features_if_enabled(true, &config).len(), 1);
}
#[test]
fn duplicate_plugin_tool_names_are_rejected_with_diagnostic() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.with_module(PluginToolFeature::new(record_with_identity(
"project:other",
vec![tool("PluginSearch")],
)))
.install_into_pending(&mut pending, &mut hooks);
assert_eq!(pending.len(), 1);
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
#[test]
fn builtin_tool_name_collision_is_rejected_with_diagnostic() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let mut registered = std::collections::HashMap::new();
registered.insert("Read".to_string(), FeatureId::builtin("preexisting-tool"));
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("Read")])))
.install_into_pending_with_registered(&mut pending, &mut hooks, registered);
assert!(pending.is_empty());
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
#[test]
fn invalid_input_schema_is_rejected_with_diagnostic() {
let mut invalid = tool("BadSchema");
invalid.input_schema = json!({"type":"object","$ref":"#/defs/input"});
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![invalid])))
.install_into_pending(&mut pending, &mut hooks);
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "invalid input_schema"));
}
#[test]
fn nested_invalid_input_schema_does_not_register_plugin_tool() {
let mut invalid = tool("BadNestedSchema");
invalid.input_schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![invalid])))
.install_into_pending(&mut pending, &mut hooks);
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "invalid input_schema"));
assert!(has_diagnostic(&report, "$.properties.query"));
}
#[tokio::test]
async fn registered_tool_executes_as_runtime_missing_error() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.install_into_pending(&mut pending, &mut hooks);
assert!(
report
.reports
.iter()
.all(|feature_report| feature_report.diagnostics.is_empty()),
"{:#?}",
report.reports
);
let (_, tool) = pending[0]();
let error = tool
.execute("{}", ToolExecutionContext::default())
.await
.unwrap_err();
assert!(error.to_string().contains("runtime missing/unavailable"));
assert!(error.to_string().contains("project:example"));
}
}

View File

@ -824,7 +824,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
registry: FeatureRegistryBuilder,
) -> FeatureRegistryInstallReport {
let worker = self.worker.as_mut().expect("worker taken during run");
let report = registry.install_into_worker(worker, &mut self.hook_builder);
let active_workflow_committer = self.log_writer.clone().map(|writer| {
Arc::new(move |entry| writer.commit_log_entry(entry))
as active_workflow::LogEntryCommitter
@ -833,6 +832,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.active_workflows.clone(),
active_workflow_committer,
));
let report = registry.install_into_worker(worker, &mut self.hook_builder);
report
}
@ -924,11 +924,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata {
let mut metadata = PodMetadata::new(self.manifest.pod.name.clone(), active);
if self.manifest.profile.is_some() {
metadata.resolved_manifest_snapshot = serde_json::to_value(&self.manifest).ok();
}
metadata
pod_metadata_for_manifest(&self.manifest, active)
}
fn write_pod_metadata_pending(&self) -> Result<(), PodError> {
@ -4321,6 +4317,21 @@ fn request_config_from_worker_manifest(wm: &WorkerManifest) -> RequestConfig {
config
}
fn pod_metadata_for_manifest(
manifest: &PodManifest,
active: Option<PodActiveSegmentRef>,
) -> PodMetadata {
let mut metadata = PodMetadata::new(manifest.pod.name.clone(), active);
if should_persist_resolved_manifest_snapshot(manifest) {
metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok();
}
metadata
}
fn should_persist_resolved_manifest_snapshot(manifest: &PodManifest) -> bool {
manifest.profile.is_some() || manifest.plugins.has_resolved_plan()
}
fn restore_manifest_from_pod_metadata_snapshot(
pod_name: &str,
snapshot: Option<serde_json::Value>,
@ -5294,6 +5305,75 @@ permission = "write"
Permission::Write
);
}
#[test]
fn plugin_resolved_manifest_snapshot_is_persisted_without_profile() {
let mut manifest = PodManifest::from_toml(
r#"
[pod]
name = "plugin-snapshot"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[worker]
instruction = "saved"
[[scope.allow]]
target = "/snapshot/workspace"
permission = "read"
"#,
)
.unwrap();
assert!(manifest.profile.is_none());
assert!(
pod_metadata_for_manifest(&manifest, None)
.resolved_manifest_snapshot
.is_none()
);
manifest.plugins.resolved = vec![manifest::plugin::ResolvedPluginRecord {
identity: manifest::plugin::SourceQualifiedPluginId::new(
manifest::plugin::PluginSourceKind::Project,
"example",
),
source: manifest::plugin::PluginSourceKind::Project,
package_path: PathBuf::from("/snapshot/workspace/.yoi/plugins/example.yoi-plugin"),
package_label: "example.yoi-plugin".to_string(),
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_string(),
version: "0.1.0".to_string(),
manifest: manifest::plugin::PluginPackageManifest {
schema_version: 1,
id: "example".to_string(),
name: "Example".to_string(),
version: "0.1.0".to_string(),
description: None,
surfaces: vec![manifest::plugin::PluginSurface::Hook],
runtime: None,
hooks: vec![],
tools: vec![],
},
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
grants: manifest::plugin::PluginGrantConfig::default(),
config: None,
}];
let metadata = pod_metadata_for_manifest(&manifest, None);
let snapshot = metadata
.resolved_manifest_snapshot
.expect("plugin-resolved manifest should be snapshotted");
let restored: PodManifest = serde_json::from_value(snapshot).unwrap();
assert!(restored.profile.is_none());
assert_eq!(restored.plugins.resolved.len(), 1);
assert_eq!(
restored.plugins.resolved[0].digest,
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
assert_eq!(restored.plugins.resolved[0].version, "0.1.0");
}
}
#[cfg(test)]

View File

@ -775,6 +775,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig {
rules: p.rules.clone(),
}),
feature: manifest.feature.clone().into(),
plugins: manifest.plugins.clone(),
compaction: manifest
.compaction
.as_ref()

View File

@ -12,6 +12,7 @@ e2e-test = []
client = { workspace = true }
protocol = { workspace = true }
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
base64 = "0.22.1"
crossterm = "0.28"
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] }
serde_json = { workspace = true }

View File

@ -20,6 +20,7 @@ use crate::composer_history::{
use crate::input::InputBuffer;
use crate::scroll::Scroll;
use crate::task::TaskStore;
use crate::text_selection::TextSelectionState;
use crate::view_mode::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -293,6 +294,10 @@ pub struct App {
/// `[Session TaskStore snapshot]` system messages — no protocol
/// surface added on the Pod side.
pub task_store: TaskStore,
/// Transient single-Pod transcript text selection. This is viewport-local
/// UI state only; it is never sent to the Pod, persisted, or appended to
/// session history/model context.
pub text_selection: TextSelectionState,
/// Whether the right-side task pane is currently open.
pub task_pane_open: bool,
/// Top entry index of the task pane's visible window. Clamped on
@ -351,6 +356,7 @@ impl App {
rewind_refresh_fence: false,
greeting: None,
task_store: TaskStore::new(),
text_selection: TextSelectionState::default(),
task_pane_open: false,
task_pane_scroll: 0,
queued_inputs: VecDeque::new(),

View File

@ -93,6 +93,21 @@ mod tests {
);
}
#[test]
fn maps_alt_enter_to_newline() {
assert_eq!(
composer_edit_action(modified(KeyCode::Enter, KeyModifiers::ALT)),
Some(ComposerEditAction::InsertNewline)
);
assert_eq!(
composer_edit_action(modified(
KeyCode::Enter,
KeyModifiers::ALT | KeyModifiers::CONTROL,
)),
None
);
}
#[test]
fn leaves_enter_tab_esc_and_control_letters_for_callers() {
assert_eq!(composer_edit_action(key(KeyCode::Enter)), None);

View File

@ -18,6 +18,7 @@ pub mod setup_model;
mod single_pod;
mod spawn;
mod task;
mod text_selection;
mod tool;
mod ui;
mod view_mode;

View File

@ -131,11 +131,7 @@ pub(crate) async fn run(
let mut pending_reload = PendingReload::default();
let mut pending_queue_attention_notice = PendingQueueAttentionNotice::default();
if let Some(mode) = app.enter_reload.take() {
if pending_reload.start(mode) {
app.refreshing = true;
}
}
let mut deferred_enter_reload = app.enter_reload.take();
let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
#[cfg(feature = "e2e-test")]
let mut emitted_panel_ready = false;
@ -161,6 +157,12 @@ pub(crate) async fn run(
app.emit_rows_rendered();
}
if let Some(mode) = deferred_enter_reload.take() {
if pending_reload.start(mode) {
app.refreshing = true;
}
}
let now = Instant::now();
if now >= next_poll {
pending_reload.start(OrchestratorLifecycleMode::Observe);
@ -2134,6 +2136,14 @@ impl MultiPodApp {
};
}
let composer_action = composer_edit_action(key);
if let Some(action) = composer_action {
if action.is_modifier_action() {
self.apply_composer_edit_action(action);
return MultiPodAction::None;
}
}
match key.code {
KeyCode::F(2) => {
if self.panel_diagnostic.is_some() {
@ -2199,11 +2209,12 @@ impl MultiPodApp {
.prepare_companion_send()
.map(MultiPodAction::SendCompanion)
.unwrap_or(MultiPodAction::None),
_ if composer_edit_action(key).is_some() => {
self.apply_composer_edit_action(composer_edit_action(key).expect("checked above"));
_ => {
if let Some(action) = composer_action {
self.apply_composer_edit_action(action);
}
MultiPodAction::None
}
_ => MultiPodAction::None,
}
}
}
@ -3202,7 +3213,22 @@ fn derive_orchestrator_work_set(
.iter()
.filter_map(|row| {
let ticket = row.ticket.as_ref()?;
if ticket.workflow_state == TicketWorkflowState::InProgress {
if let Some(overlay) = ticket
.orchestration_overlay
.as_ref()
.filter(|overlay| overlay.workflow_state == TicketWorkflowState::InProgress)
{
Some(OrchestratorActiveWorkItem {
id: ticket.id.clone(),
title: ticket.title.clone(),
status: format!(
"local: {} · {}: {}",
ticket.workflow_state.as_str(),
overlay.source,
overlay.workflow_state.as_str()
),
})
} else if ticket.workflow_state == TicketWorkflowState::InProgress {
Some(OrchestratorActiveWorkItem {
id: ticket.id.clone(),
title: ticket.title.clone(),
@ -3288,6 +3314,13 @@ fn queued_duplicate_guard(
guards.push(format!("related pod/worktree {pod}"));
}
}
if let Some(overlay) = ticket.orchestration_overlay.as_ref() {
guards.push(format!(
"{} worktree overlay shows state {}",
overlay.source,
overlay.workflow_state.as_str()
));
}
if guards.is_empty() {
None
} else {
@ -5212,7 +5245,7 @@ const POD_STATUS_COLUMN_WIDTH: usize = 18;
fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec<Line<'static>> {
if row.kind == PanelRowKind::TicketIntakePod {
vec![panel_row_title_line(row, selected, width)]
vec![panel_intake_child_line(row, selected, width)]
} else {
vec![
panel_row_title_line(row, selected, width),
@ -5232,7 +5265,7 @@ fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'sta
let mut spans = Vec::new();
let mut remaining = width as usize;
push_ticket_marker_span(&mut spans, selected, &mut remaining);
push_ticket_primary_marker_span(&mut spans, selected, &mut remaining);
push_column_span(
&mut spans,
&row.status,
@ -5245,11 +5278,41 @@ fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'sta
Line::from(spans)
}
fn panel_intake_child_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let title_style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
let mut spans = Vec::new();
let mut remaining = width as usize;
push_intake_child_marker_span(&mut spans, selected, &mut remaining);
push_column_span(
&mut spans,
&row.status,
TICKET_STATE_COLUMN_WIDTH,
intake_status_style(&row.status),
&mut remaining,
);
push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining);
Line::from(spans)
}
fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let mut spans = Vec::new();
let mut remaining = width as usize;
push_ticket_marker_span(&mut spans, selected, &mut remaining);
push_ticket_detail_marker_span(&mut spans, selected, &mut remaining);
push_bounded_span(
&mut spans,
"meta ",
Style::default().fg(Color::DarkGray),
&mut remaining,
);
push_bounded_span(
&mut spans,
&panel_ticket_detail(row),
@ -5260,10 +5323,14 @@ fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'st
Line::from(spans)
}
fn push_ticket_marker_span(spans: &mut Vec<Span<'static>>, selected: bool, remaining: &mut usize) {
fn push_ticket_primary_marker_span(
spans: &mut Vec<Span<'static>>,
selected: bool,
remaining: &mut usize,
) {
let (marker, style) = if selected {
(
"| ",
" ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
@ -5274,6 +5341,42 @@ fn push_ticket_marker_span(spans: &mut Vec<Span<'static>>, selected: bool, remai
push_bounded_span(spans, marker, style, remaining);
}
fn push_ticket_detail_marker_span(
spans: &mut Vec<Span<'static>>,
selected: bool,
remaining: &mut usize,
) {
let (marker, style) = if selected {
(
"",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else {
(" ", Style::default().fg(Color::DarkGray))
};
push_bounded_span(spans, marker, style, remaining);
}
fn push_intake_child_marker_span(
spans: &mut Vec<Span<'static>>,
selected: bool,
remaining: &mut usize,
) {
let (marker, style) = if selected {
(
"",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
("", Style::default().fg(Color::DarkGray))
};
push_bounded_span(spans, marker, style, remaining);
}
fn panel_ticket_detail(row: &PanelRow) -> String {
if row.kind == PanelRowKind::InvalidTicket {
let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()];
@ -5411,6 +5514,15 @@ fn panel_priority_style(priority: ActionPriority) -> Style {
}
}
fn intake_status_style(status: &str) -> Style {
match status {
"live" => Style::default().fg(Color::Green),
"restorable" => Style::default().fg(Color::Yellow),
"stale" => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::Cyan),
}
}
fn section_rows(
list: &PodList,
section: &MultiPodSection,
@ -5544,6 +5656,34 @@ fn target_status_line(app: &MultiPodApp) -> Line<'static> {
));
}
Line::from(spans)
} else if let Some(row) = app
.selected_panel_row()
.filter(|row| row.kind == PanelRowKind::TicketIntakePod)
{
let ticket_id = panel_ticket_reference(row);
let action = if row.next_action == Some(NextUserAction::OpenPod) {
"open/attach"
} else {
"unavailable"
};
Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" · selected Intake Pod ",
Style::default().fg(Color::DarkGray),
),
Span::styled(row.status.clone(), intake_status_style(&row.status)),
Span::styled(
format!(" · Ticket {ticket_id} · blank Enter {action}"),
Style::default().fg(Color::DarkGray),
),
])
} else if let Some(entry) = app.selected_pod_entry() {
let (status, status_style) = row_status_label(entry);
Line::from(vec![
@ -7389,9 +7529,8 @@ branch = "orchestration/custom-panel"
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
let row_id = row.ticket.as_ref().unwrap().id.as_str();
assert!(title_line.starts_with("| "));
assert!(detail_line.starts_with("| "));
assert!(!title_line.starts_with(""));
assert!(title_line.starts_with(""));
assert!(detail_line.starts_with("│ meta "));
assert!(!title_line.contains(row_id));
assert_eq!(display_column(&title_line, "inprogress"), state_start);
assert_eq!(
@ -7421,7 +7560,7 @@ branch = "orchestration/custom-panel"
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
assert!(title_line.starts_with(" ready"));
assert!(detail_line.starts_with(" 00001KTTB479X"));
assert!(detail_line.starts_with(" meta 00001KTTB479X"));
assert_eq!(display_column(&title_line, "ready"), state_start);
assert_eq!(
display_column(&title_line, "Long Ticket title"),
@ -7449,7 +7588,7 @@ branch = "orchestration/custom-panel"
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
assert!(title_line.ends_with('…'));
assert_eq!(detail_line.width(), 42);
assert!(detail_line.starts_with(" 00001KTTB479X · Gate: clear"));
assert!(detail_line.starts_with(" meta 00001KTTB479X · Gate: clear"));
assert!(detail_line.ends_with('…'));
}
@ -7474,6 +7613,67 @@ branch = "orchestration/custom-panel"
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
}
#[test]
fn panel_ticket_intake_child_rows_render_as_indented_single_line() {
let row = panel_test_intake_child_row(
"00001TICKET",
"intake-live",
TicketLocalClaimStatus::Live,
Some(NextUserAction::OpenPod),
);
let lines = panel_row_lines(&row, false, 160);
assert_eq!(lines.len(), 1);
let line = plain_line(&lines[0]);
let status_start = 4;
let title_start = status_start + TICKET_STATE_COLUMN_WIDTH + 1;
assert!(line.starts_with(" └ live"));
assert_eq!(display_column(&line, "live"), status_start);
assert_eq!(
display_column(&line, "Intake Pod: intake-live"),
title_start
);
assert!(!line.starts_with(" live"));
let selected_line = plain_line(&panel_row_lines(&row, true, 160)[0]);
assert!(selected_line.starts_with(" ▶ live"));
assert!(selected_line.contains("Intake Pod: intake-live"));
}
#[test]
fn selected_ticket_intake_child_status_is_not_rendered_as_generic_ticket_or_pod() {
let ticket_id = "00001TICKET";
let pod_name = "intake-live";
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.rows.push(panel_test_intake_child_row(
ticket_id,
pod_name,
TicketLocalClaimStatus::Live,
Some(NextUserAction::OpenPod),
));
let list = PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
vec![live_info(pod_name, PodStatus::Idle)],
None,
10,
);
let mut app = app_with_panel(list, panel);
app.select_panel_key(PanelRowKey::TicketIntakePod {
ticket_id: ticket_id.to_string(),
pod_name: pod_name.to_string(),
});
let status = plain_line(&target_status_line(&app));
assert!(status.contains("selected Intake Pod live"));
assert!(status.contains("Ticket 00001TICKET"));
assert!(status.contains("blank Enter open/attach"));
assert!(!status.contains("selected Ticket"));
assert!(!status.contains("selected Pod live"));
}
#[test]
fn panel_pod_rows_use_aligned_columns_before_pod_name() {
let app = test_app(vec![
@ -7983,6 +8183,64 @@ branch = "orchestration/custom-panel"
);
}
#[test]
fn multi_alt_enter_inserts_newline_without_companion_send() {
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
app.input.insert_str("first line");
assert!(matches!(
app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)),
MultiPodAction::None
));
assert_eq!(input_text(&app), "first line\n");
assert!(!app.sending);
assert!(app.notice.is_none());
}
#[test]
fn multi_alt_enter_on_blank_pod_selection_inserts_newline_without_opening() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
let selected_before = app.selected_row.clone();
assert!(matches!(
app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)),
MultiPodAction::None
));
assert_eq!(input_text(&app), "\n");
assert_eq!(app.selected_row, selected_before);
assert!(app.notice.is_none());
}
#[test]
fn multi_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.rows.push(panel_test_ticket_row(
"TICKET-1",
"Ready",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
));
let mut app = app_with_panel(
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
panel,
);
let selected_before = app.selected_row.clone();
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue));
assert!(matches!(
app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)),
MultiPodAction::None
));
assert_eq!(input_text(&app), "\n");
assert_eq!(app.selected_row, selected_before);
assert!(!app.sending);
assert!(app.notice.is_none());
}
#[test]
fn multi_composer_shared_word_motion_and_delete_keys() {
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
@ -8659,6 +8917,7 @@ branch = "orchestration/custom-panel"
workflow_state: TicketWorkflowState::parse(state)
.unwrap_or(TicketWorkflowState::Planning),
workflow_state_explicit: true,
orchestration_overlay: None,
next_action: Some(next_action),
updated_at: None,
latest_event_kind: Some("implementation_report".to_string()),
@ -8683,6 +8942,36 @@ branch = "orchestration/custom-panel"
}
}
fn panel_test_intake_child_row(
ticket_id: &str,
pod_name: &str,
status: TicketLocalClaimStatus,
next_action: Option<NextUserAction>,
) -> PanelRow {
PanelRow {
key: PanelRowKey::TicketIntakePod {
ticket_id: ticket_id.to_string(),
pod_name: pod_name.to_string(),
},
kind: PanelRowKind::TicketIntakePod,
title: format!("Intake Pod: {pod_name}"),
subtitle: Some(format!("Intake claim for Ticket {ticket_id}")),
status: status.label().to_string(),
priority: match status {
TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => {
ActionPriority::ActiveWork
}
TicketLocalClaimStatus::Stale => ActionPriority::Background,
},
next_action,
ticket: None,
related_pods: vec![pod_name.to_string()],
disabled_reason: (status == TicketLocalClaimStatus::Stale)
.then(|| "claim metadata is stale".to_string()),
key_hint: Some(format!("Ticket {ticket_id} Intake Pod {pod_name}")),
}
}
fn closed_list(count: usize, selected: Option<&str>) -> PodList {
PodList::from_sources(
PodVisibilitySource::ResumePicker,

View File

@ -10,8 +10,8 @@ use std::thread;
use std::time::Duration;
use crossterm::event::{
self, DisableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent,
MouseEventKind,
self, DisableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton,
MouseEvent, MouseEventKind,
};
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{Command, execute};
@ -23,6 +23,7 @@ use ratatui::backend::CrosstermBackend;
use session_store::SegmentId;
use tokio::sync::mpsc;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use client::{PodClient, PodRuntimeCommand};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
@ -33,20 +34,17 @@ use crate::{multi_pod, picker, spawn, ui};
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Enable the narrowest standard xterm mouse mode that still reports wheel
/// events. Crossterm's `EnableMouseCapture` also enables button-event
/// tracking (`?1002h`), which requests drag-motion reports and interferes
/// with terminal text selection more aggressively. Normal tracking (`?1000h`)
/// reports button presses, releases, and wheel notches, but does not request
/// drag-motion reports; the TUI ignores the non-wheel events.
/// Enable SGR coordinates plus normal mouse tracking. This captures clicks,
/// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`)
/// so terminal-native drag selection remains available during startup.
#[derive(Debug, Clone, Copy)]
struct EnableWheelMouseCapture;
struct EnableSinglePodMouseCapture;
impl Command for EnableWheelMouseCapture {
impl Command for EnableSinglePodMouseCapture {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
// 1000: normal mouse tracking (includes wheel button presses)
// 1006: SGR extended coordinates used by crossterm's parser
f.write_str("\x1B[?1000h\x1B[?1006h")
// 1000: normal mouse tracking (button presses/releases and wheel)
f.write_str("\x1B[?1006h\x1B[?1000h")
}
#[cfg(windows)]
@ -60,6 +58,69 @@ impl Command for EnableWheelMouseCapture {
}
}
/// Enable Panel mouse input without drag tracking. The Panel only needs button
/// presses/releases and wheel events; enabling `?1002h` can make terminal drag
/// selection look captured and is intentionally avoided for Panel startup.
#[derive(Debug, Clone, Copy)]
struct EnablePanelMouseCapture;
impl Command for EnablePanelMouseCapture {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
// 1006: SGR extended coordinates used by crossterm's parser
// 1000: normal mouse tracking (button presses/releases and wheel)
f.write_str("\x1B[?1006h\x1B[?1000h")
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Ok(())
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
true
}
}
fn copy_to_terminal_clipboard<W: io::Write>(out: &mut W, text: &str) -> io::Result<()> {
let encoded = BASE64_STANDARD.encode(text.as_bytes());
write!(out, "\x1B]52;c;{}\x07", encoded)?;
out.flush()
}
fn copy_selection_to_writer<W: io::Write>(app: &mut App, out: &mut W) -> bool {
let Some(text) = app.text_selection.copy_text() else {
return false;
};
let result = copy_to_terminal_clipboard(out, &text);
app.text_selection.clear();
match result {
Ok(()) => {
app.flash_actionbar_notice(
"Copied selected text to terminal clipboard.",
ActionbarNoticeLevel::Info,
ActionbarNoticeSource::Tui,
Duration::from_secs(3),
);
}
Err(_) => {
app.flash_actionbar_notice(
"Copy failed: terminal clipboard write failed.",
ActionbarNoticeLevel::Error,
ActionbarNoticeSource::Tui,
Duration::from_secs(5),
);
}
}
true
}
fn copy_selection_to_terminal(app: &mut App) -> bool {
let mut stdout = io::stdout();
copy_selection_to_writer(app, &mut stdout)
}
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
if let Some(p) = override_path {
return p;
@ -224,7 +285,7 @@ pub(crate) async fn run_panel(
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let mut app = multi_pod::load_app(runtime_command.clone()).await?;
let mut terminal = enter_fullscreen()?;
let mut terminal = enter_panel_fullscreen()?;
loop {
match multi_pod::run(&mut terminal, &mut app).await? {
@ -294,10 +355,18 @@ pub(crate) async fn run_spawn(
fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
let mut stdout = io::stdout();
// Enable only normal mouse tracking for wheel events. Avoid crossterm's
// full mouse capture because it requests drag-motion events and breaks
// terminal-native text selection.
execute!(stdout, EnterAlternateScreen, EnableWheelMouseCapture)?;
// Enable button-event tracking so the transcript can own drag selection;
// avoid all-motion capture because hover-motion reports are unnecessary.
execute!(stdout, EnterAlternateScreen, EnableSinglePodMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
fn enter_panel_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
let mut stdout = io::stdout();
// Panel needs clicks and wheel input only; do not capture drag motion before
// the first visible frame.
execute!(stdout, EnterAlternateScreen, EnablePanelMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
@ -310,7 +379,7 @@ fn enter_fullscreen_existing(
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableWheelMouseCapture
EnableSinglePodMouseCapture
)?;
Ok(())
}
@ -761,8 +830,25 @@ const PANE_SCROLL_LINES: usize = 5;
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES),
MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES),
MouseEventKind::ScrollUp => {
app.text_selection.clear();
app.scroll.scroll_up(WHEEL_LINES);
}
MouseEventKind::ScrollDown => {
app.text_selection.clear();
app.scroll.scroll_down(WHEEL_LINES);
}
MouseEventKind::Down(MouseButton::Left) if app.rewind_picker.is_none() => {
if !app.text_selection.begin_drag(mouse.column, mouse.row) {
app.text_selection.clear();
}
}
MouseEventKind::Drag(MouseButton::Left) if app.rewind_picker.is_none() => {
app.text_selection.update_drag(mouse.column, mouse.row);
}
MouseEventKind::Up(MouseButton::Left) if app.rewind_picker.is_none() => {
app.text_selection.finish_drag(mouse.column, mouse.row);
}
_ => {}
}
}
@ -983,6 +1069,25 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
}
}
if key.modifiers.is_empty() {
match key.code {
KeyCode::Esc if app.text_selection.clear() => return None,
KeyCode::Char('y') if app.text_selection.has_selection() => {
if !copy_selection_to_terminal(app) {
app.text_selection.clear();
app.flash_actionbar_notice(
"Selection contains no copyable text.",
ActionbarNoticeLevel::Warn,
ActionbarNoticeSource::Tui,
Duration::from_secs(3),
);
}
return None;
}
_ => {}
}
}
match key.code {
KeyCode::Esc => {
// Close the popup if it's still showing (covers the
@ -1128,18 +1233,116 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
#[cfg(test)]
mod tests {
use super::*;
use crate::text_selection::{HistoryViewport, SelectionRow};
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
#[test]
fn wheel_mouse_capture_uses_normal_tracking_without_drag_capture() {
fn single_pod_mouse_capture_avoids_drag_and_all_motion_modes() {
let mut ansi = String::new();
Command::write_ansi(&EnableWheelMouseCapture, &mut ansi).unwrap();
Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap();
assert_eq!(ansi, "\x1B[?1000h\x1B[?1006h");
assert!(ansi.contains("?1000h"));
assert!(!ansi.contains("?1002h"));
assert!(ansi.contains("?1006h"));
assert!(!ansi.contains("?1003h"));
}
#[test]
fn mouse_drag_updates_selection_state() {
let mut app = App::new("pod".into());
app.text_selection.set_history_snapshot(
HistoryViewport {
x: 1,
y: 2,
width: 20,
height: 3,
top_offset: 0,
total_lines: 1,
},
vec![SelectionRow::new("alpha".into(), true)],
);
handle_mouse(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 2,
modifiers: KeyModifiers::NONE,
},
);
handle_mouse(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 4,
row: 2,
modifiers: KeyModifiers::NONE,
},
);
handle_mouse(
&mut app,
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 4,
row: 2,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(app.text_selection.copy_text().as_deref(), Some("lph"));
assert!(!app.text_selection.active().unwrap().dragging);
}
#[test]
fn esc_clears_selection_without_editing_composer() {
let mut app = App::new("pod".into());
app.text_selection.set_history_snapshot(
HistoryViewport {
x: 0,
y: 0,
width: 10,
height: 1,
top_offset: 0,
total_lines: 1,
},
vec![SelectionRow::new("hello".into(), true)],
);
assert!(app.text_selection.begin_drag(0, 0));
assert!(handle_key(&mut app, key(KeyCode::Esc)).is_none());
assert!(!app.text_selection.has_selection());
assert!(app.input.is_empty());
}
#[test]
fn copy_selection_writes_osc52_and_clears_selection() {
let mut app = App::new("pod".into());
app.text_selection.set_history_snapshot(
HistoryViewport {
x: 0,
y: 0,
width: 10,
height: 1,
top_offset: 0,
total_lines: 1,
},
vec![SelectionRow::new("hello".into(), true)],
);
assert!(app.text_selection.begin_drag(0, 0));
assert!(app.text_selection.update_drag(4, 0));
let mut out = Vec::new();
assert!(copy_selection_to_writer(&mut app, &mut out));
assert_eq!(String::from_utf8(out).unwrap(), "\x1B]52;c;aGVsbG8=\x07");
assert!(!app.text_selection.has_selection());
assert!(
app.current_actionbar_notice(std::time::Instant::now())
.is_some()
);
}
#[tokio::test]
async fn terminal_event_is_selected_before_ready_pod_event() {
let (tx, mut rx) = mpsc::unbounded_channel();

View File

@ -0,0 +1,367 @@
//! Local, non-persistent text selection state for the single-Pod transcript view.
//!
//! This module deliberately stores only the most recent rendered history rows and
//! the active drag endpoints. Selected/copied text never leaves TUI-local state
//! unless the user explicitly presses the copy key, and even then the caller is
//! responsible for using a non-history clipboard path.
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectionPoint {
pub row: usize,
pub col: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HistoryViewport {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
pub top_offset: usize,
pub total_lines: usize,
}
impl HistoryViewport {
pub fn contains(self, col: u16, row: u16) -> bool {
col >= self.x
&& col < self.x.saturating_add(self.width)
&& row >= self.y
&& row < self.y.saturating_add(self.height)
}
pub fn point_at(self, col: u16, row: u16) -> Option<SelectionPoint> {
if !self.contains(col, row) {
return None;
}
Some(SelectionPoint {
row: self.top_offset + row.saturating_sub(self.y) as usize,
col: col.saturating_sub(self.x) as usize,
})
}
pub fn clamped_point_at(self, col: u16, row: u16) -> Option<SelectionPoint> {
if self.width == 0 || self.height == 0 || self.total_lines == 0 {
return None;
}
let max_col = self.x.saturating_add(self.width.saturating_sub(1));
let max_row = self.y.saturating_add(self.height.saturating_sub(1));
let col = col.clamp(self.x, max_col);
let row = row.clamp(self.y, max_row);
let absolute_row = self.top_offset + row.saturating_sub(self.y) as usize;
if absolute_row >= self.total_lines {
return None;
}
Some(SelectionPoint {
row: absolute_row,
col: col.saturating_sub(self.x) as usize,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectionRow {
pub text: String,
/// Whether this rendered row belongs to selectable transcript text. Tool
/// calls, thinking blocks, greetings, notices, stats, and other non-text
/// items are intentionally false.
pub selectable: bool,
}
impl SelectionRow {
pub fn new(text: String, selectable: bool) -> Self {
Self { text, selectable }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveSelection {
pub anchor: SelectionPoint,
pub focus: SelectionPoint,
pub dragging: bool,
}
#[derive(Debug, Clone, Default)]
pub struct TextSelectionState {
active: Option<ActiveSelection>,
viewport: Option<HistoryViewport>,
rows: Vec<SelectionRow>,
}
impl TextSelectionState {
pub fn set_history_snapshot(&mut self, viewport: HistoryViewport, rows: Vec<SelectionRow>) {
let rows_changed = self.rows != rows;
self.viewport = Some(viewport);
self.rows = rows;
if rows_changed {
self.active = None;
} else {
self.drop_selection_if_stale();
}
}
pub fn clear_history_snapshot(&mut self) {
self.viewport = None;
self.rows.clear();
self.active = None;
}
#[cfg(test)]
pub fn active(&self) -> Option<ActiveSelection> {
self.active
}
pub fn has_selection(&self) -> bool {
self.active.is_some()
}
pub fn clear(&mut self) -> bool {
self.active.take().is_some()
}
pub fn begin_drag(&mut self, col: u16, row: u16) -> bool {
let Some(point) = self
.viewport
.and_then(|viewport| viewport.point_at(col, row))
else {
return false;
};
if !self.row_is_selectable(point.row) {
self.active = None;
return false;
}
self.active = Some(ActiveSelection {
anchor: point,
focus: point,
dragging: true,
});
true
}
pub fn update_drag(&mut self, col: u16, row: u16) -> bool {
let Some(mut active) = self.active else {
return false;
};
if !active.dragging {
return false;
}
let Some(point) = self
.viewport
.and_then(|viewport| viewport.clamped_point_at(col, row))
else {
return false;
};
active.focus = point;
self.active = Some(active);
true
}
pub fn finish_drag(&mut self, col: u16, row: u16) -> bool {
let updated = self.update_drag(col, row);
if let Some(active) = self.active.as_mut() {
active.dragging = false;
true
} else {
updated
}
}
pub fn copy_text(&self) -> Option<String> {
let active = self.active?;
selected_text_from_rows(&self.rows, active.anchor, active.focus)
}
pub fn range_for_row(&self, row: usize) -> Option<(usize, usize)> {
let active = self.active?;
let (start, end) = ordered_points(active.anchor, active.focus);
if row < start.row || row > end.row || !self.row_is_selectable(row) {
return None;
}
let row_width = display_width(&self.rows.get(row)?.text);
let (from, to) = if start.row == end.row {
(
start.col.min(row_width),
end.col.saturating_add(1).min(row_width),
)
} else if row == start.row {
(start.col.min(row_width), row_width)
} else if row == end.row {
(0, end.col.saturating_add(1).min(row_width))
} else {
(0, row_width)
};
if from >= to { None } else { Some((from, to)) }
}
fn row_is_selectable(&self, row: usize) -> bool {
self.rows.get(row).is_some_and(|row| row.selectable)
}
fn drop_selection_if_stale(&mut self) {
let Some(active) = self.active else {
return;
};
if active.anchor.row >= self.rows.len() || active.focus.row >= self.rows.len() {
self.active = None;
}
}
}
pub fn selected_text_from_rows(
rows: &[SelectionRow],
anchor: SelectionPoint,
focus: SelectionPoint,
) -> Option<String> {
let (start, end) = ordered_points(anchor, focus);
if start.row >= rows.len() || end.row >= rows.len() {
return None;
}
let mut copied = Vec::new();
for row_idx in start.row..=end.row {
let row = &rows[row_idx];
if !row.selectable {
continue;
}
let row_width = display_width(&row.text);
let (from, to) = if start.row == end.row {
(
start.col.min(row_width),
end.col.saturating_add(1).min(row_width),
)
} else if row_idx == start.row {
(start.col.min(row_width), row_width)
} else if row_idx == end.row {
(0, end.col.saturating_add(1).min(row_width))
} else {
(0, row_width)
};
copied.push(slice_display_cols(&row.text, from, to));
}
if copied.iter().any(|line| !line.is_empty()) {
Some(copied.join("\n"))
} else {
None
}
}
pub fn ordered_points(a: SelectionPoint, b: SelectionPoint) -> (SelectionPoint, SelectionPoint) {
if (a.row, a.col) <= (b.row, b.col) {
(a, b)
} else {
(b, a)
}
}
pub fn display_width(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
pub fn slice_display_cols(text: &str, start: usize, end: usize) -> String {
if start >= end {
return String::new();
}
let mut out = String::new();
let mut col = 0usize;
for c in text.chars() {
let width = UnicodeWidthChar::width(c).unwrap_or(0);
let next = col.saturating_add(width);
if next > start && col < end {
out.push(c);
}
col = next;
if col >= end {
break;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coordinate_mapping_uses_history_inner_origin_and_scroll_offset() {
let viewport = HistoryViewport {
x: 2,
y: 3,
width: 10,
height: 5,
top_offset: 7,
total_lines: 20,
};
assert_eq!(
viewport.point_at(4, 5),
Some(SelectionPoint { row: 9, col: 2 })
);
assert_eq!(viewport.point_at(1, 5), None);
assert_eq!(viewport.point_at(4, 8), None);
}
#[test]
fn selection_state_drag_update_and_clear() {
let mut state = TextSelectionState::default();
state.set_history_snapshot(
HistoryViewport {
x: 0,
y: 0,
width: 20,
height: 3,
top_offset: 0,
total_lines: 3,
},
vec![
SelectionRow::new("alpha".into(), true),
SelectionRow::new("tool".into(), false),
SelectionRow::new("omega".into(), true),
],
);
assert!(state.begin_drag(1, 0));
assert!(state.update_drag(2, 2));
assert_eq!(state.copy_text().as_deref(), Some("lpha\nome"));
assert!(state.finish_drag(2, 2));
assert!(!state.active().unwrap().dragging);
assert!(state.clear());
assert!(!state.has_selection());
}
#[test]
fn selected_text_preserves_blank_separator_between_text_items() {
let rows = vec![
SelectionRow::new("first".into(), true),
SelectionRow::new("".into(), true),
SelectionRow::new("second".into(), true),
];
let copied = selected_text_from_rows(
&rows,
SelectionPoint { row: 0, col: 0 },
SelectionPoint { row: 2, col: 5 },
)
.unwrap();
assert_eq!(copied, "first\n\nsecond");
}
#[test]
fn non_text_rows_are_skipped_during_extraction() {
let rows = vec![
SelectionRow::new("user".into(), true),
SelectionRow::new("tool output".into(), false),
SelectionRow::new("assistant".into(), true),
];
let copied = selected_text_from_rows(
&rows,
SelectionPoint { row: 0, col: 0 },
SelectionPoint { row: 2, col: 8 },
)
.unwrap();
assert_eq!(copied, "user\nassistant");
}
}

View File

@ -31,6 +31,7 @@ use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label,
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
use crate::command::CommandCandidate;
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
use crate::text_selection::{HistoryViewport, SelectionRow};
use crate::view_mode::Mode;
pub fn draw(frame: &mut Frame, app: &mut App) {
@ -306,55 +307,90 @@ fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -
needed.clamp(1, cap)
}
pub struct HistoryRow {
pub line: Line<'static>,
pub text: String,
pub selectable: bool,
}
impl HistoryRow {
fn new(line: Line<'static>, selectable: bool) -> Self {
let text = line_text(&line);
Self {
line,
text,
selectable,
}
}
fn selection_row(&self) -> SelectionRow {
SelectionRow::new(self.text.clone(), self.selectable)
}
}
/// Pre-rendered history lines plus the line indices at which each turn
/// begins (used for Ctrl-[/] jumps).
pub struct HistoryLayout {
pub lines: Vec<Line<'static>>,
pub rows: Vec<HistoryRow>,
pub turn_starts: Vec<usize>,
}
pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
// Step 1: collect logical lines from each block (unwrapped).
let mut logical: Vec<Line<'static>> = Vec::new();
let mut logical: Vec<(Line<'static>, bool)> = Vec::new();
let mut logical_turn_starts: Vec<usize> = Vec::new();
let mut first = true;
let mut previous_selectable = false;
let mut i = 0;
while i < app.blocks.len() {
let block = &app.blocks[i];
let current_selectable = block_is_selectable_text(block);
if !first {
logical.push(Line::from(""));
// Preserve a deterministic blank-line separator when copying
// across text-like items. Tool/non-text item rows remain
// unselectable, but separators adjacent to text are copied as
// blank lines so cross-item extraction is stable.
logical.push((Line::from(""), previous_selectable || current_selectable));
}
first = false;
let block = &app.blocks[i];
if matches!(block, Block::TurnHeader { .. }) {
logical_turn_starts.push(logical.len());
}
if matches!(block, Block::ToolCall(_)) {
let out = crate::tool::render_tool(&app.cache, &app.blocks, i, width, app.mode);
logical.extend(out.lines);
logical.extend(out.lines.into_iter().map(|line| (line, false)));
i += out.consumed.max(1);
previous_selectable = false;
continue;
}
render_block_into(&mut logical, block, width, app.mode);
let mut block_lines = Vec::new();
render_block_into(&mut block_lines, block, width, app.mode);
logical.extend(
block_lines
.into_iter()
.map(|line| (line, current_selectable)),
);
previous_selectable = current_selectable;
i += 1;
}
// Step 2: pre-wrap every logical line to char-based terminal rows so
// scroll math is exact. Track the logical → wrapped mapping so
// turn-start indices get translated into wrapped-row coordinates.
let mut lines: Vec<Line<'static>> = Vec::with_capacity(logical.len());
let mut rows: Vec<HistoryRow> = Vec::with_capacity(logical.len());
let mut logical_to_wrapped: Vec<usize> = Vec::with_capacity(logical.len() + 1);
for line in logical {
logical_to_wrapped.push(lines.len());
wrap_line_into(line, width, &mut lines);
for (line, selectable) in logical {
logical_to_wrapped.push(rows.len());
wrap_history_row_into(line, selectable, width, &mut rows);
}
logical_to_wrapped.push(lines.len());
logical_to_wrapped.push(rows.len());
let turn_starts = logical_turn_starts
.into_iter()
.map(|i| logical_to_wrapped.get(i).copied().unwrap_or(lines.len()))
.map(|i| logical_to_wrapped.get(i).copied().unwrap_or(rows.len()))
.collect();
HistoryLayout { lines, turn_starts }
HistoryLayout { rows, turn_starts }
}
/// Horizontal gutter around the log area. Applied via a
@ -369,6 +405,7 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
app.scroll.total_lines = 0;
app.scroll.tail_top_offset = 0;
app.scroll.turn_starts.clear();
app.text_selection.clear_history_snapshot();
return;
}
@ -389,21 +426,23 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING));
let inner = outer_block.inner(history_area);
if inner.width == 0 || inner.height == 0 {
app.text_selection.clear_history_snapshot();
return;
}
if let Some(picker) = app.rewind_picker.as_mut() {
app.text_selection.clear_history_snapshot();
draw_rewind_picker(frame, history_area, inner, outer_block, picker);
return;
}
let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width);
let HistoryLayout { rows, turn_starts } = compute_history(app, inner.width);
// `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll
// `rows` is already pre-wrapped: 1 entry == 1 terminal row. Scroll
// math degenerates to index arithmetic.
let tail_top = lines.len().saturating_sub(inner.height as usize);
let tail_top = rows.len().saturating_sub(inner.height as usize);
app.scroll.area_height = inner.height;
app.scroll.total_lines = lines.len();
app.scroll.total_lines = rows.len();
app.scroll.tail_top_offset = tail_top;
app.scroll.turn_starts = turn_starts;
@ -413,8 +452,30 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
app.scroll.top_offset = app.scroll.top_offset.min(tail_top);
}
let end = (app.scroll.top_offset + inner.height as usize).min(lines.len());
let visible: Vec<Line<'static>> = lines[app.scroll.top_offset..end].to_vec();
app.text_selection.set_history_snapshot(
HistoryViewport {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height,
top_offset: app.scroll.top_offset,
total_lines: rows.len(),
},
rows.iter().map(HistoryRow::selection_row).collect(),
);
let end = (app.scroll.top_offset + inner.height as usize).min(rows.len());
let visible: Vec<Line<'static>> = rows[app.scroll.top_offset..end]
.iter()
.enumerate()
.map(|(offset, row)| {
let absolute_row = app.scroll.top_offset + offset;
match app.text_selection.range_for_row(absolute_row) {
Some((from, to)) => highlight_line_selection(&row.line, from, to),
None => row.line.clone(),
}
})
.collect();
// Pre-wrapped input → render without ratatui's word-wrap (which
// would otherwise re-wrap mid-row at word boundaries and desync the
@ -717,6 +778,88 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>)
push_row(&mut current, &mut row_width, out);
}
fn wrap_history_row_into(
line: Line<'static>,
selectable: bool,
width: u16,
out: &mut Vec<HistoryRow>,
) {
let mut wrapped = Vec::new();
wrap_line_into(line, width, &mut wrapped);
out.extend(
wrapped
.into_iter()
.map(|line| HistoryRow::new(line, selectable)),
);
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
fn highlight_line_selection(line: &Line<'static>, start: usize, end: usize) -> Line<'static> {
if start >= end {
return line.clone();
}
let highlight = Style::default().fg(Color::Black).bg(Color::Cyan);
let mut col = 0usize;
let mut spans = Vec::new();
for span in &line.spans {
let mut plain = String::new();
let mut selected = String::new();
let push_pending =
|plain: &mut String, selected: &mut String, spans: &mut Vec<Span<'static>>| {
if !plain.is_empty() {
spans.push(Span::styled(std::mem::take(plain), span.style));
}
if !selected.is_empty() {
spans.push(Span::styled(
std::mem::take(selected),
span.style.patch(highlight),
));
}
};
let mut in_selected = false;
for ch in span.content.chars() {
let width = UnicodeWidthChar::width(ch).unwrap_or(0);
let next = col.saturating_add(width);
let selected_char = next > start && col < end;
if selected_char != in_selected {
push_pending(&mut plain, &mut selected, &mut spans);
in_selected = selected_char;
}
if selected_char {
selected.push(ch);
} else {
plain.push(ch);
}
col = next;
}
push_pending(&mut plain, &mut selected, &mut spans);
}
Line {
spans,
style: line.style,
alignment: line.alignment,
}
}
/// Only text-like transcript Items are selectable/copyable. Tool calls and
/// other non-text Items remain visible but unselectable, so their rendered
/// diagnostics/output are never copied through this path.
fn block_is_selectable_text(block: &Block) -> bool {
matches!(
block,
Block::UserMessage { .. } | Block::SystemMessage { .. } | Block::AssistantText { .. }
)
}
fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16, mode: Mode) {
match block {
Block::Greeting(g) => match mode {
@ -1668,4 +1811,41 @@ mod tests {
Some("retrying LLM request".into())
);
}
#[test]
fn history_rows_mark_text_items_selectable_and_non_text_unselectable() {
let mut app = App::new("pod".to_string());
app.blocks = vec![
Block::UserMessage {
segments: vec![Segment::Text {
content: "hello".to_string(),
}],
},
Block::AssistantText {
text: "world".to_string(),
},
Block::Thinking(ThinkingBlock {
text: "private reasoning".to_string(),
state: ThinkingState::Finished { elapsed_secs: None },
}),
];
let layout = compute_history(&app, 80);
let rows: Vec<_> = layout
.rows
.iter()
.map(|row| (row.text.as_str(), row.selectable))
.collect();
assert!(rows.contains(&("hello", true)));
assert!(rows.contains(&("world", true)));
assert!(
rows.iter()
.any(|(text, selectable)| text.contains("private reasoning") && !*selectable)
);
assert!(
rows.iter()
.any(|(text, selectable)| text.is_empty() && *selectable)
);
}
}

View File

@ -1,7 +1,12 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use protocol::PodStatus;
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
use ticket::config::{
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
TicketOrchestrationConfig,
};
use ticket::{
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
@ -242,6 +247,7 @@ pub(crate) struct TicketPanelEntry {
pub(crate) priority: String,
pub(crate) workflow_state: TicketWorkflowState,
pub(crate) workflow_state_explicit: bool,
pub(crate) orchestration_overlay: Option<TicketStateOverlay>,
pub(crate) next_action: Option<NextUserAction>,
pub(crate) updated_at: Option<String>,
pub(crate) latest_event_kind: Option<String>,
@ -252,6 +258,12 @@ pub(crate) struct TicketPanelEntry {
pub(crate) intake_pods: Vec<TicketAssociatedIntakeEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketStateOverlay {
pub(crate) source: String,
pub(crate) workflow_state: TicketWorkflowState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketAssociatedIntakeEntry {
pub(crate) ticket_id: String,
@ -379,6 +391,27 @@ pub(crate) enum OrchestratorLifecyclePlan {
Unavailable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OrchestrationTicketOverlay {
states: BTreeMap<String, TicketStateOverlay>,
diagnostics: Vec<String>,
}
impl OrchestrationTicketOverlay {
fn empty() -> Self {
Self {
states: BTreeMap::new(),
diagnostics: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OrchestrationWorktreeLayout {
path: PathBuf,
branch: String,
}
pub(crate) fn workspace_companion_pod_name(workspace_root: &Path) -> String {
let seed = workspace_root
.file_name()
@ -543,6 +576,161 @@ pub(crate) fn bounded_panel_diagnostic(message: impl AsRef<str>) -> String {
excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string())
}
fn load_orchestration_ticket_overlay(
workspace_root: &Path,
config: &TicketConfig,
) -> OrchestrationTicketOverlay {
let layout = orchestration_worktree_layout(workspace_root, &config.orchestration);
if !layout.path.exists() {
return OrchestrationTicketOverlay::empty();
}
match validate_orchestration_overlay_source(workspace_root, &layout) {
Ok(()) => {
load_orchestration_ticket_overlay_states(&layout.path, config.ticket_record_language())
.unwrap_or_else(|message| OrchestrationTicketOverlay {
states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration Ticket overlay unavailable: {message}"
))],
})
}
Err(message) => OrchestrationTicketOverlay {
states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration Ticket overlay unavailable: {message}"
))],
},
}
}
fn orchestration_worktree_layout(
workspace_root: &Path,
config: &TicketOrchestrationConfig,
) -> OrchestrationWorktreeLayout {
OrchestrationWorktreeLayout {
path: workspace_root
.join(config.worktree_dir())
.join(config.worktree_name()),
branch: config.effective_branch_name().to_string(),
}
}
fn load_orchestration_ticket_overlay_states(
worktree_root: &Path,
record_language: Option<&str>,
) -> Result<OrchestrationTicketOverlay, String> {
let ticket_root = worktree_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
if !ticket_root.is_dir() {
return Ok(OrchestrationTicketOverlay {
states: BTreeMap::new(),
diagnostics: vec![bounded_panel_diagnostic(format!(
"Orchestration worktree has no {} directory",
DEFAULT_TICKET_BACKEND_RELATIVE_PATH
))],
});
}
let backend = LocalTicketBackend::new(ticket_root).with_record_language(record_language);
let partial = backend
.list_partial(TicketFilter::all())
.map_err(|error| error.to_string())?;
let mut states = BTreeMap::new();
for summary in partial.tickets {
states.insert(
summary.id,
TicketStateOverlay {
source: "orchestration".to_string(),
workflow_state: summary.workflow_state,
},
);
}
let diagnostics = invalid_ticket_diagnostics(partial.invalid_records.len());
Ok(OrchestrationTicketOverlay {
states,
diagnostics,
})
}
fn validate_orchestration_overlay_source(
workspace_root: &Path,
layout: &OrchestrationWorktreeLayout,
) -> Result<(), String> {
if !layout.path.exists() {
return Err(format!(
"expected worktree {} does not exist",
layout.path.display()
));
}
if !layout.path.is_dir() {
return Err(format!(
"expected worktree {} is not a directory",
layout.path.display()
));
}
let expected_path = layout
.path
.canonicalize()
.map_err(|error| format!("could not canonicalize expected worktree: {error}"))?;
let overlay_top = git_output(&layout.path, &["rev-parse", "--show-toplevel"])?;
let overlay_top = PathBuf::from(overlay_top);
let overlay_top = overlay_top
.canonicalize()
.map_err(|error| format!("could not canonicalize overlay git top-level: {error}"))?;
if overlay_top != expected_path {
return Err(format!(
"overlay git top-level {} does not match expected path {}",
overlay_top.display(),
expected_path.display()
));
}
let current_common_dir = git_common_dir(workspace_root)?;
let overlay_common_dir = git_common_dir(&layout.path)?;
if current_common_dir != overlay_common_dir {
return Err("expected worktree is from a different git common-dir".to_string());
}
let overlay_branch = git_output(&layout.path, &["branch", "--show-current"])?;
if overlay_branch != layout.branch {
return Err(format!(
"expected branch {} but worktree is on {}",
layout.branch, overlay_branch
));
}
Ok(())
}
fn git_common_dir(worktree_root: &Path) -> Result<PathBuf, String> {
let common_dir = git_output(worktree_root, &["rev-parse", "--git-common-dir"])?;
let common_dir_path = PathBuf::from(common_dir);
let absolute = if common_dir_path.is_absolute() {
common_dir_path
} else {
worktree_root.join(common_dir_path)
};
absolute
.canonicalize()
.map_err(|error| format!("could not canonicalize git common-dir: {error}"))
}
fn git_output(worktree_root: &Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.args(args)
.current_dir(worktree_root)
.output()
.map_err(|error| format!("could not run git {}: {error}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = if stderr.is_empty() {
format!("git {} exited with {}", args.join(" "), output.status)
} else {
format!("git {} failed: {stderr}", args.join(" "))
};
return Err(message);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn build_workspace_panel(
workspace_root: &Path,
pods: &PodList,
@ -595,10 +783,17 @@ fn build_workspace_panel_with_registry_model(
model.header.ticket_root = config.backend_root().to_path_buf();
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
.with_record_language(config.ticket_record_language());
match build_ticket_rows(&backend, pods, registry) {
let orchestration_overlay =
load_orchestration_ticket_overlay(workspace_root, &config);
match build_ticket_rows(&backend, pods, registry, &orchestration_overlay.states)
{
Ok(ticket_rows) => {
model.rows.extend(ticket_rows.rows);
model.header.diagnostics.extend(ticket_rows.diagnostics);
model
.header
.diagnostics
.extend(orchestration_overlay.diagnostics);
}
Err(error) => {
model
@ -652,6 +847,7 @@ pub(crate) fn build_current_ticket_row(
&ticket.relations.blockers,
pods,
&registry,
None,
))
}
@ -683,6 +879,7 @@ fn build_ticket_rows(
backend: &LocalTicketBackend,
pods: &PodList,
registry: &PanelRegistrySnapshot,
orchestration_overlay: &BTreeMap<String, TicketStateOverlay>,
) -> ticket::Result<TicketRowsBuild> {
let partial = backend.list_partial(TicketFilter::all())?;
let mut ticket_rows = Vec::new();
@ -701,12 +898,14 @@ fn build_ticket_rows(
if current_ticket_invalid {
continue;
}
let overlay = orchestration_overlay.get(&summary.id);
ticket_rows.push(ticket_row(
summary,
&ticket.ticket.events,
&ticket.ticket.relations.blockers,
pods,
registry,
overlay,
));
}
Err(_) => invalid_records.push(TicketInvalidRecord {
@ -802,6 +1001,7 @@ fn ticket_row(
relation_blockers: &[TicketRelationBlocker],
pods: &PodList,
registry: &PanelRegistrySnapshot,
orchestration_overlay: Option<&TicketStateOverlay>,
) -> PanelRow {
let local_claim = local_claim_for_ticket(&summary, pods, registry);
let intake_pods =
@ -815,14 +1015,24 @@ fn ticket_row(
related_pods.push(pod_name);
}
}
let derived = derive_ticket_state(&summary, relation_blockers);
let visible_overlay = orchestration_overlay
.filter(|overlay| {
overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
})
.cloned();
let mut derived = derive_ticket_state(&summary, relation_blockers);
if let Some(overlay) = visible_overlay.as_ref() {
apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay);
}
let latest_event = events.last();
let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref());
let entry = TicketPanelEntry {
id: summary.id.clone(),
title: summary.title.clone(),
priority: summary.priority.clone(),
workflow_state: summary.workflow_state,
workflow_state_explicit: summary.workflow_state_explicit,
orchestration_overlay: visible_overlay,
next_action: derived.action,
updated_at: summary.updated_at.clone(),
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
@ -838,7 +1048,7 @@ fn ticket_row(
kind: derived.kind,
title: summary.title,
subtitle,
status: summary.workflow_state.as_str().to_string(),
status: state_display,
priority: derived.priority,
next_action: derived.action,
ticket: Some(entry),
@ -858,6 +1068,76 @@ struct DerivedTicketState {
blocked_reason: Option<String>,
}
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
match state {
TicketWorkflowState::Planning => 0,
TicketWorkflowState::Ready => 1,
TicketWorkflowState::Queued => 2,
TicketWorkflowState::InProgress => 3,
TicketWorkflowState::Done => 4,
TicketWorkflowState::Closed => 5,
}
}
fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool {
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
}
fn ticket_state_display(
local: TicketWorkflowState,
overlay: Option<&TicketStateOverlay>,
) -> String {
match overlay {
Some(overlay) => format!(
"local: {} · {}: {}",
local.as_str(),
overlay.source,
overlay.workflow_state.as_str()
),
None => local.as_str().to_string(),
}
}
fn apply_orchestration_overlay_to_derived(
derived: &mut DerivedTicketState,
local: TicketWorkflowState,
overlay: &TicketStateOverlay,
) {
derived.action = Some(NextUserAction::Wait);
let overlay_state = overlay.workflow_state.as_str();
match overlay.workflow_state {
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
derived.kind = PanelRowKind::Review;
derived.priority = ActionPriority::Background;
derived.disabled_reason = Some(format!(
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
overlay.source,
local.as_str()
));
derived.key_hint = Some(format!(
"Merge pending: local: {} · {}: {overlay_state}",
local.as_str(),
overlay.source
));
}
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
derived.kind = PanelRowKind::ActiveWork;
derived.priority = ActionPriority::ActiveWork;
derived.disabled_reason = Some(format!(
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
overlay.source,
local.as_str()
));
derived.key_hint = Some(format!(
"Progress overlay: local: {} · {}: {overlay_state}",
local.as_str(),
overlay.source
));
}
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
}
}
fn derive_ticket_state(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
@ -1039,7 +1319,7 @@ fn ticket_intake_pod_row(intake: &TicketAssociatedIntakeEntry) -> PanelRow {
pod_name: intake.pod_name.clone(),
},
kind: PanelRowKind::TicketIntakePod,
title: format!("Intake Pod: {}", intake.pod_name),
title: format!("Intake Pod: {}", intake.pod_name),
subtitle: Some(format!(
"Ticket {} · {} · {}",
intake.ticket_id,
@ -1099,7 +1379,11 @@ pub(crate) fn local_claim_status_for_pod(pod_name: &str, pods: &PodList) -> Tick
}
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
let mut parts = vec![format!("{} · {}", entry.id, entry.workflow_state.as_str())];
let mut parts = vec![format!(
"{} · {}",
entry.id,
ticket_state_display(entry.workflow_state, entry.orchestration_overlay.as_ref())
)];
if let Some(claim) = entry.local_claim.as_ref() {
parts.push(format!(
"claim: {} ({})",
@ -1212,7 +1496,10 @@ mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use ticket::{NewTicket, NewTicketRelation, TicketRelationKind, TicketWorkflowState};
use ticket::{
NewTicket, NewTicketRelation, TicketIdOrSlug, TicketRelationKind, TicketStateChange,
TicketWorkflowState,
};
fn empty_pods() -> PodList {
PodList::from_sources(
@ -1229,9 +1516,17 @@ mod tests {
title: &str,
configure: impl FnOnce(&mut NewTicket),
) {
create_ticket_with_id(backend, title, configure);
}
fn create_ticket_with_id(
backend: &LocalTicketBackend,
title: &str,
configure: impl FnOnce(&mut NewTicket),
) -> String {
let mut input = NewTicket::new(title);
configure(&mut input);
backend.create(input).unwrap();
backend.create(input).unwrap().id
}
fn write_ticket_config(workspace_root: &Path) {
@ -1244,6 +1539,111 @@ mod tests {
.unwrap();
}
fn run_git(workspace_root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(workspace_root)
.output()
.unwrap_or_else(|error| panic!("failed to run git {args:?}: {error}"));
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn init_git_repo(workspace_root: &Path) {
run_git(workspace_root, &["init", "--initial-branch", "main"]);
run_git(workspace_root, &["config", "user.name", "Panel Test"]);
run_git(
workspace_root,
&["config", "user.email", "panel-test@example.invalid"],
);
fs::write(workspace_root.join("README.md"), "panel test\n").unwrap();
run_git(workspace_root, &["add", "README.md"]);
run_git(workspace_root, &["commit", "-m", "init"]);
}
fn add_orchestration_worktree(workspace_root: &Path, branch: &str) -> PathBuf {
let orchestration_root = workspace_root.join(".worktree/orchestration");
fs::create_dir_all(orchestration_root.parent().unwrap()).unwrap();
run_git(
workspace_root,
&[
"worktree",
"add",
"-b",
branch,
orchestration_root.to_str().unwrap(),
"HEAD",
],
);
orchestration_root
}
fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) {
let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id);
let overlay_ticket_dir = orchestration_root.join(".yoi/tickets").join(id);
fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap();
fs::create_dir_all(&overlay_ticket_dir).unwrap();
fs::copy(
local_ticket_dir.join("item.md"),
overlay_ticket_dir.join("item.md"),
)
.unwrap();
let local_thread = local_ticket_dir.join("thread.md");
if local_thread.exists() {
fs::copy(local_thread, overlay_ticket_dir.join("thread.md")).unwrap();
}
}
fn set_ticket_state(backend: &LocalTicketBackend, id: &str, state: TicketWorkflowState) {
loop {
let ticket = backend.show(TicketIdOrSlug::Id(id.to_string())).unwrap();
if ticket.meta.workflow_state == state {
break;
}
let next = match (ticket.meta.workflow_state, state) {
(TicketWorkflowState::Queued, TicketWorkflowState::InProgress)
| (TicketWorkflowState::Queued, TicketWorkflowState::Done) => {
TicketWorkflowState::InProgress
}
(TicketWorkflowState::InProgress, TicketWorkflowState::Done) => {
TicketWorkflowState::Done
}
(from, to) => panic!("unsupported test transition {from} -> {to}"),
};
backend
.set_workflow_state(
TicketIdOrSlug::Id(id.to_string()),
TicketStateChange::new(
ticket.meta.workflow_state.as_str(),
next.as_str(),
"test",
format!("test state -> {}", next.as_str()),
),
)
.unwrap();
}
}
fn ticket_row_by_title<'a>(model: &'a WorkspacePanelViewModel, title: &str) -> &'a PanelRow {
model
.rows
.iter()
.find(|row| row.title == title)
.unwrap_or_else(|| panic!("missing row for {title}"))
}
fn status_contains(row: &PanelRow, needle: &str) {
assert!(
row.status.contains(needle),
"status {:?} did not contain {:?}",
row.status,
needle
);
}
fn live_pods(names: &[&str]) -> PodList {
PodList::from_sources(
crate::pod_list::PodVisibilitySource::ResumePicker,
@ -1308,6 +1708,151 @@ mod tests {
assert_eq!(row.next_action, Some(NextUserAction::Queue));
}
#[test]
fn workspace_panel_joins_orchestration_overlay_by_ticket_id() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
write_ticket_config(temp.path());
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let id = create_ticket_with_id(&backend, "Overlay Match", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued);
});
create_ticket(&backend, "Local Only", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued);
});
copy_ticket_to_overlay(temp.path(), &orchestration_root, &id);
let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets"));
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress);
create_ticket(&overlay_backend, "Overlay Only", |input| {
input.workflow_state = Some(TicketWorkflowState::Done);
});
let model = build_workspace_panel(temp.path(), &empty_pods());
let matched = ticket_row_by_title(&model, "Overlay Match");
status_contains(matched, "local: queued");
status_contains(matched, "orchestration: inprogress");
assert_eq!(
matched.ticket.as_ref().unwrap().workflow_state,
TicketWorkflowState::Queued
);
assert_eq!(
matched
.ticket
.as_ref()
.unwrap()
.orchestration_overlay
.as_ref()
.unwrap()
.workflow_state,
TicketWorkflowState::InProgress
);
assert_eq!(ticket_row_by_title(&model, "Local Only").status, "queued");
assert!(model.rows.iter().all(|row| row.title != "Overlay Only"));
}
#[test]
fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket()
{
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
write_ticket_config(temp.path());
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let id = create_ticket_with_id(&backend, "Overlay In Progress", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued);
});
copy_ticket_to_overlay(temp.path(), &orchestration_root, &id);
let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets"));
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress);
let local_item = temp.path().join(".yoi/tickets").join(&id).join("item.md");
let before = fs::read_to_string(&local_item).unwrap();
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = ticket_row_by_title(&model, "Overlay In Progress");
status_contains(row, "local: queued");
status_contains(row, "orchestration: inprogress");
assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_eq!(row.kind, PanelRowKind::ActiveWork);
assert_eq!(fs::read_to_string(&local_item).unwrap(), before);
let local_ticket = backend.show(TicketIdOrSlug::Id(id)).unwrap();
assert_eq!(
local_ticket.meta.workflow_state,
TicketWorkflowState::Queued
);
}
#[test]
fn workspace_panel_displays_queued_plus_orchestration_done_as_merge_pending_without_queue() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
write_ticket_config(temp.path());
let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration");
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let id = create_ticket_with_id(&backend, "Overlay Done", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued);
});
copy_ticket_to_overlay(temp.path(), &orchestration_root, &id);
let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets"));
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done);
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = ticket_row_by_title(&model, "Overlay Done");
status_contains(row, "local: queued");
status_contains(row, "orchestration: done");
assert_eq!(row.kind, PanelRowKind::Review);
assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_ne!(row.next_action, Some(NextUserAction::Queue));
assert!(
row.disabled_reason
.as_deref()
.unwrap()
.contains("merge/review/close authority")
);
}
#[test]
fn workspace_panel_ignores_orchestration_overlay_on_branch_mismatch() {
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
write_ticket_config(temp.path());
let orchestration_root = add_orchestration_worktree(temp.path(), "other-branch");
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let id = create_ticket_with_id(&backend, "Branch Mismatch", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued);
});
copy_ticket_to_overlay(temp.path(), &orchestration_root, &id);
let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets"));
set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done);
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = ticket_row_by_title(&model, "Branch Mismatch");
assert_eq!(row.status, "queued");
assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none());
let diagnostics = model.header.diagnostics.join("\n");
assert!(diagnostics.contains("expected branch orchestration"));
}
#[test]
fn workspace_panel_falls_back_when_orchestration_worktree_is_missing() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Missing Overlay", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued);
});
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = ticket_row_by_title(&model, "Missing Overlay");
assert_eq!(row.status, "queued");
assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none());
}
#[test]
fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() {
let temp = TempDir::new().unwrap();

View File

@ -22,51 +22,46 @@ LICENSE* # recommended license text
assets/** # optional non-executable data assets
```
The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register `hooks/*.toml`, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution.
The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register hook metadata, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution.
## `plugin.toml`
`plugin.toml` is the package authority for package identity and declared needs. It is not the authority for runtime grants.
Illustrative manifest shape:
Currently implemented strict `plugin.toml` shape:
```toml
schema_version = 1
id = "example"
name = "Example Plugin"
id = "example.summarizer"
name = "Example Summarizer"
version = "0.1.0"
description = "Demonstrates declarative hooks and an optional WASM module."
[runtime]
kind = "wasm" # "declarative" or "wasm" for the initial plugin system
entry = "module.wasm"
abi = "yoi-plugin-wasm-1"
[package]
readme = "README.md"
license = "LICENSE"
[permissions]
tools = ["Bash"]
web = false
secrets = []
filesystem = []
description = "Adds a custom summary command."
surfaces = ["hook"]
[[hooks]]
id = "summarize-ticket"
file = "hooks/summarize-ticket.toml"
id = "summary"
file = "hooks/summary.md"
```
Fields proposed for the first implementation pass:
The package archive must contain both root `plugin.toml` and the referenced `hooks/summary.md` entry. Optional WASM metadata is accepted only for the declared future runtime boundary and is not executed:
```toml
[runtime]
kind = "wasm"
entry = "plugin.wasm"
abi = "yoi-plugin-wasm-1"
```
First-pass fields accepted by the parser:
- `schema_version`: required integer; unsupported versions fail closed.
- `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself.
- `name`, `version`, `description`: human metadata used in listings and diagnostics.
- `runtime.kind`: required runtime family. Initial values should be `declarative` and `wasm`.
- `runtime.entry`: required for `wasm`, forbidden or ignored for purely declarative packages.
- `runtime.abi`: required for `wasm` so the host can reject incompatible modules before initialization.
- `hooks`, `schemas`, `package.readme`, `package.license`: package-relative paths that must pass the same normalized-path validation as archive entries.
- `permissions`: requested authority. These declarations are requests only; they do not grant access.
- `surfaces`: optional declared contribution surface names.
- `runtime`: optional WASM metadata only. Discovery records metadata and never executes it.
- `hooks`: optional hook metadata. Discovery records metadata and does not register hooks.
Future descriptor sections such as `[package]`, `[permissions]`, richer `contributions`, or `runtime.kind = "declarative"` are aspirational and are intentionally rejected by the current strict parser until implemented safely.
The `source` is not read from `plugin.toml`. It is assigned by the store that discovered the package.
@ -104,11 +99,12 @@ Discovery is a read-only inventory operation. It may report package metadata, va
Enablement is a resolved runtime plan. It should come from Profile/manifest configuration or another explicit local policy layer, then be recorded into the resolved Manifest/session metadata used to start the Pod. Restored Pods should use that resolved enabled-plugin plan instead of silently re-running fresh discovery and picking newer packages. Fresh discovery must not silently upgrade a restored Pod.
A future enablement record can be shaped like this, but the exact schema belongs to the implementation Ticket:
A minimal implemented enablement record is shaped like this. `version` is an exact package-version requirement; richer range constraints are deferred. `digest` is optional in authoring config, but fresh startup records the resolved digest into runtime metadata.
```toml
[[plugins.enabled]]
id = "user:example"
version = "0.1.0" # optional exact package-version requirement
digest = "sha256:..." # optional pin in authoring, resolved in runtime metadata
config = { level = "concise" }
```

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-XNj5cb8O4aUlrzeXF43htxhoTE3i6XGmzJpXas+jsAg=";
cargoHash = "sha256-Y1siH1oDe9It7ntx83DJO5fzV9LtC7+qq9V6RPlRxUY=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -1,9 +1,131 @@
use std::time::Duration;
use std::time::{Duration, Instant};
const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500);
const FULL_READY_BUDGET: Duration = Duration::from_secs(5);
use yoi_e2e::{
FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, RenderedPanelRow, yoi_binary,
};
#[test]
fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> {
let binary = yoi_binary()?;
let fixture = FixtureWorkspace::new(&binary)?;
assert_fixture_paths_are_isolated(&fixture);
let started = Instant::now();
let mut panel =
PanelHarness::spawn(fixture.panel_config_holding_background_task(binary, "reload"))?;
let remaining = FIRST_VISIBLE_RENDER_BUDGET
.checked_sub(started.elapsed())
.unwrap_or_else(|| Duration::from_millis(0));
panel.wait_for("first visible panel render", remaining, |event| {
event.event == "panel_ready"
})?;
let first_visible_elapsed = started.elapsed();
eprintln!(
"panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}",
panel.artifacts().dir.display()
);
assert!(
first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET,
"first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}",
panel.artifacts().dir.display()
);
let events = panel.events()?;
let ready_index = events
.iter()
.position(|event| event.event == "panel_ready")
.expect("panel_ready event should be present");
assert!(
events[..ready_index]
.iter()
.all(|event| event.event != "background_task_started"),
"initial render must be emitted before reload/background work starts; artifacts at {}",
panel.artifacts().dir.display()
);
panel.expect_background_task_pending("reload")?;
let events = panel.events()?;
let reload_started_index = events
.iter()
.position(|event| {
event.event == "background_task_started"
&& event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload")
})
.expect("held reload should start after first visible render");
assert!(
ready_index < reload_started_index,
"first visible render and reload ordering should remain separate; artifacts at {}",
panel.artifacts().dir.display()
);
panel.press(KeyPress::CtrlC)?;
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
drop(panel);
assert_fixture_cleanup(fixture.cleanup()?);
Ok(())
}
#[test]
fn panel_full_ready_has_separate_startup_budget() -> yoi_e2e::Result<()> {
let binary = yoi_binary()?;
let fixture = FixtureWorkspace::new(&binary)?;
assert_fixture_paths_are_isolated(&fixture);
let started = Instant::now();
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET
.checked_sub(started.elapsed())
.unwrap_or_else(|| Duration::from_millis(0));
panel.wait_for(
"first visible panel render",
first_visible_remaining,
|event| event.event == "panel_ready",
)?;
let first_visible_elapsed = started.elapsed();
eprintln!(
"panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}",
panel.artifacts().dir.display()
);
assert!(
first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET,
"first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}",
panel.artifacts().dir.display()
);
let full_ready_remaining = FULL_READY_BUDGET
.checked_sub(started.elapsed())
.unwrap_or_else(|| Duration::from_millis(0));
panel.wait_for("full ready fixture rows", full_ready_remaining, |event| {
event.event == "rows_rendered"
&& event
.data
.get("rows")
.and_then(serde_json::Value::as_array)
.is_some_and(|rows| rows.len() >= 2)
})?;
let full_ready_elapsed = started.elapsed();
eprintln!(
"panel full ready: {full_ready_elapsed:?} (budget {FULL_READY_BUDGET:?}); artifacts at {}",
panel.artifacts().dir.display()
);
assert!(
full_ready_elapsed <= FULL_READY_BUDGET,
"full ready took {full_ready_elapsed:?}, budget {FULL_READY_BUDGET:?}; artifacts at {}",
panel.artifacts().dir.display()
);
panel.press(KeyPress::CtrlC)?;
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
drop(panel);
assert_fixture_cleanup(fixture.cleanup()?);
Ok(())
}
#[test]
fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> {
let binary = yoi_binary()?;