You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.
-
-
+
+
@@ -54,33 +54,33 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc
@@ -196,15 +196,15 @@ electron-builder generates these artifacts per platform:
| Platform | Versioned Name | Stable Name (for /latest/download) |
|------------------|--------------------------------------------------|--------------------------------------------|
-| macOS arm64 DMG | `Claude.Agent.Teams.UI--arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
-| macOS x64 DMG | `Claude.Agent.Teams.UI--x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
-| macOS arm64 ZIP | `Claude.Agent.Teams.UI--arm64-mac.zip` | - |
-| macOS x64 ZIP | `Claude.Agent.Teams.UI--x64-mac.zip` | - |
-| Windows | `Claude.Agent.Teams.UI.Setup..exe` | `Claude-Agent-Teams-UI-Setup.exe` |
-| Linux AppImage | `Claude.Agent.Teams.UI-.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
-| Linux deb | `claude-agent-teams-ui__amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
-| Linux rpm | `claude-agent-teams-ui-.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
-| Linux pacman | `claude-agent-teams-ui-.pacman` | `Claude-Agent-Teams-UI.pacman` |
+| macOS arm64 DMG | `Agent.Teams.AI--arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
+| macOS x64 DMG | `Agent.Teams.AI--x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
+| macOS arm64 ZIP | `Agent.Teams.AI--arm64-mac.zip` | - |
+| macOS x64 ZIP | `Agent.Teams.AI--x64-mac.zip` | - |
+| Windows | `Agent.Teams.AI.Setup..exe` | `Claude-Agent-Teams-UI-Setup.exe` |
+| Linux AppImage | `Agent.Teams.AI-.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
+| Linux deb | `agent-teams-ai__amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
+| Linux rpm | `agent-teams-ai-.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
+| Linux pacman | `agent-teams-ai-.pacman` | `Claude-Agent-Teams-UI.pacman` |
## Stable Download Links
@@ -214,7 +214,7 @@ It starts only after **release-mac** (two matrix jobs), **release-win**, and **r
This enables permanent links in README that always point to the latest release:
```
-https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg
+https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg
```
GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version.
@@ -251,10 +251,10 @@ git push origin v1.0.0
# Wait for CI to finish (~10 min), then update notes
# Delete a release (if needed)
-gh release delete v1.0.0 --repo 777genius/claude_agent_teams_ui --yes
+gh release delete v1.0.0 --repo 777genius/agent-teams-ai --yes
git tag -d v1.0.0
git push origin :refs/tags/v1.0.0
# Check workflow status
-gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3
+gh run list --repo 777genius/agent-teams-ai --workflow release.yml --limit 3
```
diff --git a/docs/research/agent-launch-architecture-comparison-2026-05-07.md b/docs/research/agent-launch-architecture-comparison-2026-05-07.md
new file mode 100644
index 00000000..6fa60bce
--- /dev/null
+++ b/docs/research/agent-launch-architecture-comparison-2026-05-07.md
@@ -0,0 +1,291 @@
+# Agent launch architecture comparison research
+
+Research date: 2026-05-07
+
+Purpose: record factual research on how different systems launch or execute agents. This is informational context only, not an implementation recommendation.
+
+## Scope
+
+Systems compared:
+
+| System | Repository / source | Snapshot |
+|---|---|---|
+| Our Agent Teams | Local `claude_team` + `agent_teams_orchestrator` | Local working tree, 2026-05-07 |
+| Paperclip | `paperclipai` docs/code research from earlier pass | Public docs / local research |
+| Gastown | `github.com/gastownhall/gastown` | cloned `cfbdf3c` |
+| GoClaw Enterprise / Teams | `github.com/nextlevelbuilder/goclaw` | cloned `a97e502` |
+| GoClaw OpenClaw-compatible gateway | `github.com/roelfdiedericks/goclaw` | cloned `6a7ccdb` |
+
+Primary external references:
+
+| Topic | Source |
+|---|---|
+| Gastown README | https://github.com/gastownhall/gastown/blob/main/README.md |
+| Gastown agent provider integration | https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md |
+| GoClaw agent loop | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/01-agent-loop.md |
+| GoClaw agent teams | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/11-agent-teams.md |
+| GoClaw team WS events | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/13-ws-team-events.md |
+| Paperclip agent runtime | https://github.com/paperclipai/docs/blob/main/agents-runtime.md |
+| Paperclip adapters overview | https://paperclip.inc/docs/adapters/overview/ |
+
+## Short answer
+
+There are four distinct launch/execution models:
+
+| Model | Used by | Essence |
+|---|---|---|
+| External live CLI process | Our Agent Teams | App/orchestrator launches real teammate runtimes and tracks bootstrap, PID, stderr, process health, runtime evidence, task/message state. |
+| Bounded adapter run | Paperclip | A heartbeat or job starts a short agent run, adapter invokes CLI/provider, result is captured, run exits or times out. |
+| Tmux session orchestration | Gastown | `tmux` is the universal runtime adapter. Agents run in terminal sessions, receive input through tmux, and are observed through panes/session state. |
+| In-process agent loop | GoClaw Enterprise / Teams | Agent execution is a Go `Loop.Run(ctx, RunRequest)` scheduled through lanes. The agent is a logical loop inside the gateway, not necessarily a separate CLI teammate process. |
+
+## What “in-process agent loop” removes from our live teammate product
+
+In-process loop does not mean “bad”. It is often cleaner. But compared to our external process teammate model, it removes or changes several product properties.
+
+| Product property | External process teammate, our model | In-process GoClaw-style loop |
+|---|---|---|
+| Real process identity | Each teammate can have PID/RSS/stdout/stderr/process lifetime. | Agent run is a gateway invocation; no independent teammate PID by default. |
+| CLI-realism | Claude/Codex/OpenCode behave as their real CLI runtimes, including auth, prompts, provider errors, stderr quirks. | Provider/driver behavior is normalized inside gateway; fewer raw CLI lifecycle surfaces. |
+| Per-member restart semantics | Restart means kill/relaunch or reattach a concrete runtime for that member. | Restart is usually cancel/reschedule a logical run/session. |
+| Bootstrap evidence | We can distinguish process alive, bootstrap submitted, bootstrap confirmed, delivery proof, task proof. | The loop itself is already the controlled runtime; less need for low-level bootstrap proof. |
+| UI runtime cards | UI can show memory, process state, liveness source, failed/stalled bootstrap, exact runtime diagnostics. | UI tends to show run/session/task status rather than OS/process-level teammate state. |
+| TTY/process debugging | Process/tmux mode can expose raw CLI behavior when needed. | Debugging is gateway traces/events/logs, not a live CLI pane/process per member. |
+| Failure classes | Auth prompt, no stdin, CLI did not submit bootstrap, process died, stale PID, provider CLI stderr. | Mostly provider/tool/session/run errors inside the loop. |
+| Isolation boundary | OS process boundary per teammate. | Mostly logical/session isolation inside one gateway process, unless it delegates to external providers/tools. |
+
+Important distinction: in-process loop is simpler and can be more stable for gateway/chat products. It is not a drop-in replacement for a desktop product whose value includes live external teammate runtimes.
+
+## Our Agent Teams launch/execution model
+
+Our current direction is app-managed live external teammate runtime.
+
+Observed local architecture:
+
+| Layer | Role |
+|---|---|
+| `claude_team` Electron app | UI, provisioning, runtime projection, team messages, tasks, diagnostics, retries. |
+| `agent_teams_orchestrator` | Multi-agent runtime orchestration, teammate spawning, provider/runtime bridging. |
+| Process backend | Default for app-launched teammates after recent changes. Launch-owned processes are tracked as runtime entities. |
+| Optional tmux mode | Debug/manual mode, not production default. Useful for real TTY inspection. |
+| App-managed bootstrap | Backend injects/records startup context and requires durable readiness evidence instead of trusting “process exists”. |
+| Runtime projection | Maps launch state, process liveness, bootstrap proof, delivery proof, task state and diagnostics to UI. |
+
+Key properties:
+
+| Dimension | Current behavior |
+|---|---|
+| Agent lifetime | Long-lived teammate process/session, not just one request. |
+| Availability proof | Process alive is not enough. Need bootstrap/runtime evidence. |
+| Provider mix | Claude, Codex, OpenCode can coexist in one team. |
+| User experience | Live team room: cards, memory, tasks, messages, runtime errors, restart/retry controls. |
+| Complexity cost | High. Many edge cases around launch, cleanup, stale state, delivery, work-sync, retries. |
+
+Technical assessment:
+
+| Criterion | Score |
+|---|---:|
+| Live team product fit | 9.2/10 |
+| Mixed provider fidelity | 8.7/10 |
+| Runtime proof strictness | 8.8/10 |
+| Simplicity | 5.8/10 |
+| Maintainability today | 7.2/10 |
+| Overall technical score | 8.5/10 |
+
+## Paperclip launch/execution model
+
+Paperclip is closest to a bounded job/heartbeat runner.
+
+Research summary from earlier pass:
+
+| Piece | Behavior |
+|---|---|
+| Agent invocation | Heartbeat or scheduled run calls adapter execution. |
+| Runtime | Adapter starts/calls CLI or provider, captures output/status/errors. |
+| Lifecycle | Run exits, times out, or is cancelled. |
+| Concurrency | Wakeups coalesce if agent is already running. |
+| Persistence | Status/logs/tokens/errors are stored per run. |
+
+This is operationally clean because there is no expectation that every teammate is a continuously alive process with card-level runtime state.
+
+Technical assessment:
+
+| Criterion | Score |
+|---|---:|
+| Bounded execution design | 9.1/10 |
+| Simplicity | 8.8/10 |
+| Failure boundedness | 8.7/10 |
+| Live teammate room fit | 6.3/10 |
+| External CLI fidelity | 7.5/10 |
+| Overall technical score | 8.2/10 |
+
+## Gastown launch/execution model
+
+Gastown is tmux-first.
+
+Facts from `gastownhall/gastown`:
+
+| Piece | Behavior |
+|---|---|
+| Main runtime adapter | `tmux` sessions. |
+| Universal integration | Any CLI that runs in terminal can be started and controlled. |
+| Work unit | Beads/issues and convoys. |
+| Worker identity | Polecats have persistent identity and reusable worktrees. |
+| Session lifetime | Sessions are ephemeral; identity and sandbox can persist. |
+| Communication | Mail, nudges, hooks, Beads state, tmux input/output. |
+| Monitoring | Witness, Deacon, Dogs, Doctor, cleanup commands. |
+| Provider integration | Built-in/custom presets with command, args, env, process names, hooks, readiness delay/prompt. |
+
+Gastown explicitly documents a Tier 0 tmux shim: start CLI in tmux, send work through keystrokes, detect liveness through pane process, read output through captured pane. It also notes that this level is timing-sensitive and lacks delivery confirmation.
+
+Core model:
+
+```text
+gt sling
+ -> allocate or reuse polecat identity/worktree
+ -> create tmux session
+ -> set env: GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, GT_AGENT, etc.
+ -> inject startup beacon / prompt / hook context
+ -> nudge with instructions if provider needs fallback
+ -> Witness/Deacon patrol health and cleanup
+```
+
+Technical assessment:
+
+| Criterion | Score |
+|---|---:|
+| Terminal-native ops | 9.0/10 |
+| Persistent worker identity | 8.7/10 |
+| Cleanup / doctor culture | 8.8/10 |
+| Delivery proof strictness | 6.4/10 |
+| Live product state consistency | 6.8/10 |
+| Overall technical score | 8.0/10 |
+
+## GoClaw Enterprise / Teams launch/execution model
+
+This is `nextlevelbuilder/goclaw`, the relevant GoClaw for agent teams.
+
+Core architecture from docs/code:
+
+| Piece | Behavior |
+|---|---|
+| Agent unit | `Loop` configured with provider, model, tools, workspace and agent type. |
+| Run entrypoint | `Loop.Run(ctx, RunRequest)`. |
+| Loop pattern | Think -> Act -> Observe, with max iterations and tool execution. |
+| Scheduler | First-class lane scheduler. |
+| Lanes | `main`, `subagent`, `team`, `cron`. |
+| Queueing | Per-session queues with debounce, drop policy, max concurrent. |
+| Team model | Lead/member, task board, mailbox, delegation. |
+| Task semantics | Atomic claim, status lifecycle, dependencies, blocker escalation, task events. |
+| Events | Typed WS events for delegation, tasks, team messages and agent lifecycle. |
+
+Core execution shape:
+
+```text
+Inbound message / teammate message / cron / delegation
+ -> Scheduler.Schedule(lane, RunRequest)
+ -> SessionQueue serializes or bounds per session
+ -> Lane worker admits execution
+ -> Router.Get(agentID)
+ -> Loop.Run(ctx, req)
+ -> Provider call + tools + finalization
+ -> Events + stored session/task/trace state
+```
+
+GoClaw team member execution is conceptually a scheduled agent run, not an externally spawned teammate CLI process with bootstrap/check-in.
+
+Technical assessment:
+
+| Criterion | Score |
+|---|---:|
+| Scheduler architecture | 9.2/10 |
+| Agent loop clarity | 8.9/10 |
+| Team task model | 8.8/10 |
+| Typed event model | 8.8/10 |
+| Real external teammate runtime fidelity | 6.6/10 |
+| Live process UI fit | 6.5/10 |
+| Overall technical score | 8.7/10 |
+
+## GoClaw OpenClaw-compatible gateway model
+
+This is `roelfdiedericks/goclaw`. It is a different project than `nextlevelbuilder/goclaw`.
+
+High-level facts:
+
+| Piece | Behavior |
+|---|---|
+| Product class | Personal AI gateway / OpenClaw-compatible bot runtime. |
+| Main strengths | Transcript search, memory graph, channels, persistent memory, delegated runs, ACP sessions. |
+| Delegated work | `subagent_spawn`, `subagent_fanout`, `subagent_status`, `subagent_cancel`. |
+| Runner | `DefaultRunner` starts active runs as goroutines with run IDs, timeout/cancel, optional concurrency lane semaphore. |
+| UI/control | `/runners` dashboard, SSE events, Telegram/TUI summaries. |
+| Cursor integration | ACP attachment to live Cursor session. |
+
+Runner shape:
+
+```text
+subagent_spawn / fanout
+ -> DefaultRunner.Start(ctx, RunSpec)
+ -> create RunRecord queued
+ -> goroutine waits for lane admission
+ -> execute function runs child work
+ -> registry records completed/failed/canceled/timeout
+ -> events emitted
+```
+
+This is closer to Paperclip-style delegated bounded runs than to our live teammate process model.
+
+Technical assessment:
+
+| Criterion | Score |
+|---|---:|
+| Personal gateway/memory architecture | 8.8/10 |
+| Delegated run boundedness | 8.5/10 |
+| Channel/memory richness | 9.0/10 |
+| Live external teammate fidelity | 5.8/10 |
+| Team room fit | 6.4/10 |
+| Overall technical score | 8.1/10 |
+
+## Direct comparison table
+
+| System | Launch/execution primitive | Separate OS process per agent? | Long-lived teammate? | Task board | Team messages | Scheduler | Tmux | Best fit |
+|---|---|---:|---:|---:|---:|---:|---:|---|
+| Our Agent Teams | Launch-owned external CLI/process runtime | Yes | Yes | Yes | Yes | Partial/ad-hoc today | Optional/debug | Desktop live mixed-provider team room |
+| Paperclip | Bounded adapter heartbeat run | Usually per run | No | Limited/not central | Not team-room focused | Yes, job-like | No core tmux | Reliable background/job agents |
+| Gastown | Tmux session + worktree + Beads | Yes, through tmux | Session ephemeral, identity persistent | Beads/convoys | Mail/nudges | Scheduler/capacity exists | Core | Terminal-native multi-agent ops |
+| GoClaw Enterprise | In-process scheduled agent loop | Not by default | Logical sessions/runs | Yes | Yes | First-class lanes | No core tmux | Multi-agent gateway/platform |
+| GoClaw OpenClaw-compatible | Delegated goroutine runner + gateway sessions | Not by default | Logical runs/sessions | Not primary team board in same way | Channels | Runner lane semaphore | No core tmux | Personal gateway, memory, delegated runs |
+
+## Honest overall scores
+
+| System | Overall technical score | Why |
+|---|---:|---|
+| GoClaw Enterprise / Teams | 8.7/10 | Cleanest scheduler/team/task/event architecture among compared systems. |
+| Our Agent Teams | 8.5/10 | Best fit for real live external Claude/Codex/OpenCode teammate product, but high complexity. |
+| Paperclip | 8.2/10 | Very clean bounded runtime model, but not a live team-room system. |
+| GoClaw OpenClaw-compatible | 8.1/10 | Strong personal gateway/memory/delegated run model, less comparable to our team runtime. |
+| Gastown | 8.0/10 | Strong terminal ops and lifecycle culture, but tmux-first delivery/readiness is less proof-strict. |
+
+## Research conclusions
+
+The systems optimize for different truths:
+
+| System | Optimized for |
+|---|---|
+| Our Agent Teams | User-visible live team of real external coding agents. |
+| Paperclip | Bounded, simple, resumable background agent runs. |
+| Gastown | Terminal-native agent ops at scale with durable work identity. |
+| GoClaw Enterprise | Clean gateway-native multi-agent scheduling and team task orchestration. |
+| GoClaw OpenClaw-compatible | Long-memory personal agent gateway with delegated subruns. |
+
+Most useful conceptual takeaways for future reference:
+
+| Idea | Source | Why it matters |
+|---|---|---|
+| First-class scheduler lanes | GoClaw Enterprise | Separates main/team/subagent/cron load and makes cancellation/backpressure more deterministic. |
+| Typed team event catalog | GoClaw Enterprise | Makes UI and state transitions easier to reason about. |
+| Persistent identity vs ephemeral session | Gastown | Useful framing for member identity, runtime session, task ownership and cleanup. |
+| Bounded adapter runs | Paperclip | Good model for cron, background checks and non-live workers. |
+| Patrol/doctor cleanup culture | Gastown | Good operational model for stale runtime/process/data cleanup. |
+
+Non-recommendation note: this document intentionally does not propose changing our architecture. It records observed models for future design discussions.
diff --git a/docs/research/paperclip-agent-launch-research-2026-05-07.md b/docs/research/paperclip-agent-launch-research-2026-05-07.md
new file mode 100644
index 00000000..1f03b55e
--- /dev/null
+++ b/docs/research/paperclip-agent-launch-research-2026-05-07.md
@@ -0,0 +1,92 @@
+# Paperclip agent launch research
+
+Research date: 2026-05-07
+
+This note records factual findings only. It is not an implementation plan and does not make recommendations.
+
+## Scope
+
+Compared Paperclip agent launch/runtime behavior with the local Agent Teams orchestrator.
+
+Local orchestrator inspected:
+
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/tools/shared/spawnMultiAgent.ts`
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapRunner.ts`
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/processBackend.ts`
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts`
+
+Paperclip inspected from GitHub and a shallow local clone at `/tmp/paperclip-inspect`.
+
+## Paperclip runtime model
+
+Paperclip agents do not run continuously. They run in heartbeat windows triggered by wakeups such as timer, assignment, on-demand, or automation.
+
+Each heartbeat starts an adapter, gives it prompt/context, lets it run until exit, timeout, or cancellation, stores run status/tokens/errors/logs, and updates UI.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/docs/agents-runtime.md
+
+## Paperclip process execution
+
+Paperclip uses child processes for local adapters. Normal execution is not tmux-based.
+
+The shared process runner uses `node:child_process.spawn`, streams stdout/stderr, records pid and process group information, supports timeout, sends graceful termination, and escalates to kill after a grace period.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapter-utils/src/server-utils.ts
+
+The generic process adapter is documented as executing arbitrary shell commands as child processes with env injection and exit-code based success/failure.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/docs/adapters/process.md
+
+## Paperclip adapter examples
+
+Claude local adapter:
+
+- Uses `claude --print - --output-format stream-json --verbose`.
+- Supports session resume with `--resume`.
+- Supports model/effort/max-turns/append-system-prompt/add-dir style options.
+- Parses stream JSON for terminal result, session id, and usage.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/claude-local/src/server/execute.ts
+
+Codex local adapter:
+
+- Uses `codex exec --json ... -`.
+- Supports session continuation through `resume`.
+- Manages `CODEX_HOME`, injected skills/config/auth context, and fresh-session fallback paths.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/codex-local/src/server/execute.ts
+
+OpenCode local adapter:
+
+- Uses `opencode run --format json`.
+- Supports `--session`, `--model`, and `--variant`.
+- Uses temp config and model/session validation paths.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/opencode-local/src/server/execute.ts
+
+## Paperclip orchestration/lifecycle
+
+Paperclip stores heartbeat runs and events, updates agent runtime state, publishes live events, stores run logs, supports cancellation by pid/process group, and has recovery paths for lost/orphaned running runs.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/heartbeat.ts
+
+It has a per-agent start lock so concurrent starts for the same agent are coalesced or blocked.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/agent-start-lock.ts
+
+It also has run liveness classification/recovery paths for cases like empty or low-signal runs.
+
+Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/run-liveness.ts
+
+## Comparison facts
+
+Paperclip is organized around short, resumable heartbeat runs. It waits for CLI run completion and records result/logs/state.
+
+Agent Teams is organized around live team members, mixed providers, direct messages, tasks, work-sync, runtime evidence, and durable bootstrap/check-in proof.
+
+Paperclip does not need the same live teammate readiness model because it does not maintain a long-running team room with continuously addressable members.
+
+Agent Teams still supports tmux/pane backends in the orchestrator, but current app-launched teammates can use process backend with app-managed runtime evidence.
+
+Paperclip's process lifecycle primitives are more centralized. Agent Teams has more live multi-agent protocol surface and therefore more runtime states to reconcile.
+
diff --git a/docs/screenshots/agent-graph-four-participants-layout-preview.svg b/docs/screenshots/agent-graph-four-participants-layout-preview.svg
new file mode 100644
index 00000000..2a03d844
--- /dev/null
+++ b/docs/screenshots/agent-graph-four-participants-layout-preview.svg
@@ -0,0 +1,189 @@
+
\ No newline at end of file
diff --git a/docs/screenshots/agent-graph-row-orbit-layout-preview.svg b/docs/screenshots/agent-graph-row-orbit-layout-preview.svg
new file mode 100644
index 00000000..4ffbd6d1
--- /dev/null
+++ b/docs/screenshots/agent-graph-row-orbit-layout-preview.svg
@@ -0,0 +1,125 @@
+
diff --git a/docs/team-management/README.md b/docs/team-management/README.md
index 587c378b..3a8d06fb 100644
--- a/docs/team-management/README.md
+++ b/docs/team-management/README.md
@@ -22,6 +22,7 @@
| [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API |
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
+| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, включая `CLAUDE_TEAM_TEAMMATE_MODE=tmux` для pane-backed teammate debug |
## Ключевые решения
diff --git a/docs/team-management/agent-attachments-architecture-plan.md b/docs/team-management/agent-attachments-architecture-plan.md
new file mode 100644
index 00000000..ef868cef
--- /dev/null
+++ b/docs/team-management/agent-attachments-architecture-plan.md
@@ -0,0 +1,2147 @@
+# Agent attachments architecture plan
+
+## Summary
+
+Goal: support screenshots/images and later documents across Claude, Codex, and OpenCode teammates without treating base64 as a universal transport and without destabilizing team launch/runtime delivery.
+
+Chosen architecture: **shared attachment normalization + artifact variants + runtime capability gate + provider-specific delivery adapters**.
+
+🎯 9.1 🛡️ 8.7 🧠 7.0
+Estimated total implementation size: `650-1100` LOC across phased work, excluding broad tests and fixtures.
+
+Risk if implemented in phases: `3/10`.
+Risk if implemented as one big change across all runtimes: `7/10`.
+
+Repos involved:
+
+- `/Users/belief/dev/projects/claude/claude_team`
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+## Live research facts
+
+The design is based on live smoke tests run on May 8-9, 2026.
+
+### Claude
+
+Claude subscription is working for image input in streaming mode.
+
+Confirmed path:
+
+```text
+@anthropic-ai/claude-agent-sdk query({ prompt: async iterable with image block })
+PNG -> red
+JPEG -> red
+```
+
+Important nuance:
+
+```text
+claude -p / single-message mode is not the right validation path for images.
+```
+
+Official Claude Code docs say image uploads are supported in streaming input mode and not in single-message mode. Our team lead process uses long-lived `--input-format stream-json`, so Claude support is viable.
+
+### Codex
+
+Codex subscription image input works through CLI file attachment:
+
+```bash
+codex exec --json --skip-git-repo-check -C /tmp --model gpt-5.4-mini --image red-card-valid.png -
+```
+
+Result:
+
+```text
+red
+```
+
+Therefore Codex native delivery should use optimized image files and `--image `, not inline base64 in prompt text.
+
+### OpenCode
+
+OpenCode OpenAI OAuth image input works:
+
+```bash
+opencode run --pure --format json --dir /tmp --model openai/gpt-5.4-mini "..." -f red-card-valid.png
+```
+
+Result:
+
+```text
+red
+```
+
+OpenCode OpenRouter works when `OPENROUTER_API_KEY` is present:
+
+```text
+openrouter/moonshotai/kimi-k2.6 -> red
+openrouter/z-ai/glm-4.5v -> red
+openrouter/z-ai/glm-5.1 -> model replied it cannot view images
+```
+
+Therefore OpenCode support is not only provider-level. It needs model-level vision capability gating.
+
+## Non-goals
+
+- Do not change team launch/bootstrap semantics.
+- Do not make attachments part of readiness or liveness truth.
+- Do not send base64 blobs as plain text to any model.
+- Do not silently drop attachments for unsupported runtimes.
+- Do not attempt to make every OpenRouter model vision-capable.
+- Do not add a native image processing dependency in Electron main for v1.
+- Do not introduce a new retry loop for attachment failures.
+
+## Core invariants
+
+1. Original user attachment is immutable.
+2. Optimized variants are derived artifacts with deterministic metadata.
+3. Delivery is blocked before send if the selected runtime/model cannot accept the attachment.
+4. Delivery success does not mean model understood the image; it only means the runtime accepted the attachment transport.
+5. A model that says it cannot view an image is a model capability failure, not a transport failure.
+6. The renderer can optimize for UX and payload size, but the backend is the final validation authority.
+7. No runtime adapter should know about React, IPC, or draft UI state.
+8. No renderer code should know filesystem runtime log paths or process internals.
+9. No app shell code should deep-import feature internals.
+10. All user-visible attachment errors must be actionable and specific.
+
+## Why not universal base64
+
+Universal base64 looks simple but is the wrong abstraction.
+
+Claude accepts inline image content blocks in streaming mode.
+Codex accepts image file paths through `--image`.
+OpenCode accepts file parts through `-f` / session message parts.
+OpenRouter models vary by capability.
+
+Sending base64 text to all providers creates false success:
+
+```text
+transport accepted prompt text
+model sees base64 noise
+user thinks image was attached
+agent silently fails to use screenshot
+```
+
+The right abstraction is a normalized attachment plus runtime-specific prepared parts.
+
+## Target feature layout
+
+Medium-sized cross-process feature should follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`.
+
+Recommended home:
+
+```text
+src/features/agent-attachments/
+ contracts/
+ api.ts
+ dto.ts
+ channels.ts
+ core/
+ domain/
+ AttachmentModel.ts
+ AttachmentCapability.ts
+ AttachmentBudget.ts
+ AttachmentDeliveryDecision.ts
+ application/
+ AttachmentNormalizer.ts
+ AttachmentCapabilityResolver.ts
+ AttachmentDeliveryPlanner.ts
+ AttachmentVariantSelector.ts
+ ports.ts
+ main/
+ composition/
+ createAgentAttachmentsFeature.ts
+ adapters/
+ input/
+ ipc/registerAgentAttachmentIpc.ts
+ output/
+ ClaudeStreamJsonAttachmentAdapter.ts
+ CodexNativeAttachmentAdapter.ts
+ OpenCodeAttachmentAdapter.ts
+ infrastructure/
+ AttachmentArtifactStore.ts
+ AttachmentMetadataStore.ts
+ RuntimeModelCapabilityCatalog.ts
+ ServerImageBudgetValidator.ts
+ preload/
+ createAgentAttachmentsBridge.ts
+ renderer/
+ hooks/
+ useAttachmentPreparation.ts
+ useAttachmentCapabilityWarnings.ts
+ ui/
+ AttachmentPreviewList.tsx
+ AttachmentCapabilityNotice.tsx
+ utils/
+ picaImageOptimizer.ts
+```
+
+Do not move existing composer code wholesale in one step. Introduce the feature and connect it gradually.
+
+## Domain model sketch
+
+```ts
+export type AgentAttachmentKind = 'image' | 'document' | 'text' | 'unsupported';
+
+export type AttachmentDataRef =
+ | { kind: 'inline-base64'; base64: string }
+ | { kind: 'artifact-file'; path: string; sha256: string }
+ | { kind: 'text'; text: string };
+
+export interface NormalizedAgentAttachment {
+ id: string;
+ originalName: string;
+ mimeType: string;
+ kind: AgentAttachmentKind;
+ originalBytes: number;
+ originalRef: AttachmentDataRef;
+ variants: AgentAttachmentVariant[];
+ warnings: AttachmentWarning[];
+}
+
+export interface AgentAttachmentVariant {
+ id: string;
+ sourceAttachmentId: string;
+ purpose:
+ | 'preview'
+ | 'claude-inline-image'
+ | 'claude-inline-document'
+ | 'codex-image-file'
+ | 'opencode-file-part';
+ mimeType: string;
+ byteSize: number;
+ width?: number;
+ height?: number;
+ ref: AttachmentDataRef;
+}
+
+export interface AttachmentWarning {
+ code:
+ | 'image_resized'
+ | 'format_converted'
+ | 'animated_gif_not_supported'
+ | 'model_does_not_support_images'
+ | 'attachment_too_large'
+ | 'unknown_runtime_capability';
+ message: string;
+ severity: 'info' | 'warning' | 'error';
+}
+```
+
+## Capability model sketch
+
+```ts
+export type AttachmentRuntimeKind = 'claude-stream-json' | 'codex-native' | 'opencode';
+
+export interface AttachmentRuntimeContext {
+ teamName: string;
+ memberName?: string;
+ providerId: 'anthropic' | 'codex' | 'opencode' | string;
+ modelId: string;
+ runtimeKind: AttachmentRuntimeKind;
+ deliveryTarget: 'lead' | 'member' | 'opencode-secondary';
+}
+
+export interface RuntimeAttachmentCapability {
+ supportsImages: boolean;
+ supportsDocuments: boolean;
+ supportedImageMimeTypes: string[];
+ maxInlineBytes?: number;
+ maxFileBytes?: number;
+ modelCapabilitySource: 'static' | 'catalog' | 'live-probe' | 'unknown';
+ reason?: string;
+}
+
+export interface AttachmentCapabilityDecision {
+ allowed: boolean;
+ warnings: AttachmentWarning[];
+ blockers: AttachmentWarning[];
+ capability: RuntimeAttachmentCapability;
+}
+```
+
+## Delivery planner sketch
+
+```ts
+export interface PreparedAttachmentPart {
+ runtimeKind: AttachmentRuntimeKind;
+ attachmentId: string;
+ part:
+ | { kind: 'claude-content-block'; value: Record }
+ | { kind: 'codex-image-arg'; path: string }
+ | { kind: 'opencode-file-part'; value: Record };
+ diagnostics: string[];
+}
+
+export interface AttachmentDeliveryAdapter {
+ runtimeKind: AttachmentRuntimeKind;
+ canDeliver(
+ ctx: AttachmentRuntimeContext,
+ attachment: NormalizedAgentAttachment,
+ ): AttachmentCapabilityDecision;
+ prepare(
+ ctx: AttachmentRuntimeContext,
+ attachment: NormalizedAgentAttachment,
+ ): Promise;
+}
+
+export class AttachmentDeliveryPlanner {
+ constructor(private readonly adapters: AttachmentDeliveryAdapter[]) {}
+
+ async prepareAll(
+ ctx: AttachmentRuntimeContext,
+ attachments: NormalizedAgentAttachment[],
+ ): Promise {
+ const adapter = this.adapters.find(candidate => candidate.runtimeKind === ctx.runtimeKind);
+ if (!adapter) {
+ throw new Error(`Attachments are not supported for runtime ${ctx.runtimeKind}`);
+ }
+
+ const prepared: PreparedAttachmentPart[] = [];
+ for (const attachment of attachments) {
+ const decision = adapter.canDeliver(ctx, attachment);
+ if (!decision.allowed) {
+ throw new Error(decision.blockers.map(blocker => blocker.message).join('\n'));
+ }
+ prepared.push(await adapter.prepare(ctx, attachment));
+ }
+ return prepared;
+ }
+}
+```
+
+## Phase map
+
+### Phase 1 - normalization, image optimization, budgets, and UI warnings
+
+🎯 9.4 🛡️ 9.3 🧠 5.8
+Estimated change size: `260-420` LOC.
+
+Create feature skeleton, normalize attachments, optimize images with `pica@9.0.1`, enforce hard server-side budgets, and show capability/budget warnings. Do not change provider delivery paths yet except to fail oversized images earlier.
+
+Plan file:
+
+```text
+docs/team-management/agent-attachments-phase-1-normalization-and-budgets-plan.md
+```
+
+### Phase 2 - Claude stream-json adapter
+
+🎯 9.0 🛡️ 8.8 🧠 5.8
+Estimated change size: `180-320` LOC.
+
+Route existing Claude lead attachments through the new planner while preserving current content block semantics. This removes ad-hoc attachment serialization from `TeamProvisioningService` without changing launch.
+
+Plan file:
+
+```text
+docs/team-management/agent-attachments-phase-2-claude-stream-json-plan.md
+```
+
+### Phase 3 - Codex native image adapter
+
+🎯 8.6 🛡️ 8.4 🧠 6.6
+Estimated change size: `260-440` LOC across two repos.
+
+Write optimized image artifacts and pass them to Codex native via `--image `. Extend Codex native exec input from text-only to text plus image paths.
+
+Plan file:
+
+```text
+docs/team-management/agent-attachments-phase-3-codex-native-plan.md
+```
+
+### Phase 4 - OpenCode file parts and model vision gate
+
+🎯 8.3 🛡️ 8.0 🧠 7.2
+Estimated change size: `320-560` LOC across two repos.
+
+Support OpenCode file parts and model capability gating. Block text-only models like `openrouter/z-ai/glm-5.1` before send. Allow vision models like Kimi K2.6 and GLM 4.5V.
+
+Plan file:
+
+```text
+docs/team-management/agent-attachments-phase-4-opencode-vision-plan.md
+```
+
+### Phase 5 - cross-runtime E2E, diagnostics, docs, and polish
+
+🎯 8.8 🛡️ 8.7 🧠 5.4
+Estimated change size: `180-320` LOC plus tests/docs.
+
+Add live e2e scripts, UI copy, diagnostics, and documentation. Keep this separate to avoid mixing correctness changes with polish.
+
+Plan file:
+
+```text
+docs/team-management/agent-attachments-phase-5-e2e-and-polish-plan.md
+```
+
+## Shared testing strategy
+
+Use three levels of tests.
+
+### Unit tests
+
+- image optimizer budget decisions;
+- capability resolver decisions;
+- adapter serialization output;
+- artifact idempotency;
+- redaction of secrets in diagnostics.
+
+### Fixture integration tests
+
+- renderer attachment preview and warnings;
+- IPC validation rejects oversized or unsupported attachments;
+- planner blocks unsupported runtime/model;
+- delivery paths produce correct content parts without live provider calls.
+
+### Live e2e smoke tests
+
+Run only when explicitly requested or behind live test env.
+
+Live models already validated manually:
+
+```text
+Claude subscription PNG/JPEG -> red
+Codex gpt-5.4-mini PNG -> red
+OpenCode openai/gpt-5.4-mini PNG -> red
+OpenCode openrouter/moonshotai/kimi-k2.6 PNG -> red
+OpenCode openrouter/z-ai/glm-4.5v PNG -> red
+OpenCode openrouter/z-ai/glm-5.1 PNG -> text-only refusal
+```
+
+## Release safety
+
+Default rollout order:
+
+1. Land Phase 1 alone.
+2. Verify no regressions in text-only sends.
+3. Land Phase 2 for Claude only.
+4. Land Phase 3 Codex after focused native exec tests.
+5. Land Phase 4 OpenCode after model capability tests.
+6. Land Phase 5 e2e/polish.
+
+Do not bundle all phases into one release commit.
+
+## Main bug risks and mitigations
+
+| Risk | Impact | Mitigation |
+|---|---:|---|
+| Oversized image crashes or kills lead process | High | renderer optimization + backend serialized budget |
+| Unsupported model silently ignores image | High | capability gate blocks before send |
+| Base64 leaked into prompt text | Medium | adapters never produce plain text base64 |
+| Retry loses attachment artifact | Medium | artifact store rebuilds from original or fails loudly |
+| OpenCode model catalog changes | Medium | static curated map plus explicit unknown capability state |
+| Cross-process API becomes too broad | Medium | feature contracts expose only DTOs and use cases |
+| Existing Claude path regresses | Medium | Phase 2 keeps exact content block semantics and tests current behavior |
+
+## Decision record
+
+Use `pica@9.0.1` in renderer for high-quality browser image resizing.
+
+Do not use `sharp` in Electron main for this phase because native packaging risk is not worth it before release.
+
+Do not use `@squoosh/lib` because it is stale and heavier operationally.
+
+Do not use text dedupe or model-specific prompt hacks for attachments.
+
+Do not treat OpenCode provider support as model support.
+
+## Deep implementation guardrails
+
+This section tightens the plan after reviewing the first draft. The most important correction is that the attachment feature must not become a generic utility imported everywhere. It should be a feature with a small public facade and strict contracts. Provider-specific code should depend on feature ports, not on renderer utilities or raw DTOs.
+
+### Ownership boundaries
+
+| Layer | Owns | Must not own |
+|---|---|---|
+| Renderer | user preview, local optimization attempt, UI warnings | final safety decision, filesystem artifact paths, runtime args |
+| Main feature application | normalization policy, budget policy, delivery planning | direct process spawning, React state, OpenCode/Codex implementation details |
+| Main infrastructure | artifact store, filesystem reads/writes, byte validation | provider business rules |
+| Runtime adapters | provider-specific serialization | UI messages, attachment optimization algorithm |
+| TeamProvisioningService | send orchestration and runtime state | image resizing, model capability catalog, base64 parsing details |
+| Orchestrator | actual Codex/OpenCode runtime bridge | desktop UI validation, user-facing attachment UX |
+
+Concrete rule:
+
+```ts
+// Good: app service asks a feature facade to prepare parts.
+const prepared = await this.agentAttachments.prepareForRuntime(ctx, attachments);
+
+// Bad: team service knows provider-specific conversion details.
+const jpeg = await picaResizeInTeamProvisioningService(...);
+const codexArgs = ['--image', jpeg.path];
+```
+
+### Dependency direction
+
+```text
+renderer UI -> feature renderer hooks -> contracts
+main IPC -> feature application -> domain -> ports
+main composition -> infrastructure/adapters
+team services -> feature facade only
+runtime provider adapters -> feature contracts only
+```
+
+No circular dependency should exist between:
+
+```text
+TeamProvisioningService <-> agent-attachments internals
+OpenCodePromptDeliveryLedger <-> agent-attachments internals
+CodexNativeTurnExecutor <-> desktop renderer contracts
+```
+
+### Correct source of truth per decision
+
+| Decision | Source of truth |
+|---|---|
+| Is the file selected by the user? | renderer draft state |
+| Is the file safe to upload? | backend validator |
+| Is the image optimized enough? | attachment budget policy |
+| Can the runtime accept the transport? | provider adapter capability |
+| Can the model interpret images? | model capability catalog/probe |
+| Did the agent answer? | existing delivery proof gates |
+| Is teammate alive/ready? | existing runtime/bootstrap proof |
+
+Do not collapse these into one boolean like `supportsAttachments`.
+
+### Cross-runtime delivery matrix
+
+| Runtime | Transport | Transport proof | Model understanding proof |
+|---|---|---|---|
+| Claude stream-json | content block `{ type: 'image', source: base64 }` | stdin write accepted, no immediate CLI schema error | normal assistant response |
+| Codex native | `codex exec --image ` | process spawned with image path, no CLI arg error | normal Codex response |
+| OpenCode | session file part / CLI `-f` equivalent | OpenCode accepted message part | existing OpenCode response proof |
+| OpenCode OpenRouter text-only model | file part may be accepted | transport may succeed | model may say it cannot view image, so capability gate should prevent send |
+
+The subtle case is OpenCode/OpenRouter: transport can succeed while the model is text-only. That must be represented as capability failure before send.
+
+### Error taxonomy
+
+Use typed errors internally. Do not parse English UI messages later.
+
+```ts
+export type AttachmentFailureCode =
+ | 'attachment_too_large_original'
+ | 'attachment_too_large_optimized'
+ | 'attachment_serialized_payload_too_large'
+ | 'attachment_unsupported_mime'
+ | 'attachment_corrupt_image'
+ | 'attachment_runtime_unsupported'
+ | 'attachment_model_vision_unsupported'
+ | 'attachment_model_vision_unknown'
+ | 'attachment_artifact_missing'
+ | 'attachment_artifact_write_failed'
+ | 'attachment_provider_auth_required'
+ | 'attachment_provider_quota_exceeded';
+
+export interface AttachmentFailure {
+ code: AttachmentFailureCode;
+ severity: 'warning' | 'error';
+ userMessage: string;
+ diagnostic: string;
+ retryable: boolean;
+}
+```
+
+UI should render `userMessage`. Logs/copy diagnostics may include `diagnostic` after redaction.
+
+### Idempotency requirements
+
+Attachment artifacts need stable identity. Repeated sends/retries/watchdog runs must not create unbounded duplicate files.
+
+Recommended id:
+
+```ts
+const attachmentId = sha256([
+ teamName,
+ originalMessageId,
+ filename,
+ mimeType,
+ originalBytes,
+ originalSha256,
+].join('\0')).slice(0, 24);
+```
+
+Variant id:
+
+```ts
+const variantId = sha256([
+ attachmentId,
+ purpose,
+ outputMimeType,
+ width,
+ height,
+ byteSize,
+ optimizerVersion,
+].join('\0')).slice(0, 24);
+```
+
+This prevents retry storms from creating multiple equivalent optimized copies.
+
+### Backward compatibility
+
+Current renderer payload shape is still:
+
+```ts
+{ data: string; mimeType: string; filename?: string }
+```
+
+Do not break this in Phase 1. Instead normalize at the boundary:
+
+```ts
+const normalized = await agentAttachments.normalizeLegacyPayloads(legacyAttachments);
+```
+
+Only later introduce a richer DTO if needed. Existing IPC clients and tests should continue to work until explicitly migrated.
+
+### What not to do
+
+- Do not pass base64 as a textual paragraph to Codex/OpenCode.
+- Do not auto-convert PDFs to images in v1.
+- Do not mark delivery success because an attachment was accepted.
+- Do not infer OpenCode vision support from provider id alone.
+- Do not run live model probes on every send.
+- Do not store API keys in artifact metadata.
+- Do not log image base64 or data URLs.
+- Do not delete Codex image files immediately after process spawn.
+- Do not make unknown model capability permissive by default before release.
+
+### Suggested implementation order inside each phase
+
+1. Add pure domain/application types and tests.
+2. Add infrastructure behind ports.
+3. Add adapter tests with fake artifacts.
+4. Wire into one call site.
+5. Add UI copy or diagnostics.
+6. Run focused tests.
+7. Only then expand to the next runtime.
+
+### Minimal safe rollback strategy
+
+Each phase should be revertable independently.
+
+- Phase 1 rollback: disable new validator facade and keep current attachmentUtils path.
+- Phase 2 rollback: switch Claude `sendMessageToRun()` back to old content-block builder.
+- Phase 3 rollback: keep Codex text-only guard and block image attachments.
+- Phase 4 rollback: restore OpenCode `opencode_attachments_not_supported_for_secondary_runtime` block.
+- Phase 5 rollback: remove smoke/docs only.
+
+Do not make a database migration mandatory for Phase 1-4.
+
+## Additional edge-case matrix
+
+| Edge case | Expected behavior | Reason |
+|---|---|---|
+| User attaches image then switches recipient to unsupported OpenCode model | Composer warning changes and send is blocked | capability belongs to current target |
+| User sends while team goes offline | Existing offline/send guard wins; attachment path does not queue fake delivery | avoid confusing offline queue |
+| Attachment optimization succeeds but artifact write fails | Block send with retryable local error | runtime never saw file |
+| Artifact exists but checksum mismatch | Rebuild from original if possible, otherwise block | avoid corrupted screenshots |
+| Original missing but optimized variant present | Allow only if variant checksum is valid and policy permits | useful for old drafts but risky, log diagnostic |
+| Multiple recipients have mixed capability | Block and explain unsupported recipients, unless product explicitly supports per-recipient partial send | avoid silent partial delivery |
+| User includes a text file and images | Claude may support text document, Codex/OpenCode v1 may block non-image | runtime-specific adapters decide |
+| OpenRouter key missing | Provider auth error, not attachment bug | clear setup path |
+| OpenRouter quota exceeded | Preserve provider exact error | user needs credits/key change |
+| Model returns “I cannot see images” despite catalog says supported | mark delivery responded, surface model capability diagnostic, update catalog later | do not convert response into transport crash |
+
+## Phase readiness gates
+
+Before implementing any phase, the phase must pass these gates on paper.
+
+### Gate A - Contract clarity
+
+Every new public type must answer:
+
+- who creates it;
+- who consumes it;
+- whether it crosses IPC/preload;
+- whether it can contain base64;
+- whether it can contain filesystem paths;
+- whether it is safe to log.
+
+If a type contains base64 or filesystem paths, it must not be exposed broadly to renderer UI or copied diagnostics.
+
+### Gate B - Runtime isolation
+
+A phase is not ready if implementation requires touching all three runtime providers at once.
+
+Good phase boundary:
+
+```text
+Phase 2 touches Claude stream-json only.
+Phase 3 touches Codex native only.
+Phase 4 touches OpenCode only.
+```
+
+Bad phase boundary:
+
+```text
+Add attachments everywhere and fix broken cases later.
+```
+
+### Gate C - Rollback clarity
+
+Each phase must have a single revert path:
+
+```text
+remove feature facade call -> restore previous behavior
+```
+
+If rollback requires data migration cleanup, the phase is too large.
+
+### Gate D - No readiness coupling
+
+Attachment delivery must never influence:
+
+- `confirmed_alive`;
+- `bootstrapConfirmed`;
+- `runtimeAlive`;
+- `launchState`;
+- `member_work_sync` status.
+
+The only allowed interactions are message delivery validation and diagnostics.
+
+## Review checklist for implementation PRs
+
+Use this checklist in code review.
+
+- New code does not parse provider errors with regex for core behavior.
+- Runtime-specific serialization lives in an adapter, not in UI or `TeamProvisioningService`.
+- The backend validates size even if renderer already optimized.
+- Unsupported runtime/model blocks before send.
+- Text-only sends use the old behavior path or equivalent no-op path.
+- No base64/data URL in logs, notifications, copied diagnostics, or thrown Error messages.
+- OpenCode attachment accepted does not mark ledger delivered without response proof.
+- Codex image file path comes from app artifact store, not renderer input.
+- Claude stream-json payload budget is checked before `stdin.write`.
+- Tests include negative cases, not only happy path.
+
+## Suggested shared code comments
+
+Some comments are valuable because this feature has non-obvious transport differences.
+
+```ts
+// Attachments are normalized once, but delivery is runtime-specific.
+// Do not send base64 as plain prompt text. Codex expects image files,
+// Claude expects content blocks, and OpenCode expects file parts.
+```
+
+```ts
+// Model vision support is separate from OpenCode file-part transport support.
+// Some OpenRouter models accept the prompt but cannot interpret images.
+```
+
+```ts
+// Attachment transport acceptance is not delivery proof. OpenCode still needs
+// the existing visible reply / relay / work-sync proof gates.
+```
+
+## Data lifecycle
+
+```mermaid
+flowchart TD
+ A["User selects files"] --> B["Renderer draft attachment"]
+ B --> C["Optional pica optimization"]
+ C --> D["IPC send request"]
+ D --> E["Backend validation"]
+ E --> F["Normalize legacy payload"]
+ F --> G["Capability gate"]
+ G --> H["Prepare runtime parts"]
+ H --> I["Runtime send"]
+ I --> J["Existing response proof"]
+```
+
+Key rule: `J` is existing delivery proof, not a new attachment proof.
+
+## Failure lifecycle
+
+```mermaid
+flowchart TD
+ A["Attachment selected"] --> B{"Can decode?"}
+ B -->|"no"| C["Block: corrupt/unsupported"]
+ B -->|"yes"| D{"Fits budget after optimization?"}
+ D -->|"no"| E["Block: too large"]
+ D -->|"yes"| F{"Runtime supports transport?"}
+ F -->|"no"| G["Block: runtime unsupported"]
+ F -->|"yes"| H{"Model supports vision?"}
+ H -->|"no/unknown"| I["Block: model unsupported/unknown"]
+ H -->|"yes"| J["Prepare provider-specific parts"]
+```
+
+## Compatibility with current codebase
+
+Current code has multiple attachment entry points:
+
+```text
+renderer attachmentUtils
+renderer useComposerDraft
+main IPC validateAttachments
+TeamProvisioningService.sendMessageToRun
+OpenCode secondary delivery block
+Codex native text-only guard
+```
+
+The safe strategy is not to delete these immediately. Wrap and replace one boundary at a time.
+
+### Compatibility adapter
+
+```ts
+export interface LegacyTeamMessageAttachment {
+ data: string;
+ mimeType: string;
+ filename?: string;
+}
+
+export async function normalizeLegacyTeamMessageAttachments(
+ attachments: LegacyTeamMessageAttachment[] | undefined,
+): Promise {
+ if (!attachments?.length) return [];
+ return attachments.map(normalizeLegacyTeamMessageAttachment);
+}
+```
+
+This lets existing call sites pass their current shape while the new feature owns policy.
+
+## Implementation anti-patterns to reject
+
+### Anti-pattern 1 - runtime switch in TeamProvisioningService
+
+```ts
+if (provider === 'codex') {
+ // build --image
+} else if (provider === 'opencode') {
+ // build file part
+}
+```
+
+Reject this. It breaks SRP and makes future providers risky.
+
+### Anti-pattern 2 - capability by provider only
+
+```ts
+if (provider === 'openrouter') supportsImages = true;
+```
+
+Reject this. GLM 5.1 proved provider support is not model support.
+
+### Anti-pattern 3 - best-effort partial send
+
+```ts
+const supported = attachments.filter(canSend);
+send(supported);
+```
+
+Reject this unless product explicitly designs partial delivery UI. Silent partial sends are dangerous.
+
+### Anti-pattern 4 - live probe on every send
+
+```ts
+await opencode.runProbe(modelId, tinyImage);
+```
+
+Reject this. It adds latency, cost, auth failure modes, and quota usage to normal send.
+
+## Confidence summary after deeper review
+
+- Phase 1 risk: `2/10` because it is mostly validation/optimization and can block unsafe sends.
+- Phase 2 risk: `3/10` because it preserves Claude content block shape.
+- Phase 3 risk: `4/10` because it crosses repo boundary and changes Codex exec args.
+- Phase 4 risk: `5/10` because OpenCode/OpenRouter model capabilities are dynamic.
+- Phase 5 risk: `2/10` because it is mostly tooling/docs/diagnostics.
+
+Overall phased risk remains `3/10` if phases are landed separately.
+
+## Implementation governance v2
+
+This section exists to prevent the most likely failure mode: a correct design implemented as a broad, risky refactor.
+
+### One-phase-at-a-time rule
+
+Only one runtime delivery path may be changed per implementation phase.
+
+Allowed:
+
+```text
+Phase 2 changes Claude stream-json only.
+```
+
+Not allowed:
+
+```text
+Phase 2 also sneaks in Codex image args because the abstraction is nearby.
+```
+
+If a phase needs code in both repos, the cross-repo contract must be documented in that phase and tested with fixtures before any live e2e.
+
+### Exit criteria for every phase
+
+A phase is not complete until these are true:
+
+- text-only message path is unchanged or equivalently tested;
+- unsupported attachment fails before runtime call;
+- copied diagnostics contain no base64/data URL/secrets;
+- error message is user-actionable;
+- rollback is one commit revert or one facade switch;
+- no attachment state is used as teammate readiness/liveness proof.
+
+### Attachment feature facade
+
+Expose a small facade to app shell code.
+
+```ts
+export interface AgentAttachmentsFeatureFacade {
+ validateLegacyPayloadsForSend(input: ValidateLegacyPayloadsInput): Promise;
+ normalizeLegacyPayloads(input: LegacyTeamMessageAttachment[]): Promise;
+ prepareForRuntime(
+ ctx: AttachmentRuntimeContext,
+ attachments: NormalizedAgentAttachment[],
+ ): Promise;
+ describeCapability(
+ ctx: AttachmentRuntimeContext,
+ attachments: NormalizedAgentAttachment[],
+ ): AttachmentCapabilitySummary;
+}
+```
+
+App shell should not import adapter classes directly.
+
+### Prepared part exhaustiveness
+
+Every runtime integration must exhaustively switch on prepared part kind.
+
+```ts
+function assertNever(value: never): never {
+ throw new Error(`Unhandled attachment part kind: ${JSON.stringify(value)}`);
+}
+
+for (const prepared of parts) {
+ switch (prepared.part.kind) {
+ case 'claude-content-block':
+ contentBlocks.push(prepared.part.value);
+ break;
+ default:
+ assertNever(prepared.part);
+ }
+}
+```
+
+This prevents accidentally passing a Codex/OpenCode prepared part into Claude.
+
+### Unknown capability policy
+
+Before release, unknown means blocked for binary/image attachments.
+
+```ts
+if (capability.kind === 'unknown') {
+ return block({
+ code: 'attachment_model_vision_unknown',
+ userMessage: `Image input support for ${displayModelName(modelId)} is not verified. Choose a verified vision model.`,
+ });
+}
+```
+
+Do not downgrade unknown to warning until there is explicit product UI for “send anyway”.
+
+### Diagnostic redaction contract
+
+Any feature diagnostic must pass through a single redactor.
+
+```ts
+export function redactAttachmentDiagnostic(input: string): string {
+ return input
+ .replace(/data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[REDACTED];base64,[REDACTED]')
+ .replace(/sk-or-v1-[A-Za-z0-9_-]+/g, 'sk-or-v1-[REDACTED]')
+ .replace(/sk-ant-[A-Za-z0-9_-]+/g, 'sk-ant-[REDACTED]')
+ .replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY)=\S+/g, '$1=[REDACTED]')
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [REDACTED]');
+}
+```
+
+### Observability fields
+
+Safe fields to log:
+
+```ts
+{
+ attachmentCount: 2,
+ kinds: ['image'],
+ optimizedBytes: 812345,
+ estimatedSerializedBytes: 1092345,
+ runtimeKind: 'opencode',
+ modelId: 'openrouter/moonshotai/kimi-k2.6',
+ capability: 'supported',
+}
+```
+
+Unsafe fields:
+
+```ts
+{
+ base64: '...',
+ dataUrl: '...',
+ apiKey: '...',
+ rawPayload: '...',
+}
+```
+
+### Test matrix by responsibility
+
+| Responsibility | Unit | Integration | Live |
+|---|---|---|---|
+| budget estimate | required | required | no |
+| pica optimization | required | renderer required | no |
+| Claude content block | required | service required | optional |
+| Codex args | required | orchestrator required | optional |
+| OpenCode file part | required | bridge required | optional |
+| model capability | required | renderer/backend required | optional |
+| provider auth errors | fixture | service required | optional |
+
+## Full edge-case backlog
+
+These are intentionally broad. Not all need implementation in v1, but each should have an explicit decision.
+
+| Area | Edge case | Decision for v1 |
+|---|---|---|
+| image format | HEIC/AVIF | block with clear unsupported message |
+| image format | SVG | block, no rasterization in v1 |
+| image format | animated GIF | keep only if small, otherwise block |
+| image format | transparent PNG | preserve alpha, no silent JPEG |
+| image quality | tiny text screenshot | prefer higher quality, block instead of unreadable compression |
+| size | compressed small but huge dimensions | block by megapixels |
+| size | many small images | total serialized budget wins |
+| draft | optimization completes after recipient switch | discard stale result |
+| draft | app restart with old base64 draft | revalidate/re-optimize on send |
+| artifact | checksum mismatch | rewrite from original or block |
+| artifact | cleanup while retry pending | retry rebuilds or fails loudly |
+| runtime | team offline | existing offline error wins |
+| runtime | lead busy | existing delivery semantics win |
+| runtime | provider auth expired | exact provider error wins |
+| OpenCode | model accepts file but refuses vision | capability catalog update, no transport blame |
+| Codex | image path missing | pre-spawn local error |
+| Claude | API says image invalid | provider/runtime error, not optimizer error |
+
+## Execution Protocol for Implementation
+
+This project should be implemented as a sequence of narrow commits. Each commit must preserve all existing non-attachment behavior.
+
+Recommended commit sequence:
+
+1. `feat(attachments): add normalized attachment domain`
+2. `feat(attachments): add renderer image optimization`
+3. `feat(attachments): route claude images through stream-json blocks`
+4. `feat(attachments): pass codex images through native image args`
+5. `feat(attachments): pass opencode vision images through file parts`
+6. `test(attachments): add cross-provider image delivery e2e`
+
+Rules during implementation:
+
+- Do not mix launch/runtime stabilization changes into attachment commits.
+- Do not change bootstrap, member readiness, process backend, tmux fallback, or work-sync semantics.
+- Do not add a native image processing dependency to Electron main in Phase 1.
+- Do not introduce provider-specific image normalization logic in renderer.
+- Do not trust renderer-computed budget/capability decisions as final authority.
+- Do not log attachment base64, OAuth tokens, API keys, absolute temp artifact content, or full prompt payloads.
+
+## Cross-Phase Compatibility Contract
+
+All phases should agree on the same normalized attachment contract. This prevents Phase 2/3/4 from each inventing a slightly different shape.
+
+```ts
+export interface AgentAttachmentPayload {
+ id: string;
+ originalName: string;
+ mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'application/pdf' | 'text/plain' | string;
+ sizeBytes: number;
+ kind: 'image' | 'file';
+ source: 'composer' | 'clipboard' | 'drag-drop' | 'task' | 'inbox';
+ storage: {
+ originalPath?: string;
+ optimizedPath?: string;
+ thumbnailPath?: string;
+ };
+ image?: {
+ width?: number;
+ height?: number;
+ animated?: boolean;
+ optimizedWidth?: number;
+ optimizedHeight?: number;
+ optimization: 'none' | 'lossless' | 'resized' | 'jpeg-reencoded' | 'unsupported';
+ };
+ warnings: string[];
+}
+```
+
+Compatibility rules:
+
+- New fields must be optional until every consumer is migrated.
+- Existing persisted message attachments must continue rendering even if they lack normalized metadata.
+- Provider adapters must accept both freshly normalized attachments and old persisted attachments after backend hydration.
+- Missing optimized variant must not crash delivery. It should fall back to original only if the provider budget allows it.
+- Missing original path must fail visibly for delivery, but must not corrupt the inbox record.
+
+## Provider Adapter Boundary
+
+Use a single boundary where provider-specific logic begins.
+
+```ts
+export interface AttachmentDeliveryAdapter {
+ readonly providerId: 'anthropic' | 'codex' | 'opencode';
+
+ canDeliverAttachment(input: {
+ model: string;
+ attachment: AgentAttachmentPayload;
+ capability: AgentAttachmentCapability;
+ }): AttachmentDeliveryDecision;
+
+ buildDeliveryParts(input: {
+ text: string;
+ attachments: AgentAttachmentPayload[];
+ budget: AgentAttachmentBudget;
+ }): Promise;
+}
+```
+
+Architecture intent:
+
+- Renderer owns user experience and local image optimization.
+- Main process owns validation, redaction, budget enforcement, and persistence.
+- Provider adapters own transport-specific serialization.
+- Orchestrator owns CLI/runtime invocation details where those details are outside Electron.
+
+This keeps SOLID boundaries clear:
+
+- Single Responsibility: normalization, capability, and transport are separate modules.
+- Open-Closed: adding a new provider should add an adapter, not mutate every call site.
+- Interface Segregation: UI code should not know about `--image`, OpenCode `-f`, or Claude stream-json details.
+- Dependency Inversion: delivery flow depends on adapter interfaces, not concrete provider branches.
+
+## Invariants and Enforcement Points
+
+| Invariant | Enforced in renderer | Enforced in main | Enforced in adapter | Notes |
+|---|---:|---:|---:|---|
+| No raw base64 text in prompt | yes | yes | yes | Base64 may exist in provider-native SDK block only when required. |
+| Original attachment remains immutable | yes | yes | no | Optimized files are derived artifacts. |
+| User sees unsupported-model warning before send | yes | yes | no | Backend still blocks if renderer is stale. |
+| Attachments do not change launch readiness | no | yes | yes | Delivery errors are message-level, not bootstrap truth. |
+| Provider limits are bounded | yes | yes | yes | Renderer preflight is UX, backend is authority. |
+| Secrets are never copied into diagnostics | no | yes | yes | Provider stderr tails must be redacted. |
+| File paths are never supplied by renderer unchecked | no | yes | no | Backend resolves managed artifact ids to paths. |
+
+## Failure Taxonomy
+
+Attachment failures should be separated from runtime failures.
+
+```ts
+type AttachmentDeliveryFailureCode =
+ | 'attachment_too_large'
+ | 'attachment_type_unsupported'
+ | 'attachment_model_unsupported'
+ | 'attachment_optimization_failed'
+ | 'attachment_artifact_missing'
+ | 'attachment_provider_rejected'
+ | 'attachment_runtime_transport_failed';
+```
+
+Mapping rules:
+
+- `attachment_model_unsupported`: show before send when possible. Do not start delivery.
+- `attachment_too_large`: show before send when possible. Do not start delivery.
+- `attachment_artifact_missing`: message stays saved, delivery fails actionable, no team offline state.
+- `attachment_provider_rejected`: delivery failure with exact redacted provider error.
+- `attachment_runtime_transport_failed`: delivery failure, not teammate spawn failure unless runtime actually exits.
+
+## Release Risk Budget
+
+Overall implementation risk if all phases are done at once: `6/10`.
+
+Recommended risk after phased implementation: `3/10`.
+
+Why phased risk is lower:
+
+- Phase 1 is UI/domain-only and can ship disabled from provider paths until Phase 2.
+- Claude, Codex, and OpenCode adapters can be validated independently.
+- E2E can prove each provider before enabling broad UX expectations.
+- Rollback of a provider adapter does not require removing normalization.
+
+## Whole-Project Definition of Done
+
+The attachment architecture should be considered done only when all of these are true:
+
+- A user can attach a large screenshot and see deterministic resize/compression feedback before send.
+- Claude subscription path receives the image as a real image and answers a visual question.
+- Codex subscription path receives the image as a real image and answers a visual question.
+- OpenCode OpenAI vision-capable model receives the image as a real image and answers a visual question.
+- OpenCode OpenRouter vision-capable model receives the image as a real image and answers a visual question.
+- OpenCode non-vision model is blocked or clearly warned, not allowed to silently hallucinate.
+- Multiple images are budgeted deterministically.
+- Message delivery failure does not mark the teammate offline unless the teammate runtime actually dies.
+- Diagnostics explain whether the problem is size, type, model capability, provider rejection, or runtime crash.
+- Existing text-only messages, task delegation, work-sync, and bootstrap flows behave exactly as before.
+
+
+## Deep Hardening Addendum
+
+### Top 3 architectural options considered
+
+1. Provider-native adapters with shared normalization - 🎯 9.4 🛡️ 9.3 🧠 5.5, примерно `900-1500` строк across all phases.
+
+ This is the selected approach. The shared layer owns optimization, budgets, capability checks, and diagnostics. Each provider adapter only knows how to serialize already-normalized artifacts into its native transport.
+
+ Why this is safest:
+
+ - No fake universal base64 prompt format.
+ - Each provider receives images through a mechanism already proven in prototypes.
+ - Attachment support can be rolled out provider-by-provider.
+ - Text-only behavior remains unchanged.
+
+2. Universal base64-in-text fallback - 🎯 4.5 🛡️ 3 🧠 3, примерно `250-450` строк.
+
+ This looks simple, but it is the wrong abstraction. It bloats prompts, breaks stdin/process limits, degrades model understanding, and makes provider behavior unpredictable. It also risks causing exactly the kind of runtime crashes we have been trying to stabilize.
+
+3. Fully external artifact URL service - 🎯 7 🛡️ 7.5 🧠 8.5, примерно `1600-2600` строк.
+
+ This may be good long-term for remote/cloud agents, but it is too much for the current release. It introduces upload lifecycle, access control, expiry, privacy, and offline-mode concerns.
+
+### Global go/no-go gates
+
+Do not implement the next phase until the previous phase satisfies its gate.
+
+| Gate | Required before |
+|---|---|
+| Normalized attachment type is stable and tested | Claude/Codex/OpenCode provider work |
+| Renderer optimizer does not mutate existing composer behavior | Any provider delivery wiring |
+| Backend can reject forged attachment metadata | Any real provider send |
+| Claude image smoke passes | Shipping Claude support |
+| Codex image smoke passes | Shipping Codex support |
+| OpenCode model capability matrix is explicit | Shipping OpenCode support |
+| Negative unsupported-model UX is clear | Any broad release |
+
+### Migration seams to keep stable
+
+These seams should become small stable boundaries. Do not leak implementation details across them.
+
+```ts
+// Renderer boundary
+prepareComposerAttachmentsForSend(files, target): Promise;
+
+// Main boundary
+hydrateAndValidateAgentAttachments(messageDraft, target): Promise;
+
+// Delivery boundary
+planAttachmentDelivery(providerTarget, attachments): AttachmentDeliveryPlan;
+
+// Provider boundary
+adapter.buildDeliveryParts({ text, attachments, budget }): Promise;
+```
+
+If a future provider is added, only `planAttachmentDelivery` and a provider adapter should change. The composer should not grow provider-specific branches.
+
+### Data compatibility strategy
+
+Persisted messages can exist in three versions:
+
+1. Legacy messages without attachments.
+2. Current messages with file/image metadata but no optimized variant metadata.
+3. New normalized attachment messages.
+
+Hydration should be tolerant:
+
+```ts
+export function hydratePersistedAttachment(raw: unknown): AgentAttachmentPayload | null {
+ if (isNormalizedAttachment(raw)) return raw;
+ if (isLegacyImageAttachment(raw)) return normalizeLegacyImageAttachment(raw);
+ if (isLegacyFileAttachment(raw)) return normalizeLegacyFileAttachment(raw);
+ return null;
+}
+```
+
+Rules:
+
+- Do not mutate old message JSON on read.
+- Normalize in memory for delivery/rendering.
+- Only write new schema for newly created or edited messages.
+- If old attachment cannot be hydrated, show it as unavailable, not crash message rendering.
+
+### Security checklist
+
+Before implementation PR is accepted:
+
+- Renderer cannot pass arbitrary absolute paths to backend.
+- Backend resolves attachment ids to managed artifact paths.
+- IPC rejects `../`, absolute member-controlled paths, unknown attachment ids, and unknown log paths.
+- Diagnostics redact API keys and bearer tokens.
+- Copy diagnostics does not include raw image content.
+- Temp artifacts are not world-readable if stored outside app data.
+- Provider errors are exact enough to debug but redacted.
+
+### Performance checklist
+
+- Large images are downscaled before send.
+- Renderer optimization is cancellable when attachment is removed.
+- Multiple concurrent optimizations are bounded.
+- Message JSON does not contain base64 blobs.
+- Virtualized rendering is not required for attachment previews in v1, but previews should use thumbnails.
+- Main process does not synchronously read huge files on UI-critical IPC handlers.
+
+### Reliability checklist
+
+- Delivery success is still provider proof-based, not “we attached a file”.
+- Attachment failure does not imply teammate offline.
+- Runtime crash after attachment send is surfaced separately with stderr/process diagnostics.
+- Retry uses same artifact if available, otherwise fails as artifact missing.
+- User can remove/replace attachment after validation failure.
+- Unsupported vision model is blocked before expensive runtime call.
+
+
+## Red-Team Review: Most Likely Regressions
+
+These are the failure modes most likely to slip into implementation if the plan is not followed strictly.
+
+### Regression 1 - Attachments accidentally become launch/runtime truth
+
+Risk: attachment delivery failure gets mixed with teammate liveness or bootstrap projection.
+
+How to prevent:
+
+- Keep attachment failures in message delivery diagnostics only.
+- Do not write attachment errors into `launch-state.json`.
+- Do not update member `spawnStatus` from attachment provider errors.
+- If the process exits, runtime liveness code may report that separately, but the attachment layer must not infer it.
+
+Review question:
+
+```text
+Can this code path mark a member failed/offline without process/liveness evidence?
+```
+
+If yes, reject the implementation.
+
+### Regression 2 - Renderer paths become backend file access
+
+Risk: UI sends `/Users/.../secret` path and backend reads it as an attachment.
+
+How to prevent:
+
+- Renderer sends attachment ids and normalized metadata.
+- Backend resolves ids to managed artifact paths.
+- Backend rejects paths outside the managed artifact root.
+- Provider adapters never accept user-supplied paths directly.
+
+Review question:
+
+```text
+Could a malicious renderer cause main process to read an arbitrary local file?
+```
+
+If yes, reject the implementation.
+
+### Regression 3 - Text-only delivery changes subtly
+
+Risk: refactor changes text message serialization while adding image support.
+
+How to prevent:
+
+- Add fast path for `attachments.length === 0` that calls existing text-only function.
+- Add regression tests for text-only direct user, delegate, ask, task, work-sync, and system delivery.
+- Do not normalize text-only messages through provider image adapters.
+
+Review question:
+
+```text
+Does this PR modify text-only provider payload shape?
+```
+
+If yes, require explicit tests and reason.
+
+### Regression 4 - OpenCode weak models get infinite repair loops
+
+Risk: image support and proof repair interact, causing repeated retries.
+
+How to prevent:
+
+- Capability block is terminal and not retryable.
+- Provider rejection follows existing ledger maxAttempts.
+- Empty assistant turn remains bounded by existing ledger policy.
+- Semantic “I cannot view images” visible reply is not transport retry.
+
+Review question:
+
+```text
+Can this path enqueue another prompt without consuming one bounded ledger attempt?
+```
+
+If yes, reject.
+
+### Regression 5 - Image optimization hides too much quality loss
+
+Risk: screenshots become unreadable, model fails, user blames agent.
+
+How to prevent:
+
+- Use conservative max edge and JPEG quality floor.
+- Show optimized size and warning if aggressive compression was required.
+- Never upscale.
+- Preserve PNG for small UI screenshots or transparency where practical.
+
+Review question:
+
+```text
+Can the user understand that the image was compressed and why?
+```
+
+If no, improve UX before release.
+
+## File Ownership Map by Phase
+
+This is not a hard rule, but it helps keep PR blast radius contained.
+
+| Phase | Primary ownership | Must avoid |
+|---|---|---|
+| Phase 1 | `src/features/agent-attachments`, composer UI, shared validation | Provider runtime code, launch code |
+| Phase 2 | Claude delivery adapter/call site | Codex/OpenCode runtime changes |
+| Phase 3 | Codex adapter + orchestrator command builder | Codex auth/session logic |
+| Phase 4 | OpenCode adapter + capability matrix | OpenCode launch/bootstrap logic |
+| Phase 5 | tests, docs, diagnostics | broad runtime refactors |
+
+## Suggested Review Order
+
+For each implementation PR, review in this order:
+
+1. Public contract and type shape.
+2. Path/security validation.
+3. Text-only no-regression path.
+4. Provider-specific serialization.
+5. Error mapping and diagnostics.
+6. Retry/lifecycle interaction.
+7. Tests.
+8. UI copy.
+
+Do not start by reviewing UI. Most severe bugs will be in path validation, lifecycle coupling, or provider transport.
+
+
+## Implementation Playbook Addendum
+
+### Schema versioning
+
+Attachment metadata should have an explicit schema version from day one. This prevents future migrations from relying on brittle field existence checks.
+
+```ts
+export const AGENT_ATTACHMENT_SCHEMA_VERSION = 1 as const;
+
+export interface VersionedAgentAttachmentPayload extends AgentAttachmentPayload {
+ schemaVersion: typeof AGENT_ATTACHMENT_SCHEMA_VERSION;
+}
+```
+
+Versioning rules:
+
+- New messages write `schemaVersion: 1`.
+- Old messages without `schemaVersion` are hydrated as legacy attachments in memory.
+- Hydration must be pure and non-mutating.
+- A future migration can upgrade persisted records, but Phase 1 should not rewrite historical inboxes.
+
+### Managed artifact storage layout
+
+Use deterministic app-managed storage. Do not store optimized files in random temp locations that disappear before retry.
+
+Recommended layout:
+
+```text
+~/.claude/teams//attachments///original.
+~/.claude/teams//attachments///optimized.
+~/.claude/teams//attachments///thumb.
+~/.claude/teams//attachments///meta.json
+```
+
+Storage rules:
+
+- `teamName`, `messageId`, and `attachmentId` must be validated identifiers, not raw paths.
+- Original artifact is immutable after initial write.
+- Optimized artifacts may be regenerated if missing and original exists.
+- Thumbnail is UI-only and must not be used for provider delivery.
+- Artifact cleanup must be reachability-based or age-based, never immediate after send.
+
+### Artifact metadata example
+
+```json
+{
+ "schemaVersion": 1,
+ "attachmentId": "att_abc123",
+ "messageId": "msg_abc123",
+ "originalName": "screenshot.png",
+ "mimeType": "image/png",
+ "originalBytes": 1452000,
+ "optimizedBytes": 422000,
+ "width": 2560,
+ "height": 1440,
+ "optimizedWidth": 1600,
+ "optimizedHeight": 900,
+ "createdAt": "2026-05-09T00:00:00.000Z"
+}
+```
+
+Do not store provider credentials, prompt text, or base64 content in this metadata.
+
+### Concurrency model
+
+Attachment processing has three independent concurrency domains:
+
+1. Renderer optimization jobs.
+2. Main-process artifact validation and persistence.
+3. Provider delivery attempts.
+
+They should not share mutable state directly.
+
+```ts
+export interface AttachmentProcessingToken {
+ draftId: string;
+ generation: number;
+ attachmentIds: string[];
+}
+```
+
+Concurrency rules:
+
+- Renderer jobs are invalidated by draft generation.
+- Main-process validation is tied to message id.
+- Provider delivery is tied to ledger/delivery attempt id.
+- Retry must use message id and attachment id, not current composer draft state.
+
+### Garbage collection policy
+
+Attachment artifacts can grow large. Add a conservative GC policy but do not make it part of the critical send path.
+
+Safe GC inputs:
+
+- Message records that still reference attachments.
+- Delivery ledger records that are still retryable.
+- Artifact creation time.
+- Team deletion/removal.
+
+Unsafe GC behavior:
+
+- Deleting artifacts immediately after first successful delivery.
+- Deleting artifacts while a retry is pending.
+- Deleting original while keeping optimized only.
+- Running expensive GC synchronously during send.
+
+Recommended first version:
+
+```ts
+export interface AttachmentGarbageCollectionPolicy {
+ minAgeBeforeUnreferencedDeleteMs: number;
+ maxTotalBytesPerTeam?: number;
+ dryRun?: boolean;
+}
+```
+
+Phase 1 can define the policy and tests. Actual scheduled GC can be a later small PR if release risk is high.
+
+### Telemetry and diagnostics boundaries
+
+Useful diagnostic summary:
+
+```ts
+export interface AgentAttachmentDeliveryDiagnostic {
+ messageId: string;
+ targetProvider: string;
+ targetModel: string;
+ attachmentCount: number;
+ totalOriginalBytes: number;
+ totalOptimizedBytes: number;
+ capabilityDecision: string;
+ failureCode?: AttachmentDeliveryFailureCode;
+}
+```
+
+Never include:
+
+- Raw image bytes.
+- Base64 strings.
+- OCR output.
+- Secret-bearing provider request payloads.
+- Full local absolute artifact path in normal UI diagnostics.
+
+### Explicit accepted risks
+
+These are acceptable for the first release:
+
+- Some image formats are unsupported even if the browser can preview them.
+- Unknown OpenCode models are blocked for image delivery until tested.
+- Optimized screenshots may lose minor visual fidelity.
+- Artifact GC may initially be conservative and leave extra files.
+- Full image search/annotation/editing is out of scope.
+
+These are not acceptable:
+
+- Images pasted as base64 text to all providers.
+- Unsupported model silently receiving image-less prompt.
+- Attachment failure marking teammate spawn failed.
+- Arbitrary renderer path read by backend.
+- Text-only messaging regression.
+
+
+## Traceability Matrix: Real Problems to Design Decisions
+
+This section maps the original user pain points to concrete technical controls. It should be used during implementation review to verify that the plan solves the actual problem, not only the happy path.
+
+| Problem observed | Root risk | Design decision | Phase |
+|---|---|---|---|
+| Large screenshots may crash or destabilize runtime delivery | Huge base64/stdin payloads and unbounded image bytes | Optimize in renderer, enforce backend budgets, never paste base64 as plain text | 1 |
+| Different providers support images differently | False universal abstraction | Shared normalization plus provider-native adapters | 2, 3, 4 |
+| Codex and OpenCode may not support every model equally | Model capability ambiguity | Capability matrix and backend final support gate | 3, 4 |
+| User needs clear UI when model cannot see images | Silent hallucination or confusing failure | Pre-send warnings/blocking with exact provider/model reason | 1, 4, 5 |
+| Attachment failure could be mistaken for team crash | Lifecycle coupling | Attachment errors remain message-delivery diagnostics only | all |
+| Existing launch stability is fragile | Accidental runtime/provisioning changes | No launch/readiness imports in attachment feature | all |
+| Retry could resend without files or as text fallback | Silent data loss | Retry preserves attachment identity, missing artifact is explicit failure | 3, 4, 5 |
+| User privacy across teammates | Accidental fanout | Attachments follow explicit message route only | 4 |
+
+## Dependency Policy
+
+Phase 1 may add or use `pica` in the renderer for high-quality resizing only if dependency review passes.
+
+Before adding or updating dependency:
+
+```bash
+pnpm view pica version
+pnpm view pica license
+pnpm view pica time --json
+```
+
+Dependency rules:
+
+- Prefer an already-installed dependency if it satisfies quality and maintenance needs.
+- Do not add native dependencies to Electron main for image processing in this phase.
+- Do not add a full log/image viewer package for basic image resizing.
+- Record the checked version and date in the PR description.
+- If the installed version is older than latest, decide explicitly whether to upgrade or use current lockfile version.
+
+Recommended dependency risk rating for `pica` approach:
+
+- 🎯 8.8 🛡️ 8.7 🧠 3.5, roughly `120-220` LOC renderer integration.
+
+Why not sharp/jimp in main for v1:
+
+- `sharp` adds native packaging risk in Electron.
+- `jimp` is slower and lower quality for large screenshots.
+- Renderer has browser decode/canvas APIs and keeps image work close to the user action.
+
+## Phase Slicing for Small PRs
+
+Even inside each phase, split implementation into small reviewable PRs where possible.
+
+### Phase 1 PR slices
+
+1. Shared types and pure budget/capability functions.
+2. Artifact storage/path validation in main.
+3. Renderer optimization hook and UI warnings.
+4. Composer send blocking and backend validation integration.
+
+### Phase 2 PR slices
+
+1. Claude adapter pure serializer and tests.
+2. Claude delivery call-site integration behind attachment-present branch.
+3. Claude live smoke and diagnostics polish.
+
+### Phase 3 PR slices
+
+1. Desktop Codex adapter request shape.
+2. Orchestrator Codex image arg builder.
+3. End-to-end Codex smoke and failure mapping.
+
+### Phase 4 PR slices
+
+1. OpenCode vision capability matrix.
+2. OpenCode file-part adapter.
+3. Ledger/proof interaction tests.
+4. OpenRouter model smoke and unsupported-model UX.
+
+### Phase 5 PR slices
+
+1. Automated test matrix.
+2. Manual smoke scripts/docs.
+3. Diagnostics and release notes.
+
+Do not ship a phase as one huge PR unless the repo owner explicitly accepts the higher review risk.
+
+## Contract Boundaries That Must Not Be Crossed
+
+```text
+Renderer composer
+ -> normalized draft attachment metadata
+Main attachment feature
+ -> validated managed artifact bundle
+Provider delivery adapter
+ -> provider-native delivery parts
+Runtime/orchestrator
+ -> process/CLI execution only
+Delivery ledger
+ -> proof/retry state only
+Launch/provisioning
+ -> no dependency on attachments
+```
+
+Forbidden imports:
+
+- Launch/provisioning code importing `agent-attachments`.
+- Attachment adapter importing launch-state helpers.
+- Renderer optimizer importing main-process path/storage helpers.
+- OpenCode repair policy importing model vision capability to decide retry loops.
+
+## Design Comments to Add in Code
+
+Add short comments only where the decision is non-obvious.
+
+Example near provider-native base64 block:
+
+```ts
+// This base64 is part of Claude's structured image block, not plain prompt text.
+// Do not replace this with data:image text, because that breaks provider-native image handling.
+```
+
+Example near launch/liveness separation:
+
+```ts
+// Attachment delivery errors are message-level diagnostics.
+// They must not update teammate launchState/spawnStatus unless runtime liveness independently reports an exit.
+```
+
+Example near OpenCode capability block:
+
+```ts
+// OpenCode image support is model-specific. Unknown models are blocked for reliability instead of silently receiving image-less prompts.
+```
+
+
+## Implementation Contract Addendum
+
+### No feature flags, but explicit capability gates
+
+This project should not use hidden feature flags or environment flags for the final behavior. The safe gating mechanism is provider/model capability, not runtime flags.
+
+Rules:
+
+- If a provider/model supports images, the UI may allow image send after validation.
+- If a provider/model does not support images, the UI blocks image send with explanation.
+- If support is unknown, default to safe block for release.
+- Do not add `ENABLE_AGENT_ATTACHMENTS`, `USE_NEW_ATTACHMENTS`, or similar flags.
+- Temporary local dev toggles are allowed only in uncommitted experiments, not in final implementation.
+
+Reason:
+
+Feature flags would create two production paths and double the regression surface. Capability gates are part of the domain and must remain after release.
+
+### Public API and IPC principles
+
+Attachments should not create broad filesystem IPC. If new IPC is required, it should be attachment-specific and validated.
+
+Allowed IPC style:
+
+```ts
+attachments:prepareImageArtifact(draftAttachmentId, metadata)
+attachments:getAttachmentPreview(attachmentId)
+attachments:removeDraftAttachment(attachmentId)
+```
+
+Avoid IPC style:
+
+```ts
+fs:readFile(path)
+attachments:readArbitraryPath(path)
+provider:sendRawPayload(payload)
+```
+
+IPC validation requirements:
+
+- Validate all ids.
+- Validate MIME type against allowlist.
+- Validate declared byte size against file stat when file exists.
+- Reject unknown provider/model values unless they pass existing provider metadata validation.
+- Return typed errors, not raw thrown stack traces.
+
+### Error class pattern
+
+Use typed domain errors so UI and provider adapters can map failures consistently.
+
+```ts
+export class AgentAttachmentError extends Error {
+ constructor(
+ readonly code: AttachmentDeliveryFailureCode,
+ message: string,
+ readonly details: Record = {}
+ ) {
+ super(message);
+ this.name = 'AgentAttachmentError';
+ }
+}
+```
+
+Mapping rule:
+
+- Domain validation throws/returns `AgentAttachmentError`.
+- Provider adapter wraps provider rejection into `AgentAttachmentError` after redaction.
+- UI consumes `code` for behavior and `message` for display.
+- Logs may include safe `details`, never raw bytes or secrets.
+
+### DTO stability
+
+Keep DTOs discriminated and serializable.
+
+```ts
+export type AttachmentValidationResult =
+ | { ok: true; warnings: AttachmentWarning[] }
+ | { ok: false; code: AttachmentDeliveryFailureCode; message: string; warnings: AttachmentWarning[] };
+```
+
+Avoid returning `Error` objects over IPC. Convert to plain JSON.
+
+### Attachment lifecycle diagram
+
+```mermaid
+flowchart TD
+ A["User selects image"] --> B["Renderer decodes and optimizes"]
+ B --> C["Renderer shows preview and warnings"]
+ C --> D["User sends message"]
+ D --> E["Main validates and persists artifacts"]
+ E --> F["Message record references attachment ids"]
+ F --> G["Provider adapter builds native payload"]
+ G --> H["Existing delivery proof gates decide success"]
+ H --> I["UI shows reply or delivery diagnostic"]
+```
+
+Critical separation:
+
+- Steps A-C are draft UX.
+- Steps D-F are persistence and validation.
+- Step G is provider transport.
+- Step H is existing message proof lifecycle.
+- None of these steps should mutate launch/bootstrap state.
+
+### Multi-repo sequencing
+
+When a phase touches both `claude_team` and `agent_teams_orchestrator`, use this sequence:
+
+1. Add passive types/tests in `claude_team`.
+2. Add orchestrator command builder support with tests.
+3. Wire desktop to send new request shape.
+4. Run focused tests in both repos.
+5. Run one manual smoke.
+6. Commit both repos only after compatible state is reached.
+
+Do not merge desktop code that emits image request fields before orchestrator understands them.
+
+
+## Implementation Readiness Addendum
+
+### Definition of Ready before coding
+
+Do not start production implementation until these decisions are accepted:
+
+- Image delivery uses provider-native channels, not base64 plain text.
+- OpenCode unknown vision models are blocked for release reliability.
+- Attachments are message delivery artifacts, not launch/runtime truth.
+- Renderer optimization is allowed, but backend remains final authority.
+- No feature flags are used for final behavior; capability gates are the product behavior.
+- Non-image file semantics are not expanded until image flow is stable.
+
+### Image vs file scope boundary
+
+This plan is primarily for image attachments. Existing file attachments should not be redesigned in this phase.
+
+| Attachment kind | Phase behavior |
+|---|---|
+| PNG/JPEG screenshot | Supported through image pipeline. |
+| WebP | Optional only if all provider paths are tested. Otherwise block or convert explicitly. |
+| GIF/animated image | Unsupported in v1 unless first-frame conversion is intentionally implemented. |
+| PDF | Keep current file behavior. Do not pretend all agents can visually inspect PDF pages. |
+| TXT/MD/code file | Keep current file/path/text behavior. Not part of image pipeline. |
+| Arbitrary binary | Unsupported for agent delivery unless existing file flow already supports it. |
+
+If users attach non-image files while targeting a provider with image support, do not route those files through image adapters.
+
+### Persisted message attachment example
+
+New messages should reference attachments, not embed bytes.
+
+```json
+{
+ "id": "msg_123",
+ "from": "user",
+ "to": "lead",
+ "text": "What is wrong with this screenshot?",
+ "attachments": [
+ {
+ "schemaVersion": 1,
+ "id": "att_1",
+ "kind": "image",
+ "originalName": "screenshot.png",
+ "mimeType": "image/png",
+ "sizeBytes": 1452000,
+ "storage": {
+ "originalArtifactId": "art_original_1",
+ "optimizedArtifactId": "art_optimized_1",
+ "thumbnailArtifactId": "art_thumb_1"
+ },
+ "image": {
+ "width": 2560,
+ "height": 1440,
+ "optimizedWidth": 1600,
+ "optimizedHeight": 900,
+ "optimization": "resized"
+ },
+ "warnings": ["image_was_resized"]
+ }
+ ]
+}
+```
+
+Forbidden persisted shape:
+
+```json
+{
+ "text": "What is wrong with this screenshot? data:image/png;base64,..."
+}
+```
+
+### Provider payload examples
+
+Claude structured image block:
+
+```ts
+[
+ { type: 'text', text: 'What color is this square?' },
+ {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: 'image/png',
+ data: '',
+ },
+ },
+]
+```
+
+Codex native runtime request:
+
+```ts
+{
+ text: 'What color is this square?',
+ images: [
+ { path: '/managed/team/msg/att/optimized.png', mimeType: 'image/png' },
+ ],
+}
+```
+
+OpenCode native runtime request:
+
+```ts
+{
+ text: 'What color is this square?',
+ files: [
+ { path: '/managed/team/msg/att/optimized.png', mimeType: 'image/png' },
+ ],
+}
+```
+
+### Rollback by phase
+
+| Phase | Safe rollback |
+|---|---|
+| Phase 1 | Disable composer attachment preparation call. Leave pure modules unused. |
+| Phase 2 | Disable Claude image adapter branch. Text-only Claude path remains. |
+| Phase 3 | Disable Codex image capability gate. Orchestrator image arg builder can remain unused. |
+| Phase 4 | Mark OpenCode image capability unsupported for all models. Text-only OpenCode remains. |
+| Phase 5 | Remove only smoke scripts/docs if unstable. Runtime behavior unaffected. |
+
+Rollback must not delete persisted messages or artifacts. If needed, UI can show attachments as unavailable until support is restored.
+
+
+## Final Implementation Guardrails Addendum
+
+### Bug ledger to keep during implementation
+
+During implementation, keep a short bug ledger in the PR description. This prevents hidden tradeoffs from being forgotten.
+
+```md
+## Attachment implementation bug ledger
+
+| Risk | Status | Mitigation |
+|---|---|---|
+| Text-only path changed | checked | attachments.length === 0 uses legacy path |
+| Renderer path could escape storage | checked | backend id-based resolver only |
+| Unsupported OpenCode model silently accepts image | checked | capability block before delivery |
+| Retry loses attachment artifact | checked | retry resolves same artifact id |
+| Attachment error mutates launch state | checked | no launch-state imports |
+```
+
+### Acceptance-test specification format
+
+Every phase should define tests in this style before code is written:
+
+```md
+Given: a user attaches a 5 MB PNG screenshot
+When: the selected model supports images
+Then: the image is optimized below the provider budget
+And: the persisted message references artifact ids, not base64
+And: provider delivery uses the native image channel
+```
+
+This keeps implementation aligned with product behavior instead of internal mechanics only.
+
+### Provider capability maintenance policy
+
+Capabilities are product behavior, so changes must be reviewed like code, not treated as config trivia.
+
+Rules:
+
+- Add a test for every new model capability entry.
+- Include evidence for `vision: yes` and `vision: no`.
+- Prefer `unknown` or blocked over optimistic support.
+- Remove or downgrade support if live smoke becomes unstable.
+- Do not infer that all OpenRouter models support vision because one OpenRouter model worked.
+
+### Provider capability review table
+
+| Decision | Required evidence | Risk if wrong |
+|---|---|---|
+| Mark model vision-capable | live image smoke or official docs | user sends image, model cannot see it |
+| Mark model unsupported | live refusal or official docs | false negative, user cannot use capable model |
+| Keep model unknown | no stable evidence | safest release behavior |
+
+### Architectural quality score target
+
+Implementation should target:
+
+- SOLID: `8.5/10` or higher.
+- DRY: `8/10` or higher.
+- Provider isolation: `9/10` or higher.
+- Launch/runtime isolation: `9.5/10` or higher.
+- Testability: `8.5/10` or higher.
+
+If a phase drops below these, do not continue to the next provider. Refactor the boundary first.
+
+### What not to optimize prematurely
+
+Do not add these before the basic image path is stable:
+
+- Full image gallery management.
+- Image annotation/editing.
+- OCR extraction.
+- Remote artifact upload service.
+- Streaming upload progress per provider.
+- Cross-teammate automatic attachment forwarding.
+- Automatic model selection based on attachment.
+
+Each adds product and reliability complexity that is not needed for the first stable release.
+
+
+## Pre-Mortem: How This Project Could Fail
+
+Use this section before implementation and before merge. If any item becomes true, stop and fix the design before continuing.
+
+| Failure mode | Early warning sign | Prevention |
+|---|---|---|
+| Attachment logic leaks into launch | `agent-attachments` imported from provisioning/runtime bootstrap files | Keep attachment layer under message delivery only. |
+| Provider adapters duplicate validation | Same budget/type checks copy-pasted in Claude/Codex/OpenCode adapters | Shared validator produces validated bundle before adapter. |
+| UI says image sent but provider got text only | Provider payload tests do not assert native image channel | Add serialization tests for each provider. |
+| Retry loses image | Retry code reads current composer draft instead of persisted message attachments | Retry resolves attachments by message id and attachment id. |
+| OpenCode weak model loops forever | Capability unsupported case creates ledger attempt | Capability block happens before delivery attempt. |
+| Codex auth regresses | Image support touches auth/env construction | Keep Codex auth path untouched and test diagnostics. |
+| Attachments bloat inbox files | Message JSON contains base64 | Persist artifact ids only. |
+| Screenshots become unreadable | Aggressive compression without warning | Conservative quality floor and explicit warning. |
+| User image leaks to all teammates | Team send fanouts attachment automatically | Attachments follow explicit route only. |
+
+## Error Propagation Contract
+
+Errors should move through the system with enough structure to drive UI and diagnostics.
+
+```ts
+export interface AgentAttachmentErrorJson {
+ code: AttachmentDeliveryFailureCode;
+ message: string;
+ providerId?: string;
+ model?: string;
+ attachmentId?: string;
+ retryable: boolean;
+ safeDetails?: Record;
+}
+```
+
+Rules:
+
+- Renderer displays `message` and may branch on `code`.
+- Main logs `safeDetails` only after redaction.
+- Provider adapter must preserve provider/model in error context.
+- `retryable` means the same message can be attempted again without user edits.
+- Capability errors are not retryable until user changes model or removes attachment.
+- Artifact missing is not retryable unless original can be regenerated.
+
+## Test Builder Conventions
+
+Use builders to keep tests readable and avoid fragile object literals.
+
+```ts
+export function fakeImageAttachment(overrides: Partial = {}): AgentAttachmentPayload {
+ return {
+ schemaVersion: 1,
+ id: 'att_test_1',
+ kind: 'image',
+ originalName: 'red-square.png',
+ mimeType: 'image/png',
+ sizeBytes: 1024,
+ storage: {
+ originalArtifactId: 'art_original_1',
+ optimizedArtifactId: 'art_optimized_1',
+ },
+ image: {
+ width: 64,
+ height: 64,
+ optimizedWidth: 64,
+ optimizedHeight: 64,
+ optimization: 'none',
+ },
+ warnings: [],
+ ...overrides,
+ };
+}
+```
+
+Builder rules:
+
+- Defaults should represent a valid supported image.
+- Invalid cases should override one field at a time.
+- Builders must not hide required fields with `as any`.
+- Provider adapter tests should use fake artifact readers, not real files unless testing storage.
+
+## Review Grep Checklist
+
+Run these searches during implementation review.
+
+```bash
+rg "data:image|base64" src test
+rg "launchState|spawnStatus|bootstrapConfirmed" src/features/agent-attachments src/main/services/team
+rg "--image|-f" src /Users/belief/dev/projects/claude/agent_teams_orchestrator/src
+rg "readFile\(|writeFile\(" src/features/agent-attachments src/main/services
+rg "OPENAI_API_KEY|ANTHROPIC_API_KEY|CODEX_API_KEY|Authorization" src/features/agent-attachments src/main/services
+```
+
+Review meaning:
+
+- `base64` should appear only in Claude structured image block code/tests.
+- `launchState`/`spawnStatus` should not appear inside attachment feature.
+- `--image` should appear only in Codex runtime command builder/tests.
+- `-f` should appear only in OpenCode runtime file-part builder/tests.
+- Secret env names should appear only in redaction tests or existing auth code, not attachment diagnostics.
+
+## Minimum Viable Shipping Scope
+
+If release pressure is high, ship only this subset:
+
+1. Phase 1 normalization, optimization, validation, and warnings.
+2. Claude image delivery if real app-path smoke is green.
+3. Codex image delivery if `--image` support and subscription auth are green.
+4. OpenCode support only for models with live smoke evidence.
+
+Do not ship broad “all OpenCode models support images” behavior.
+
diff --git a/docs/team-management/agent-attachments-phase-1-normalization-and-budgets-plan.md b/docs/team-management/agent-attachments-phase-1-normalization-and-budgets-plan.md
new file mode 100644
index 00000000..9614dd53
--- /dev/null
+++ b/docs/team-management/agent-attachments-phase-1-normalization-and-budgets-plan.md
@@ -0,0 +1,1719 @@
+# Phase 1 - Attachment normalization, image optimization, budgets, and UI warnings
+
+## Summary
+
+Goal: make attachment intake safe before changing provider delivery paths.
+
+Chosen approach: **new agent-attachments feature skeleton + renderer pica optimizer + backend budget validator + capability warnings**, with current runtime delivery behavior preserved.
+
+🎯 9.4 🛡️ 9.3 🧠 5.8
+Estimated change size: `260-420` LOC.
+
+This phase is intentionally conservative. It reduces crash risk from oversized image payloads without changing Claude/Codex/OpenCode runtime launch or delivery semantics.
+
+## Why this phase first
+
+Current attachment handling stores images as base64 in renderer and validates decoded file size only. This misses the real risk:
+
+```text
+image bytes -> base64 expands by ~33% -> JSON wrapper -> stream-json stdin line
+```
+
+A 20MB decoded total can become a much larger single-line JSON payload and can destabilize a long-lived lead process.
+
+Phase 1 creates the safety foundation:
+
+- normalize attachments;
+- optimize screenshots;
+- calculate estimated serialized payload size;
+- block too-large sends before stdin write;
+- show clear UI warnings;
+- do not change runtime adapter logic yet.
+
+## Scope
+
+In scope:
+
+- new `src/features/agent-attachments` contracts/core shell;
+- renderer image optimization using `pica@9.0.1`;
+- new normalized attachment DTOs;
+- backend validation for image dimensions, bytes, base64 size, and estimated serialized payload;
+- UI warnings in composer;
+- tests for optimizer decisions and validation.
+
+Out of scope:
+
+- Codex `--image` wiring;
+- OpenCode file parts;
+- model capability catalog beyond basic warnings;
+- document/PDF optimization;
+- live provider calls.
+
+## Dependency decision
+
+Add:
+
+```bash
+pnpm add pica@9.0.1
+```
+
+Rationale:
+
+- pure browser-side high-quality resize;
+- no native Electron packaging risk;
+- good quality for screenshots and UI text;
+- safer before release than `sharp` in Electron main.
+
+Do not add:
+
+- `sharp` in Electron main in this phase;
+- `@squoosh/lib` due staleness/complexity;
+- `jimp` due lower quality/performance for screenshots.
+
+## New feature layout
+
+```text
+src/features/agent-attachments/
+ contracts/
+ api.ts
+ dto.ts
+ channels.ts
+ core/
+ domain/
+ AttachmentBudget.ts
+ AttachmentModel.ts
+ AttachmentValidation.ts
+ application/
+ AttachmentIntakePolicy.ts
+ AttachmentBudgetEstimator.ts
+ main/
+ composition/
+ createAgentAttachmentsFeature.ts
+ adapters/
+ input/ipc/registerAgentAttachmentIpc.ts
+ infrastructure/
+ ServerAttachmentValidator.ts
+ preload/
+ createAgentAttachmentsBridge.ts
+ renderer/
+ hooks/useAttachmentPreparation.ts
+ ui/AttachmentCapabilityNotice.tsx
+ utils/picaImageOptimizer.ts
+```
+
+If this feels too much for phase 1, contracts/domain/application can be created first and IPC can be deferred. But the boundaries should be established now.
+
+## Contract DTOs
+
+```ts
+export type AgentAttachmentKind = 'image' | 'document' | 'text' | 'unsupported';
+
+export interface AgentAttachmentDraftDto {
+ id: string;
+ filename: string;
+ mimeType: string;
+ kind: AgentAttachmentKind;
+ originalBytes: number;
+ dataBase64: string;
+ width?: number;
+ height?: number;
+ optimized?: AgentAttachmentOptimizedVariantDto;
+ warnings: AgentAttachmentWarningDto[];
+}
+
+export interface AgentAttachmentOptimizedVariantDto {
+ mimeType: 'image/jpeg' | 'image/png' | 'image/webp';
+ dataBase64: string;
+ bytes: number;
+ width: number;
+ height: number;
+ quality?: number;
+ strategy: 'unchanged' | 'resized' | 'converted' | 'resized-and-converted';
+}
+
+export interface AgentAttachmentWarningDto {
+ code:
+ | 'image_resized'
+ | 'image_quality_reduced'
+ | 'image_too_large'
+ | 'animated_gif_unchanged'
+ | 'unsupported_mime_type'
+ | 'serialized_payload_too_large';
+ severity: 'info' | 'warning' | 'error';
+ message: string;
+}
+```
+
+## Budget constants
+
+Start conservative. These can be tuned after e2e.
+
+```ts
+export const AGENT_ATTACHMENT_BUDGETS = {
+ maxFiles: 5,
+ maxOriginalFileBytes: 10 * 1024 * 1024,
+ maxTotalOriginalBytes: 20 * 1024 * 1024,
+ maxOptimizedImageBytes: 1_500_000,
+ maxTotalOptimizedBytes: 4_000_000,
+ maxEstimatedStreamJsonPayloadBytes: 7_500_000,
+ maxDecodedMegapixels: 24,
+ maxLongEdgePx: 2000,
+ minJpegQuality: 0.72,
+ initialJpegQuality: 0.88,
+} as const;
+```
+
+Rationale:
+
+- Claude Code docs mention 10MB stdin limit for headless input modes. Use `7.5MB` app budget to leave JSON/base64 overhead headroom.
+- Multiple images need a total optimized budget, not only per-image limits.
+- Screenshots need enough resolution to read text, so do not crush quality below `0.72` silently.
+
+## Renderer optimizer policy
+
+Use `pica` only for images where this is safe.
+
+```ts
+export async function optimizeImageForAgentAttachment(
+ input: BrowserImageInput,
+ policy = DEFAULT_IMAGE_OPTIMIZATION_POLICY,
+): Promise {
+ if (input.mimeType === 'image/gif') {
+ return keepOriginalWithWarning('animated_gif_unchanged');
+ }
+
+ if (input.hasAlpha) {
+ return resizePngPreservingAlpha(input, policy);
+ }
+
+ return resizeRgbScreenshotToJpeg(input, policy);
+}
+```
+
+Rules:
+
+- Preserve aspect ratio.
+- Preserve alpha by staying PNG unless output exceeds budget and user must choose a lower-fidelity conversion explicitly later.
+- Do not silently convert animated GIF to a still image.
+- Prefer JPEG for large RGB screenshots.
+- Try qualities in bounded steps: `0.88`, `0.82`, `0.76`, `0.72`.
+- If still too large, show error instead of making unreadable images.
+
+## Payload size estimator
+
+Do not rely only on decoded bytes.
+
+```ts
+export function estimateStreamJsonPayloadBytes(input: {
+ text: string;
+ attachments: AgentAttachmentDraftDto[];
+}): number {
+ const contentBlocks = input.attachments.map(attachment => ({
+ type: attachment.kind === 'image' ? 'image' : 'document',
+ source: {
+ type: 'base64',
+ media_type: attachment.optimized?.mimeType ?? attachment.mimeType,
+ data: attachment.optimized?.dataBase64 ?? attachment.dataBase64,
+ },
+ }));
+
+ return Buffer.byteLength(JSON.stringify({
+ type: 'user',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text: input.text }, ...contentBlocks],
+ },
+ }), 'utf8');
+}
+```
+
+This estimator lives in shared/core if it avoids Node-only APIs, or duplicated as pure helper with `TextEncoder` for renderer and `Buffer.byteLength` for main. Prefer pure `TextEncoder` for cross-process reuse.
+
+## Backend validation
+
+The backend must revalidate everything because renderer optimization is not a security boundary.
+
+```ts
+export function validateAgentAttachmentsForSend(input: {
+ text: string;
+ attachments: AgentAttachmentDraftDto[];
+ runtimeHint: RuntimeAttachmentHint;
+}): ValidationResult {
+ if (input.attachments.length > AGENT_ATTACHMENT_BUDGETS.maxFiles) {
+ return error('Too many attachments.');
+ }
+
+ const estimatedBytes = estimateStreamJsonPayloadBytes(input);
+ if (estimatedBytes > AGENT_ATTACHMENT_BUDGETS.maxEstimatedStreamJsonPayloadBytes) {
+ return error(
+ `Attachments are too large after optimization (${formatBytes(estimatedBytes)} serialized). ` +
+ `Remove an image or reduce screenshot size.`,
+ );
+ }
+
+ return ok();
+}
+```
+
+For phase 1, wire this into existing `validateAttachments` before `sendMessageToTeam` accepts attachments.
+
+## Composer UI behavior
+
+Add a small notice near attachment previews.
+
+Examples:
+
+```text
+Screenshot optimized to 1920x1080 JPEG, 612 KB.
+```
+
+```text
+Attachments are too large after optimization. Remove one image or use a smaller screenshot.
+```
+
+```text
+Animated GIFs are not optimized yet and may be too large for agent delivery.
+```
+
+Do not mention provider-specific capability in Phase 1 unless the target runtime is already known in composer state. The main blocker in Phase 1 is size/budget safety.
+
+## Integration points
+
+Existing code to adjust carefully:
+
+```text
+src/renderer/utils/attachmentUtils.ts
+src/renderer/hooks/useComposerDraft.ts
+src/main/ipc/teams.ts
+src/main/services/team/TeamProvisioningService.ts
+```
+
+Do not move all logic at once. Add wrappers and leave current API shape compatible.
+
+## Edge cases
+
+### Multiple high-resolution screenshots
+
+Expected behavior:
+
+- optimize each image;
+- if total serialized payload still too large, block send with clear error;
+- do not partially send only some images.
+
+### Transparent PNG
+
+Expected behavior:
+
+- preserve PNG/alpha;
+- if too large, ask user to reduce or confirm future lossy conversion in a later phase;
+- do not silently flatten transparency.
+
+### Animated GIF
+
+Expected behavior:
+
+- keep original if within budget;
+- otherwise block with clear message;
+- do not silently first-frame it.
+
+### Corrupt image
+
+Expected behavior:
+
+- show `Cannot read image file`;
+- do not pass corrupt base64 to runtime.
+
+### Old draft with base64-only attachment
+
+Expected behavior:
+
+- load draft;
+- if no optimized variant exists, optimize on send;
+- if optimization fails, block send.
+
+### Unsupported file type
+
+Expected behavior:
+
+- existing path fallback for local files can remain;
+- unsupported binary file is not converted to base64 attachment.
+
+## Test plan
+
+### Unit
+
+- `estimateStreamJsonPayloadBytes` includes base64 and JSON overhead.
+- RGB PNG screenshot converts/resizes to JPEG under budget.
+- Small PNG remains unchanged if already safe.
+- Alpha PNG does not become JPEG silently.
+- Animated GIF is not converted silently.
+- Corrupt image returns error.
+- Total optimized bytes over budget blocks send.
+
+### Renderer
+
+- composer shows optimization notice;
+- composer shows too-large error;
+- removing an attachment clears budget error;
+- old drafts trigger optimization before send.
+
+### Main/IPС
+
+- IPC rejects too many attachments;
+- IPC rejects payload above serialized budget;
+- IPC accepts safe optimized image;
+- error messages are user-readable and do not include base64 data.
+
+Suggested focused checks:
+
+```bash
+pnpm vitest run src/features/agent-attachments/**/*.test.ts test/main/ipc/teams.test.ts test/renderer/components/team/messages/MessageComposer.test.tsx
+pnpm typecheck --pretty false
+```
+
+## Safety checklist
+
+- No provider runtime path changed.
+- No launch/provisioning path changed.
+- Text-only messages still use old path.
+- Attachments are blocked before send if unsafe.
+- Backend validation cannot be bypassed by renderer state.
+- No secrets or base64 blobs in diagnostics.
+
+## Deep implementation details
+
+### Step-by-step implementation sequence
+
+1. Add feature contracts and pure budget estimator.
+2. Add renderer-only `picaImageOptimizer` with no imports from main.
+3. Add backend `ServerAttachmentValidator` that can validate legacy payloads.
+4. Wire backend validator into existing IPC send path before `TeamProvisioningService.sendMessageToTeam()`.
+5. Add composer warnings from renderer optimization state.
+6. Add tests for estimator and validator.
+
+This order avoids changing provider delivery until validation is proven.
+
+### Pure byte estimator
+
+Use a runtime-neutral helper so both renderer and main can compute comparable values.
+
+```ts
+export function utf8Bytes(value: string): number {
+ return new TextEncoder().encode(value).byteLength;
+}
+
+export function estimateBase64JsonStringBytes(base64: string): number {
+ // JSON string escaping is normally small for base64, but include quotes.
+ return utf8Bytes(JSON.stringify(base64));
+}
+
+export function estimateClaudeStreamJsonPayloadBytes(input: {
+ text: string;
+ attachments: Array<{ mimeType: string; base64: string; kind: 'image' | 'document' }>;
+}): number {
+ const payload = {
+ type: 'user',
+ message: {
+ role: 'user',
+ content: [
+ { type: 'text', text: input.text },
+ ...input.attachments.map(att => ({
+ type: att.kind === 'image' ? 'image' : 'document',
+ source: {
+ type: 'base64',
+ media_type: att.mimeType,
+ data: att.base64,
+ },
+ })),
+ ],
+ },
+ };
+ return utf8Bytes(JSON.stringify(payload));
+}
+```
+
+Avoid using `Buffer` in shared/renderer code.
+
+### Renderer optimizer pseudo-code
+
+```ts
+export async function prepareImageAttachmentDraft(file: File): Promise {
+ const originalBase64 = await readFileAsBase64(file);
+ const metadata = await readImageMetadata(file);
+
+ if (metadata.megapixels > AGENT_ATTACHMENT_BUDGETS.maxDecodedMegapixels) {
+ return errorDraft(file, 'Image resolution is too large to process safely.');
+ }
+
+ const optimized = await optimizeImageForAgent(file, metadata);
+ const warnings = buildOptimizationWarnings(file, optimized);
+
+ return {
+ id: stableBrowserDraftId(file, originalBase64),
+ filename: file.name,
+ mimeType: file.type,
+ kind: 'image',
+ originalBytes: file.size,
+ dataBase64: originalBase64,
+ width: metadata.width,
+ height: metadata.height,
+ optimized,
+ warnings,
+ };
+}
+```
+
+### Pica resize pseudo-code
+
+```ts
+async function resizeRgbToJpeg(input: ImageBitmap, policy: ImagePolicy) {
+ const { width, height } = fitWithinLongEdge(input.width, input.height, policy.maxLongEdgePx);
+ const canvas = new OffscreenCanvas(width, height);
+ await pica().resize(input, canvas, {
+ quality: 3,
+ alpha: false,
+ unsharpAmount: 80,
+ unsharpRadius: 0.6,
+ unsharpThreshold: 2,
+ });
+
+ for (const quality of [0.88, 0.82, 0.76, 0.72]) {
+ const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
+ if (blob.size <= policy.maxOptimizedImageBytes) {
+ return toVariant(blob, { width, height, quality, strategy: 'resized-and-converted' });
+ }
+ }
+
+ throw new AttachmentTooLargeError('Image is still too large after resizing.');
+}
+```
+
+Fallback if `OffscreenCanvas` is unavailable:
+
+```ts
+const canvas = document.createElement('canvas');
+canvas.width = width;
+canvas.height = height;
+await pica().resize(sourceCanvasOrImage, canvas);
+```
+
+### Alpha detection
+
+Do not decode full huge images on main thread just to check alpha. In renderer, after image bitmap decode and drawing to a small sampling canvas:
+
+```ts
+function likelyHasAlpha(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
+ const sampleWidth = Math.min(width, 256);
+ const sampleHeight = Math.min(height, 256);
+ const data = ctx.getImageData(0, 0, sampleWidth, sampleHeight).data;
+ for (let i = 3; i < data.length; i += 4) {
+ if (data[i] !== 255) return true;
+ }
+ return false;
+}
+```
+
+If uncertain, prefer PNG and warn rather than silently flattening.
+
+### Backend legacy payload normalization
+
+```ts
+export function normalizeLegacyAttachmentPayload(input: {
+ data: string;
+ mimeType: string;
+ filename?: string;
+}): NormalizedLegacyAttachment {
+ const decodedBytes = estimateDecodedBase64Bytes(input.data);
+ const kind = classifyMimeType(input.mimeType);
+
+ if (decodedBytes > AGENT_ATTACHMENT_BUDGETS.maxOriginalFileBytes) {
+ throw new AttachmentValidationError({
+ code: 'attachment_too_large_original',
+ userMessage: `${input.filename ?? 'Attachment'} is too large.`,
+ });
+ }
+
+ return {
+ id: stableAttachmentId(input),
+ filename: sanitizeAttachmentFilename(input.filename),
+ mimeType: input.mimeType,
+ kind,
+ decodedBytes,
+ base64: input.data,
+ };
+}
+```
+
+### Filename sanitization
+
+Never use attachment filenames directly as filesystem paths.
+
+```ts
+export function sanitizeAttachmentFilename(name: string | undefined): string {
+ const fallback = 'attachment';
+ const base = (name ?? fallback)
+ .replace(/[\\/\0\r\n\t]/g, '_')
+ .replace(/^\.+$/, fallback)
+ .slice(0, 120)
+ .trim();
+ return base || fallback;
+}
+```
+
+### More edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| Browser cannot decode HEIC pasted from iPhone | show unsupported image format, suggest PNG/JPEG screenshot |
+| User attaches 5 images each individually under budget but combined over budget | block whole send, show combined payload size |
+| Image has huge dimensions but tiny compressed bytes | block before decode if dimensions exceed safe megapixels |
+| File extension says `.jpg` but MIME says PNG | trust detected MIME if available, otherwise validate magic bytes in backend later |
+| Renderer optimization fails due memory pressure | keep draft but mark send-blocked with retry/remove action |
+| User edits message text after optimization | do not recompress image, only recompute serialized payload estimate |
+| User removes image | revoke object URLs and release ImageBitmap/canvas refs |
+| User switches team while optimization running | cancel or ignore stale optimization result by draft id |
+| SVG image | treat as unsupported in v1 unless converted explicitly later |
+| WebP | allow if runtime supports, otherwise convert to JPEG/PNG if safe |
+
+### Bug-prevention checklist
+
+- All async optimizer results must check current draft id before writing state.
+- Object URLs must be revoked on unmount/remove.
+- Do not store huge base64 in React error messages.
+- Do not include base64 in Zustand dev logs if avoidable.
+- Do not throw raw DOMException to user.
+- Backend validation must run even if renderer says optimized.
+- Tests should include both `data.length` and decoded byte calculations.
+
+## File-by-file implementation plan
+
+### 1. Contracts
+
+Create:
+
+```text
+src/features/agent-attachments/contracts/dto.ts
+src/features/agent-attachments/contracts/api.ts
+src/features/agent-attachments/contracts/index.ts
+```
+
+Keep contracts serializable. Do not expose classes or functions that require DOM/Node.
+
+Example:
+
+```ts
+export interface AgentAttachmentBudgetDto {
+ maxFiles: number;
+ maxOriginalFileBytes: number;
+ maxTotalOriginalBytes: number;
+ maxOptimizedImageBytes: number;
+ maxEstimatedSerializedBytes: number;
+}
+```
+
+### 2. Core domain
+
+Create:
+
+```text
+src/features/agent-attachments/core/domain/AttachmentBudget.ts
+src/features/agent-attachments/core/domain/AttachmentMime.ts
+src/features/agent-attachments/core/domain/AttachmentErrors.ts
+```
+
+This layer must be pure. No `fs`, no `Electron`, no `React`, no `Buffer` if it needs renderer reuse.
+
+### 3. Renderer optimizer
+
+Create:
+
+```text
+src/features/agent-attachments/renderer/utils/picaImageOptimizer.ts
+```
+
+This file may import `pica`, DOM APIs, and browser canvas APIs. It must not import main process modules.
+
+### 4. Existing renderer integration
+
+Update carefully:
+
+```text
+src/renderer/utils/attachmentUtils.ts
+src/renderer/hooks/useComposerDraft.ts
+```
+
+Do not replace the whole draft flow. Add a narrow call:
+
+```ts
+const prepared = await prepareAgentAttachmentDraft(file);
+```
+
+### 5. Main validation
+
+Create:
+
+```text
+src/features/agent-attachments/main/infrastructure/ServerAttachmentValidator.ts
+```
+
+Then call it from existing IPC validation. Do not move all IPC into the new feature in Phase 1 unless it is trivial.
+
+### 6. UI warnings
+
+Add small rendering components only if existing composer can consume warnings without a broad refactor.
+
+Potential target:
+
+```text
+src/renderer/components/team/messages/MessageComposer.tsx
+```
+
+Keep UI changes minimal.
+
+## Additional code examples
+
+### Domain error class
+
+```ts
+export class AgentAttachmentError extends Error {
+ constructor(readonly failure: AttachmentFailure) {
+ super(failure.userMessage);
+ this.name = 'AgentAttachmentError';
+ }
+}
+
+export function isAgentAttachmentError(error: unknown): error is AgentAttachmentError {
+ return error instanceof AgentAttachmentError;
+}
+```
+
+### MIME classifier
+
+```ts
+export function classifyAttachmentMimeType(mimeType: string): AgentAttachmentKind {
+ const normalized = mimeType.toLowerCase();
+ if (['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(normalized)) return 'image';
+ if (normalized === 'application/pdf') return 'document';
+ if (normalized.startsWith('text/')) return 'text';
+ return 'unsupported';
+}
+```
+
+### Base64 decoded byte estimator
+
+```ts
+export function estimateDecodedBase64Bytes(base64: string): number {
+ const clean = base64.replace(/\s/g, '');
+ const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
+ return Math.floor((clean.length * 3) / 4) - padding;
+}
+```
+
+Do not decode huge base64 just to estimate size.
+
+### Safe async draft update pattern
+
+```ts
+const generation = ++attachmentPreparationGenerationRef.current;
+const result = await prepareAttachment(file);
+if (generation !== attachmentPreparationGenerationRef.current) {
+ return; // stale result after team/message switch
+}
+setDraftAttachments(prev => [...prev, result]);
+```
+
+## More detailed test cases
+
+### Budget estimator table
+
+| Input | Expected |
+|---|---|
+| no attachments, short text | under budget |
+| one 1MB base64 image | serialized estimate greater than decoded bytes |
+| five 1MB images | total serialized limit can fail |
+| base64 with whitespace | decoded byte estimator handles it |
+| empty base64 | invalid attachment error |
+
+### Optimizer table
+
+| Input | Expected |
+|---|---|
+| 320x240 PNG under budget | unchanged or tiny optimized variant |
+| 6000x4000 screenshot | resized to max long edge |
+| transparent PNG | stays PNG |
+| animated GIF | not converted, warning |
+| corrupt PNG | error draft |
+| WebP | accepted if browser decodes, otherwise unsupported |
+
+### UI state table
+
+| Action | Expected |
+|---|---|
+| attach image then remove | warning disappears, object URL revoked |
+| attach too-large image | send disabled with specific reason |
+| edit text after attach | only serialized estimate recalculated |
+| switch team during optimization | stale result ignored |
+| attach unsupported binary | existing path/link fallback or blocked, no base64 blob |
+
+## Extra risk controls
+
+- Keep old constants temporarily and map them to new budget constants to avoid conflicting limits.
+- If `pica` import increases renderer bundle unexpectedly, keep it lazy-loaded only when image attachment is selected.
+- If optimization fails unexpectedly, fail closed for attachments but do not affect text-only sends.
+- Add analytics/log event only with counts/bytes, never filenames if privacy-sensitive.
+
+## Phase 1 exit criteria
+
+Phase 1 is complete only when:
+
+- text-only composer send is unchanged;
+- image drafts show optimized size or clear error;
+- backend rejects oversized serialized payloads;
+- renderer and backend use consistent budget constants;
+- no runtime provider delivery code is changed;
+- old legacy payload shape still works;
+- no base64/data URL appears in UI errors or logs.
+
+## Migration seam from existing code
+
+Existing code should be wrapped, not replaced wholesale.
+
+Current likely call chain:
+
+```text
+MessageComposer -> useComposerDraft -> attachmentUtils.fileToAttachmentPayload -> teams IPC -> validateAttachments -> sendMessageToTeam
+```
+
+Phase 1 seam:
+
+```text
+attachmentUtils.fileToAttachmentPayload
+ -> prepareAgentAttachmentDraft
+ -> returns legacy-compatible payload plus metadata/warnings
+
+main validateAttachments
+ -> ServerAttachmentValidator.validateLegacyPayloads
+```
+
+Do not change `sendMessageToTeam` signature in Phase 1.
+
+## More concrete backend validator
+
+```ts
+export interface ServerAttachmentValidationInput {
+ messageText: string;
+ attachments: Array<{ data: string; mimeType: string; filename?: string }>;
+ budget?: Partial;
+}
+
+export interface ServerAttachmentValidationOutput {
+ ok: true;
+ normalized: NormalizedLegacyAttachment[];
+ estimatedSerializedBytes: number;
+ warnings: AttachmentWarning[];
+} | {
+ ok: false;
+ failure: AttachmentFailure;
+};
+```
+
+Usage:
+
+```ts
+const validation = serverAttachmentValidator.validateLegacyPayloads({
+ messageText,
+ attachments,
+});
+if (!validation.ok) {
+ throw new Error(validation.failure.userMessage);
+}
+```
+
+### Validation order
+
+Order matters for predictable user errors.
+
+1. attachment count;
+2. base64 validity;
+3. decoded bytes per file;
+4. total decoded bytes;
+5. MIME support;
+6. estimated serialized payload bytes;
+7. warning collection.
+
+Do not compute JSON payload with unbounded decoded buffers.
+
+## Renderer optimizer cancellation
+
+```ts
+export interface AttachmentPreparationJob {
+ id: string;
+ cancel(): void;
+ promise: Promise;
+}
+```
+
+If using AbortController:
+
+```ts
+const controller = new AbortController();
+const promise = prepareAgentAttachmentDraft(file, { signal: controller.signal });
+return { id, cancel: () => controller.abort(), promise };
+```
+
+If pica cannot fully abort, still ignore stale results by generation id.
+
+## Memory safety
+
+Large images can pressure renderer memory. Keep rules strict.
+
+- Reject dimensions above max megapixels before full resize when possible.
+- Release `ImageBitmap` with `imageBitmap.close()` after resize.
+- Revoke object URLs.
+- Avoid storing duplicate base64 strings if optimized variant replaces original for send.
+- Do not put raw base64 in React component props beyond draft state if avoidable.
+
+## Phase 1 bug traps and prevention
+
+| Trap | Prevention |
+|---|---|
+| Backend accepts unsafe payload because renderer already warned | backend validator is mandatory |
+| UI warning says optimized but send uses original huge base64 | send path chooses optimized variant or blocks |
+| GIF silently becomes static image | explicit GIF policy, test it |
+| transparent PNG becomes white/black JPEG | alpha test and PNG preservation |
+| stale optimization adds attachment to wrong team draft | generation id check |
+| file name path traversal appears in future artifact path | sanitize filenames now |
+| tests rely on browser-only APIs in Node | keep optimizer tests in jsdom/browser-compatible environment or mock pica |
+
+## Extra test skeletons
+
+```ts
+describe('ServerAttachmentValidator', () => {
+ it('rejects payload by serialized size even when decoded bytes are under old limit', () => {
+ const image = makeBase64OfSize(6_000_000);
+ const result = validator.validateLegacyPayloads({
+ messageText: 'x',
+ attachments: [{ data: image, mimeType: 'image/png', filename: 'large.png' }],
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.failure.code).toBe('attachment_serialized_payload_too_large');
+ });
+});
+```
+
+```ts
+describe('picaImageOptimizer', () => {
+ it('does not flatten transparent PNG to JPEG', async () => {
+ const result = await optimizeImageForAgentAttachment(transparentPngFile);
+ expect(result.mimeType).toBe('image/png');
+ });
+});
+```
+
+## Detailed Implementation Checklist
+
+### Step 1 - Add pure domain module
+
+Create a feature-local domain module with no Electron, DOM, or filesystem dependency.
+
+Suggested files:
+
+```text
+src/features/agent-attachments/shared/types.ts
+src/features/agent-attachments/shared/budgets.ts
+src/features/agent-attachments/shared/capabilities.ts
+src/features/agent-attachments/shared/validation.ts
+src/features/agent-attachments/shared/index.ts
+```
+
+The first implementation should be intentionally boring:
+
+```ts
+export function classifyAttachmentMime(mimeType: string): AttachmentKind {
+ if (mimeType === 'image/png') return 'image';
+ if (mimeType === 'image/jpeg') return 'image';
+ if (mimeType === 'image/webp') return 'image';
+ if (mimeType === 'application/pdf') return 'file';
+ if (mimeType.startsWith('text/')) return 'file';
+ return 'unsupported';
+}
+```
+
+Avoid clever extension guessing in v1. Extension fallback can be a later hardening step if real users need it.
+
+### Step 2 - Add renderer optimizer behind composer-only call site
+
+Keep optimization in renderer because browser APIs are good at image decode and `pica` is browser-oriented.
+
+```ts
+export interface OptimizeImageForAgentInput {
+ file: File;
+ budget: ImageOptimizationBudget;
+}
+
+export interface OptimizeImageForAgentResult {
+ original: BrowserAttachmentArtifact;
+ optimized: BrowserAttachmentArtifact;
+ warnings: string[];
+}
+```
+
+Implementation order:
+
+1. Decode image dimensions with `createImageBitmap` where available.
+2. Compute target dimensions from total batch budget and per-image max dimension.
+3. Use `pica.resize` into canvas.
+4. Encode JPEG/PNG based on input and transparency.
+5. Return warnings, not thrown errors, for non-fatal resize degradation.
+
+### Step 3 - Add backend validation
+
+Main process must re-check everything received from renderer.
+
+```ts
+export function validateNormalizedAttachmentForSend(input: {
+ attachment: AgentAttachmentPayload;
+ target: ProviderTarget;
+}): AttachmentValidationResult {
+ const capability = resolveAgentAttachmentCapability(input.target);
+ const sizeResult = validateAttachmentBudget(input.attachment, capability);
+ if (!sizeResult.ok) return sizeResult;
+ return { ok: true, warnings: [] };
+}
+```
+
+Backend validation should never trust:
+
+- MIME type from browser alone.
+- File name extension.
+- Renderer-provided optimized dimensions.
+- Renderer-provided `supported: true` capability result.
+
+### Step 4 - Wire UI warnings without changing delivery
+
+Before provider adapters are implemented, UI can show warnings but must not pretend unsupported providers work.
+
+Safe UX:
+
+- Allow attach, show preview.
+- On send, block if selected target cannot receive the attachment yet.
+- Explain which phase/provider support is missing.
+- Keep text-only send untouched.
+
+## Phase 1 Exit Criteria
+
+Phase 1 is complete when:
+
+- Text-only composer behavior is unchanged.
+- Image preview still works for small images.
+- Large image preview shows optimized size and warnings.
+- Unsupported file type produces a clear local validation error.
+- Backend rejects forged oversized attachment metadata.
+- No provider delivery path receives new attachment data yet unless explicitly wired in later phases.
+
+## Edge Case Matrix
+
+| Case | Expected behavior |
+|---|---|
+| Animated GIF | Treat as unsupported for image delivery in v1, or convert first frame only with explicit warning if implemented. |
+| Transparent PNG | Prefer PNG if small, JPEG only if transparency is absent or user accepts flattened background. |
+| Huge panorama | Downscale by max edge and total pixel budget. |
+| Tiny image | Do not upscale. |
+| Corrupt image | Show decode failed, do not send attachment. |
+| HEIC on macOS | Do not promise support unless decode pipeline is explicitly tested. |
+| Clipboard image with no filename | Generate stable display name like `clipboard-image.png`. |
+| Same image attached twice | Keep two attachment ids, do not dedupe content silently. |
+| Multiple images exceed total budget | Optimize all proportionally, then block if still over cap. |
+| User switches target after attaching | Recompute warnings for new provider/model before send. |
+
+## Common Bug Patterns to Avoid
+
+- Storing base64 in message JSON. This can bloat inboxes and break process stdin.
+- Mutating the original attachment when optimization runs.
+- Letting renderer decide final support without backend validation.
+- Showing “sent” when attachment was dropped from provider payload.
+- Collapsing all failures into generic “delivery failed”.
+- Running optimization in Electron main with a new native dependency right before release.
+- Changing current file attachment behavior for text/PDF before image flow is stable.
+
+## Focused Test Examples
+
+```ts
+describe('attachment budgets', () => {
+ it('blocks a single optimized image over hard cap', () => {
+ const result = validateAttachmentBudget(
+ fakeImage({ sizeBytes: 12 * 1024 * 1024 }),
+ codexVisionCapability()
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('attachment_too_large');
+ });
+
+ it('does not upscale small images', () => {
+ const plan = planImageResize({ width: 320, height: 200 }, { maxEdge: 1600 });
+ expect(plan.width).toBe(320);
+ expect(plan.height).toBe(200);
+ });
+});
+```
+
+## Manual QA Script
+
+Use a clean dev profile and verify:
+
+1. Attach a 200 KB PNG screenshot - preview appears, no warning.
+2. Attach a 12 MB PNG screenshot - preview appears, optimized size shown.
+3. Attach three screenshots - total budget warning is deterministic.
+4. Attach a `.txt` file - current file behavior remains unchanged.
+5. Attach a corrupt image renamed `.png` - decode error appears.
+6. Switch target from Claude to non-vision OpenCode model - unsupported warning appears.
+7. Remove attachment - warnings clear.
+
+## Rollback Plan
+
+If Phase 1 causes UI instability:
+
+- Keep domain files, but remove composer call to optimizer.
+- Leave backend validation unused.
+- No persisted migration is needed if message schema was not changed.
+
+
+## Implementation Safeguards
+
+### Keep Phase 1 read-only for provider delivery
+
+Phase 1 should not change how messages are sent to agents. It should only add normalization, previews, warnings, and backend validation primitives.
+
+Safe call sites:
+
+- Composer attachment selection.
+- Composer warning rendering.
+- Draft serialization of normalized metadata.
+- Backend validation helper tests.
+
+Unsafe call sites for Phase 1:
+
+- Claude delivery transport.
+- Codex runtime invocation.
+- OpenCode prompt ledger.
+- Team launch/provisioning.
+- Member runtime liveness.
+
+### Suggested internal state machine
+
+```ts
+type AttachmentPrepareState =
+ | { status: 'idle' }
+ | { status: 'decoding'; attachmentId: string }
+ | { status: 'optimizing'; attachmentId: string; progress?: number }
+ | { status: 'ready'; attachment: AgentAttachmentPayload }
+ | { status: 'blocked'; attachmentId: string; reason: AttachmentDeliveryFailureCode }
+ | { status: 'failed'; attachmentId: string; error: string };
+```
+
+UI rule:
+
+- `blocked` means user can fix/remove/change target.
+- `failed` means local processing failed.
+- Neither state should enqueue runtime delivery.
+
+### Budget planning algorithm
+
+Use deterministic, simple budget allocation. Do not optimize each image independently to the max, because a batch can still exceed total budget.
+
+```ts
+export function allocateImageBudgets(input: {
+ images: ImageCandidate[];
+ totalMaxBytes: number;
+ perImageMaxBytes: number;
+}): ImageBudgetAllocation[] {
+ const perImageFairShare = Math.floor(input.totalMaxBytes / Math.max(1, input.images.length));
+ const targetBytes = Math.min(input.perImageMaxBytes, perImageFairShare);
+ return input.images.map((image) => ({ imageId: image.id, targetBytes }));
+}
+```
+
+If the optimized output is still above target:
+
+1. Reduce dimensions down to min edge threshold.
+2. Reduce JPEG quality down to minimum acceptable quality.
+3. If still too large, block and explain.
+
+Do not loop indefinitely.
+
+### Cancellation edge cases
+
+| User action | Safe behavior |
+|---|---|
+| Removes image during optimization | Cancel or ignore result by generation id. |
+| Adds image then switches team | Cancel or detach optimization result from old draft. |
+| Switches provider/model | Recompute capability warnings without re-encoding if artifact is reusable. |
+| Sends while optimization pending | Disable send or show “attachment still processing”. |
+| Closes app mid-optimization | No partially written artifact should be treated as ready. |
+
+### Generation id pattern
+
+```ts
+let prepareGeneration = 0;
+
+async function prepareAttachments(files: File[]) {
+ const generation = ++prepareGeneration;
+ const result = await optimize(files);
+ if (generation !== prepareGeneration) return;
+ setPreparedAttachments(result);
+}
+```
+
+This avoids stale async results restoring removed attachments.
+
+### Phase 1 PR checklist
+
+- No provider delivery path changed.
+- No launch/runtime tests need snapshot updates.
+- Text-only message send still works manually.
+- Image preview removal cannot leave hidden payload in draft.
+- Backend validation is stricter than renderer validation.
+- All user-visible errors are actionable.
+
+
+## Failure Injection Tests for Phase 1
+
+Add tests that intentionally simulate bad renderer or corrupted local state.
+
+```ts
+describe('attachment backend validation hardening', () => {
+ it('rejects renderer supplied absolute paths outside managed storage', () => {
+ const result = validateAttachmentStorageReference({
+ artifactId: 'att_1',
+ path: '/Users/belief/.ssh/id_rsa',
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('attachment_artifact_path_unsafe');
+ });
+
+ it('rejects metadata that claims small size but file is large', async () => {
+ const result = await validateAttachmentArtifactOnDisk({
+ expectedSizeBytes: 100,
+ actualPath: fixturePath('large-image.png'),
+ maxBytes: 1024,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('attachment_too_large');
+ });
+});
+```
+
+## Browser and Platform Edge Cases
+
+| Edge case | Safe implementation note |
+|---|---|
+| Safari/WebKit image decode differences | Use feature detection, not browser assumptions. |
+| macOS clipboard TIFF/HEIC | Treat unsupported until explicitly converted/tested. |
+| EXIF orientation | Prefer decode path that respects orientation or normalize orientation during canvas draw. |
+| Color profiles | Accept minor color shift for screenshots, do not promise color-managed output. |
+| Canvas memory pressure | Bound megapixels before drawing. |
+| Pica worker failure | Fall back to canvas resize with warning, or block with clear error. |
+| Transparent UI screenshot | Avoid JPEG flattening unless transparency absent or user warning is shown. |
+| Very long screenshot | Limit max edge and total pixels to prevent huge canvas allocation. |
+
+## Memory Safety Budget
+
+Renderer memory is the main Phase 1 risk.
+
+Recommended starting limits:
+
+```ts
+export const ATTACHMENT_IMAGE_LIMITS = {
+ maxInputBytes: 20 * 1024 * 1024,
+ maxInputPixels: 32_000_000,
+ maxOutputBytesPerImage: 4 * 1024 * 1024,
+ maxOutputBytesTotal: 8 * 1024 * 1024,
+ maxOutputEdge: 2400,
+ minJpegQuality: 0.72,
+ defaultJpegQuality: 0.86,
+};
+```
+
+These are release-safe starting values, not final product limits. They should be tuned after real usage.
+
+## Phase 1 Stop Conditions
+
+Stop implementation and reassess if any of these happen:
+
+- Optimized images are stored as base64 in persisted message JSON.
+- Main process needs a new native image dependency.
+- Existing file attachments stop sending.
+- Composer draft state becomes provider-specific.
+- Text-only messages require schema migration.
+
+
+## File-Level Implementation Plan
+
+Suggested new files:
+
+```text
+src/features/agent-attachments/shared/types.ts
+src/features/agent-attachments/shared/budgets.ts
+src/features/agent-attachments/shared/capabilities.ts
+src/features/agent-attachments/shared/validation.ts
+src/features/agent-attachments/shared/storageIds.ts
+src/features/agent-attachments/renderer/optimizeImageForAgent.ts
+src/features/agent-attachments/renderer/usePreparedAttachments.ts
+src/features/agent-attachments/main/validateAttachmentArtifact.ts
+src/features/agent-attachments/main/attachmentArtifactStore.ts
+```
+
+Suggested tests:
+
+```text
+test/features/agent-attachments/budgets.test.ts
+test/features/agent-attachments/capabilities.test.ts
+test/features/agent-attachments/validation.test.ts
+test/features/agent-attachments/attachmentArtifactStore.test.ts
+```
+
+### Storage id validation
+
+```ts
+const SAFE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,120}$/;
+
+export function assertSafeAttachmentStorageId(name: string, value: string): void {
+ if (!SAFE_ID_RE.test(value)) {
+ throw new Error(`Invalid ${name}`);
+ }
+}
+```
+
+Use this for `teamName`, `messageId`, and `attachmentId` before building artifact paths.
+
+### Managed path resolver
+
+```ts
+export function resolveAttachmentArtifactPath(input: {
+ teamRoot: string;
+ teamName: string;
+ messageId: string;
+ attachmentId: string;
+ fileName: 'original.png' | 'original.jpg' | 'optimized.png' | 'optimized.jpg' | 'thumb.jpg' | 'meta.json';
+}): string {
+ assertSafeAttachmentStorageId('teamName', input.teamName);
+ assertSafeAttachmentStorageId('messageId', input.messageId);
+ assertSafeAttachmentStorageId('attachmentId', input.attachmentId);
+
+ const base = path.resolve(input.teamRoot, input.teamName, 'attachments', input.messageId, input.attachmentId);
+ const resolved = path.resolve(base, input.fileName);
+ if (!resolved.startsWith(base + path.sep)) {
+ throw new Error('Attachment artifact path escaped managed directory');
+ }
+ return resolved;
+}
+```
+
+### Renderer optimizer guardrails
+
+```ts
+export async function optimizeImageForAgent(input: OptimizeImageForAgentInput): Promise {
+ const bitmap = await createImageBitmap(input.file);
+ assertPixelBudget(bitmap.width, bitmap.height, input.budget.maxInputPixels);
+
+ const target = planResizeDimensions({
+ width: bitmap.width,
+ height: bitmap.height,
+ maxEdge: input.budget.maxOutputEdge,
+ });
+
+ const canvas = document.createElement('canvas');
+ canvas.width = target.width;
+ canvas.height = target.height;
+
+ await resizeWithPicaOrFallback(bitmap, canvas);
+ const blob = await encodeCanvasForProvider(canvas, input.budget);
+ return buildOptimizationResult(input.file, blob, target);
+}
+```
+
+Do not implement infinite quality-search loops. Use a small bounded set of quality attempts like `[0.86, 0.8, 0.74, 0.72]`.
+
+### Phase 1 exact acceptance criteria
+
+- Backend path resolver rejects traversal in tests.
+- Budget tests cover single, multiple, and oversized images.
+- Renderer hook ignores stale optimization result after removal.
+- Composer cannot send while any attachment is `optimizing`.
+- Existing text-only composer tests do not need behavioral changes.
+- No provider delivery module imports renderer optimizer.
+
+
+## Phase 1 Deep Review Addendum
+
+### Exact UI states
+
+Composer should expose these states clearly:
+
+| State | Send allowed | Copy |
+|---|---:|---|
+| No attachments | yes | normal text-only behavior |
+| Attachments optimizing | no | `Preparing image...` |
+| Attachments ready and supported | yes | optional optimized size summary |
+| Attachment too large after optimization | no | `Image is too large after optimization. Remove it or use a smaller image.` |
+| Target model unsupported | no | `Selected model does not support image attachments.` |
+| Decode failed | no | `Could not read this image.` |
+| Artifact persistence failed | no | `Could not prepare attachment for sending.` |
+
+### Accessibility and UX guardrails
+
+- Warning text should be readable without relying on color.
+- Remove button must remain available for blocked attachments.
+- If multiple attachments have warnings, show per-attachment reason and aggregate reason near send.
+- Do not hide text draft when attachment fails.
+- If user removes the blocked attachment, send button should recover immediately.
+
+### Persistence safety
+
+When user sends a message with attachments:
+
+1. Generate message id first.
+2. Persist original/optimized artifacts under message id.
+3. Persist message record referencing attachment ids.
+4. Begin provider delivery.
+
+Do not begin provider delivery before message record and artifacts are durably written.
+
+This avoids a retry state where ledger knows about a message but artifacts are missing because write happened later.
+
+### Phase 1 anti-regression tests
+
+```ts
+it('does not serialize image bytes into message json', () => {
+ const message = buildMessageRecordWithAttachment(fakeAttachment());
+ expect(JSON.stringify(message)).not.toContain('base64');
+ expect(JSON.stringify(message)).not.toContain('data:image');
+});
+
+it('restores send button after removing blocked attachment', () => {
+ const state = reducer(blockedAttachmentState(), removeAttachment('att_1'));
+ expect(selectCanSend(state)).toBe(true);
+});
+```
+
+### Phase 1 implementation order
+
+Implement in this order to reduce bug risk:
+
+1. Pure budget/capability tests.
+2. Safe id/path helpers.
+3. Artifact store tests.
+4. Renderer optimization hook without composer integration.
+5. Composer preview/warning integration.
+6. Send blocking.
+7. Backend validation on send.
+
+If a later step fails, earlier pure modules remain useful and low-risk.
+
+
+## Phase 1 Implementation Contract Addendum
+
+### Exact domain types
+
+```ts
+export type AttachmentWarningCode =
+ | 'image_was_resized'
+ | 'image_was_reencoded'
+ | 'image_quality_reduced'
+ | 'model_support_unknown'
+ | 'model_does_not_support_images'
+ | 'file_type_not_supported';
+
+export interface AttachmentWarning {
+ code: AttachmentWarningCode;
+ message: string;
+ attachmentId?: string;
+}
+
+export interface ImageOptimizationBudget {
+ maxInputBytes: number;
+ maxInputPixels: number;
+ maxOutputBytesPerImage: number;
+ maxOutputBytesTotal: number;
+ maxOutputEdge: number;
+ jpegQualityAttempts: readonly number[];
+}
+```
+
+Keep these in shared pure code. Renderer and main may import types and pure validators, but renderer-specific optimizer code must not be imported by main.
+
+### Quality strategy
+
+Use a deterministic quality strategy instead of adaptive unbounded loops.
+
+```ts
+const JPEG_QUALITY_ATTEMPTS = [0.86, 0.82, 0.78, 0.74, 0.72] as const;
+
+for (const quality of JPEG_QUALITY_ATTEMPTS) {
+ const blob = await encodeCanvas(canvas, 'image/jpeg', quality);
+ if (blob.size <= targetBytes) return blob;
+}
+
+throw new AgentAttachmentError(
+ 'attachment_too_large',
+ 'Image is too large after optimization. Remove it or use a smaller image.'
+);
+```
+
+PNG strategy:
+
+- Keep PNG for small screenshots and transparency.
+- Re-encode to JPEG only when transparency is absent and size requires it.
+- If transparency exists and PNG remains too large, block with clear reason instead of silently flattening unless product explicitly accepts flattening.
+
+### UI copy table
+
+| Code | Copy |
+|---|---|
+| `attachment_too_large` | `Image is too large after optimization. Remove it or use a smaller image.` |
+| `attachment_type_unsupported` | `This file type is not supported for agent image delivery.` |
+| `attachment_model_unsupported` | `Selected model does not support image attachments. Switch model or remove the image.` |
+| `attachment_optimization_failed` | `Could not prepare this image for sending.` |
+| `attachment_artifact_missing` | `Prepared image file is missing. Remove and attach the image again.` |
+
+Copy should be short in UI. Detailed diagnostics can go into copy diagnostics/logs.
+
+### Batch behavior
+
+Multiple images should preserve user order.
+
+```ts
+export function sortAttachmentsForDelivery(attachments: AgentAttachmentPayload[]): AgentAttachmentPayload[] {
+ return [...attachments].sort((a, b) => a.order - b.order);
+}
+```
+
+Do not sort by size or file name because the prompt may refer to “first image” and “second image”.
+
+### Draft persistence edge cases
+
+- Draft can reference attachment ids before final message id exists.
+- On send, draft attachment ids should be reparented or copied into message artifact directory.
+- If reparenting fails, send should stop before provider delivery.
+- Removing attachment from draft should remove draft artifact eventually, but not synchronously block UI.
+
+
+## Implementation Readiness Addendum
+
+### Definition of Ready for Phase 1
+
+Before coding Phase 1:
+
+- Confirm exact composer components that own attachment state.
+- Confirm where message ids are generated for user sends.
+- Confirm where existing attachments are persisted today.
+- Confirm whether current image previews store bytes, paths, or blobs.
+- Confirm no provider delivery changes are included in the Phase 1 PR.
+
+### Exact reducer-style behavior
+
+```ts
+type ComposerAttachmentAction =
+ | { type: 'attachment_added'; file: File; draftAttachmentId: string }
+ | { type: 'attachment_prepare_started'; draftAttachmentId: string; generation: number }
+ | { type: 'attachment_prepare_succeeded'; draftAttachmentId: string; generation: number; payload: AgentAttachmentPayload }
+ | { type: 'attachment_prepare_failed'; draftAttachmentId: string; generation: number; error: AgentAttachmentErrorJson }
+ | { type: 'attachment_removed'; draftAttachmentId: string }
+ | { type: 'target_changed'; target: ProviderTarget };
+```
+
+Reducer rule:
+
+- Ignore `attachment_prepare_succeeded` if generation is stale.
+- Ignore prepare results for removed attachment ids.
+- Recompute capability warnings on `target_changed` without reprocessing image bytes.
+- Do not clear text draft when attachment fails.
+
+### Backend artifact write order
+
+```ts
+async function persistMessageAttachments(input: PersistMessageAttachmentsInput): Promise {
+ const messageDir = resolveMessageAttachmentDir(input.teamName, input.messageId);
+ await fs.mkdir(messageDir, { recursive: true });
+
+ const persisted: PersistedAttachment[] = [];
+ for (const attachment of input.attachments) {
+ const paths = resolveAttachmentPaths(input.teamName, input.messageId, attachment.id);
+ await writeFileAtomic(paths.original, attachment.originalBytes);
+ if (attachment.optimizedBytes) await writeFileAtomic(paths.optimized, attachment.optimizedBytes);
+ await writeFileAtomic(paths.meta, JSON.stringify(buildMeta(attachment), null, 2));
+ persisted.push(toPersistedAttachment(paths, attachment));
+ }
+ return { attachments: persisted };
+}
+```
+
+Use atomic writes for metadata and artifacts where practical. A partially written optimized image must not be treated as ready.
+
+### Atomic write requirement
+
+```ts
+async function writeFileAtomic(path: string, bytes: Buffer | string): Promise {
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
+ await fs.writeFile(tmp, bytes);
+ await fs.rename(tmp, path);
+}
+```
+
+If write fails, cleanup tmp best-effort and return typed error.
+
+### Phase 1 additional edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| Disk full during artifact write | Send blocked, text draft preserved, typed error shown. |
+| App crashes after artifacts written before message record | Orphan artifacts may remain, later GC can clean. No message corruption. |
+| App crashes after message record before provider delivery | Message exists with attachments and can be retried later. |
+| Thumbnail write fails | Do not block delivery if original/optimized artifact is valid. Show preview fallback. |
+| Original write succeeds, optimized write fails | Block image delivery unless original fits provider budget. |
+
+
+## Final Phase 1 Acceptance Specs
+
+### Spec 1 - small supported image
+
+```gherkin
+Given a user attaches a 200 KB PNG screenshot
+And the selected target supports images
+When the composer prepares the attachment
+Then the preview is shown
+And the send button remains enabled
+And the persisted message references artifact ids
+And the message JSON does not contain base64
+```
+
+### Spec 2 - oversized image optimized successfully
+
+```gherkin
+Given a user attaches a 12 MB PNG screenshot
+When optimization completes below provider budget
+Then the user sees that the image was optimized
+And the send button is enabled
+And the optimized artifact is used for provider delivery later
+And the original artifact remains available for retry/regeneration
+```
+
+### Spec 3 - oversized image still too large
+
+```gherkin
+Given a user attaches a huge image
+When bounded optimization cannot bring it below budget
+Then the send button is disabled
+And the text draft remains intact
+And the user can remove the image
+And no provider delivery is attempted
+```
+
+### Spec 4 - stale optimization result
+
+```gherkin
+Given a user attaches an image
+And removes it while optimization is running
+When the optimization promise resolves
+Then the removed attachment is not restored
+And the send state reflects the current draft only
+```
+
+### Phase 1 exact PR contract
+
+The Phase 1 PR is acceptable only if:
+
+- It adds shared attachment domain types.
+- It adds budget/capability/validation tests.
+- It adds safe managed artifact path/id helpers.
+- It adds renderer optimization or a clearly documented placeholder if split.
+- It does not change provider delivery behavior.
+- It does not import provider runtime code into renderer optimizer.
+- It does not import attachment feature into launch/provisioning.
+
+### Phase 1 likely review findings to prevent
+
+| Finding | Prevention |
+|---|---|
+| Message JSON contains base64 | persist artifact ids only |
+| Send starts before artifact write | enforce write-before-delivery order |
+| Draft removal race restores attachment | generation id guard |
+| Backend trusts renderer size | stat artifact on disk |
+| Unsupported model warning only in UI | backend validation also blocks |
+
+
+## Phase 1 Pre-Mortem and Extra Safeguards
+
+### Likely Phase 1 mistakes
+
+| Mistake | Concrete prevention |
+|---|---|
+| Optimizer result races with removed attachment | Generation id guard in reducer/hook. |
+| Backend trusts renderer MIME | Backend validates by allowlist and artifact metadata. |
+| Draft artifacts leak forever | Mark draft artifacts and add later GC policy. |
+| UI blocks text-only send after image error removed | Selector tests for `canSend`. |
+| Multiple images reorder | Preserve user-provided order field. |
+| Image-only send unclear | Product decision before coding: allow image-only with default prompt or require text. |
+
+### Image-only message decision
+
+Top options:
+
+1. Require text with image - 🎯 8.5 🛡️ 9 🧠 2, примерно `20-50` строк.
+
+ Safest release behavior. It avoids confusing empty prompt behavior across providers.
+
+2. Allow image-only with generated prompt - 🎯 7 🛡️ 7.5 🧠 4, примерно `60-120` строк.
+
+ Useful UX, but generated prompts can surprise users and differ by action mode.
+
+3. Allow image-only raw - 🎯 5.5 🛡️ 5 🧠 2, примерно `20-40` строк.
+
+ Some providers may accept it, but behavior is inconsistent.
+
+Recommendation: start with option 1 for release.
+
+### Pica wrapper contract
+
+```ts
+export interface ImageResizeEngine {
+ resize(input: {
+ source: ImageBitmap;
+ targetWidth: number;
+ targetHeight: number;
+ }): Promise;
+}
+```
+
+Reason:
+
+- Keeps `pica` behind a tiny interface.
+- Allows tests with fake resize engine.
+- Allows fallback or replacement without changing composer state.
+
+### Quality acceptance rules
+
+- UI screenshots should remain legible at common zoom levels after resize.
+- Do not reduce JPEG quality below configured floor.
+- If text in screenshot becomes unreadable in manual QA, increase budget before release.
+- If multiple images exceed total budget, prefer blocking over making all unreadable.
+
+### Phase 1 contract tests to add before UI wiring
+
+```ts
+it('preserves attachment order for delivery', () => {
+ const sorted = sortAttachmentsForDelivery([
+ fakeImageAttachment({ id: 'b', order: 2 }),
+ fakeImageAttachment({ id: 'a', order: 1 }),
+ ]);
+ expect(sorted.map((item) => item.id)).toEqual(['a', 'b']);
+});
+
+it('does not allow send while attachment is optimizing', () => {
+ expect(selectCanSend(fakeDraft({ attachmentState: 'optimizing' }))).toBe(false);
+});
+```
+
diff --git a/docs/team-management/agent-attachments-phase-2-claude-stream-json-plan.md b/docs/team-management/agent-attachments-phase-2-claude-stream-json-plan.md
new file mode 100644
index 00000000..8549c0ee
--- /dev/null
+++ b/docs/team-management/agent-attachments-phase-2-claude-stream-json-plan.md
@@ -0,0 +1,1179 @@
+# Phase 2 - Claude stream-json attachment delivery adapter
+
+## Summary
+
+Goal: route existing Claude lead attachment delivery through the new attachment planner, preserving current stream-json content block behavior while adding deterministic budgets and diagnostics.
+
+Chosen approach: **extract current Claude serialization into `ClaudeStreamJsonAttachmentAdapter` and call it from `TeamProvisioningService.sendMessageToRun()`**.
+
+🎯 9.0 🛡️ 8.8 🧠 5.8
+Estimated change size: `180-320` LOC.
+
+This phase should not change launch, bootstrap, provider auth, or teammate liveness. It only replaces ad-hoc attachment block assembly with a tested adapter.
+
+## Current behavior to preserve
+
+Current path in `TeamProvisioningService.sendMessageToRun()` builds content blocks:
+
+```ts
+const contentBlocks: Record[] = [{ type: 'text', text: message }];
+
+if (att.mimeType === 'application/pdf') {
+ contentBlocks.push({
+ type: 'document',
+ source: {
+ type: 'base64',
+ media_type: 'application/pdf',
+ data: att.data,
+ },
+ title: att.filename,
+ });
+} else if (att.mimeType === 'text/plain') {
+ // text or base64 document
+} else {
+ contentBlocks.push({
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: att.mimeType,
+ data: att.data,
+ },
+ });
+}
+```
+
+Keep the same Claude content block shape.
+
+## Why use adapter
+
+`TeamProvisioningService` should not know image optimization or provider-specific attachment serialization details. Its responsibility is team lifecycle and message routing.
+
+The adapter gives:
+
+- unit-testable serialization;
+- budget diagnostics before stdin write;
+- future support for variant selection;
+- less risk when adding Codex/OpenCode adapters.
+
+## New adapter sketch
+
+```ts
+export class ClaudeStreamJsonAttachmentAdapter implements AttachmentDeliveryAdapter {
+ readonly runtimeKind = 'claude-stream-json' as const;
+
+ canDeliver(
+ ctx: AttachmentRuntimeContext,
+ attachment: NormalizedAgentAttachment,
+ ): AttachmentCapabilityDecision {
+ if (attachment.kind === 'image') {
+ return allowIfMime(attachment, ['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
+ }
+
+ if (attachment.kind === 'document' || attachment.kind === 'text') {
+ return allow();
+ }
+
+ return block('This attachment type is not supported by Claude.');
+ }
+
+ async prepare(
+ ctx: AttachmentRuntimeContext,
+ attachment: NormalizedAgentAttachment,
+ ): Promise {
+ const variant = selectClaudeVariant(attachment);
+ return {
+ runtimeKind: this.runtimeKind,
+ attachmentId: attachment.id,
+ part: {
+ kind: 'claude-content-block',
+ value: toClaudeContentBlock(attachment, variant),
+ },
+ diagnostics: [`prepared ${attachment.kind} for Claude stream-json`],
+ };
+ }
+}
+```
+
+## Serialization helpers
+
+```ts
+function toClaudeContentBlock(
+ attachment: NormalizedAgentAttachment,
+ variant: AgentAttachmentVariant,
+): Record {
+ if (attachment.kind === 'image') {
+ return {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: variant.mimeType,
+ data: readBase64Variant(variant),
+ },
+ };
+ }
+
+ if (attachment.kind === 'text') {
+ return {
+ type: 'document',
+ source: {
+ type: 'text',
+ media_type: 'text/plain',
+ data: readTextVariant(variant),
+ },
+ title: attachment.originalName,
+ };
+ }
+
+ return {
+ type: 'document',
+ source: {
+ type: 'base64',
+ media_type: attachment.mimeType,
+ data: readBase64Variant(variant),
+ },
+ title: attachment.originalName,
+ };
+}
+```
+
+## `sendMessageToRun` target shape
+
+Before:
+
+```ts
+const contentBlocks = buildInlineInService(message, attachments);
+```
+
+After:
+
+```ts
+const contentBlocks: Record[] = [{ type: 'text', text: message }];
+
+if (attachments?.length) {
+ const prepared = await this.attachmentDeliveryPlanner.prepareAll(
+ {
+ teamName: run.teamName,
+ providerId: run.providerId,
+ modelId: run.model,
+ runtimeKind: 'claude-stream-json',
+ deliveryTarget: 'lead',
+ },
+ await this.attachmentNormalizer.normalizeLegacyPayloads(attachments),
+ );
+
+ for (const part of prepared) {
+ if (part.part.kind !== 'claude-content-block') {
+ throw new Error('Internal attachment planner returned non-Claude part for Claude runtime');
+ }
+ contentBlocks.push(part.part.value);
+ }
+}
+```
+
+## Payload write safety
+
+Before writing stdin:
+
+```ts
+const payload = JSON.stringify({
+ type: 'user',
+ message: {
+ role: 'user',
+ content: contentBlocks,
+ },
+});
+
+this.attachmentBudgetValidator.assertSerializedPayloadWithinBudget(payload);
+```
+
+If blocked, return actionable error:
+
+```text
+Attachments are too large for Claude stream-json input after optimization. Remove one image or send a smaller screenshot.
+```
+
+## Edge cases
+
+### Existing text-only sends
+
+No change. If `attachments` is empty, the planner is not called.
+
+### Existing PDF support
+
+Keep current content block shape. Do not optimize PDFs in this phase.
+
+### Non-UTF text files
+
+Keep current behavior: try UTF-8, fallback to base64 document if replacement characters appear.
+
+### Runtime process exits after send
+
+Do not attribute exit to attachment unless the error path can prove stdin write/payload size failure. This phase should only make pre-send failures visible.
+
+### Claude image support in wrong mode
+
+Team lead is long-lived stream-json, so supported. Do not use `claude -p` as e2e validation for this path.
+
+### Multiple images
+
+Send all if under budget. If over budget, send none.
+
+## Diagnostics
+
+Add bounded diagnostics only:
+
+```text
+Prepared 2 attachments for Claude stream-json: image/jpeg 612KB, image/png 124KB.
+```
+
+Never log:
+
+- base64 content;
+- full file paths unless already user-selected and safe;
+- API keys;
+- raw JSON payload.
+
+## Test plan
+
+### Unit
+
+- image attachment serializes to Claude `image` block;
+- PDF serializes to Claude `document` block;
+- UTF-8 text serializes to `document` text source;
+- non-UTF text falls back to base64 document;
+- planner rejects unsupported mime;
+- serialized payload over budget rejects before stdin write.
+
+### Service tests
+
+- text-only `sendMessageToRun` does not call planner;
+- safe image calls planner and writes stream-json with image block;
+- over-budget image throws user-visible error and does not write stdin;
+- failure does not mark team offline by itself.
+
+Suggested focused checks:
+
+```bash
+pnpm vitest run src/features/agent-attachments/**/*.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/ipc/teams.test.ts
+pnpm typecheck --pretty false
+```
+
+## Safety checklist
+
+- Current Claude content block schema preserved.
+- No Codex/OpenCode paths touched.
+- No launch/provisioning path touched.
+- No live provider calls in unit tests.
+- Existing UI attachment workflow remains compatible.
+
+## Deep implementation details
+
+### Refactor target
+
+The desired refactor is small and reversible.
+
+Before:
+
+```ts
+private async sendMessageToRun(run, message, attachments) {
+ const contentBlocks = [{ type: 'text', text: message }];
+ // inline attachment serialization here
+ stdin.write(JSON.stringify({ ...contentBlocks }) + '\n');
+}
+```
+
+After:
+
+```ts
+private async sendMessageToRun(run, message, attachments) {
+ const contentBlocks = await this.buildClaudeLeadContentBlocks(run, message, attachments);
+ const payload = this.buildClaudeStreamJsonUserPayload(contentBlocks);
+ this.agentAttachments.assertPayloadBudget(payload, { runtime: 'claude-stream-json' });
+ await this.writeToLeadStdin(run, payload);
+}
+```
+
+This keeps `sendMessageToRun()` readable and moves serialization into testable helpers.
+
+### Helper extraction plan
+
+```ts
+private async buildClaudeLeadContentBlocks(
+ run: ProvisioningRun,
+ message: string,
+ attachments?: LegacyAttachmentPayload[],
+): Promise[]> {
+ const blocks: Record[] = [{ type: 'text', text: message }];
+ if (!attachments?.length) return blocks;
+
+ const prepared = await this.agentAttachments.prepareForRuntime({
+ teamName: run.teamName,
+ providerId: run.providerId,
+ modelId: run.model,
+ runtimeKind: 'claude-stream-json',
+ deliveryTarget: 'lead',
+ }, attachments);
+
+ for (const item of prepared) {
+ assertPreparedPartKind(item, 'claude-content-block');
+ blocks.push(item.part.value);
+ }
+ return blocks;
+}
+```
+
+### Content block compatibility tests
+
+Snapshot the exact old shape.
+
+```ts
+expect(toClaudeContentBlock(imageAttachment)).toEqual({
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: 'image/png',
+ data: '...',
+ },
+});
+```
+
+For text:
+
+```ts
+expect(toClaudeContentBlock(textAttachment)).toEqual({
+ type: 'document',
+ source: {
+ type: 'text',
+ media_type: 'text/plain',
+ data: 'hello',
+ },
+ title: 'notes.txt',
+});
+```
+
+### Error handling
+
+Use typed attachment errors and convert at IPC boundary.
+
+```ts
+try {
+ await service.sendMessageToTeam(teamName, message, attachments);
+} catch (error) {
+ if (isAttachmentValidationError(error)) {
+ throw new Error(error.userMessage);
+ }
+ throw error;
+}
+```
+
+Do not catch and convert provider/runtime errors here.
+
+### More edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| Claude lead is alive but stdin not writable | existing `process stdin is not writable` error wins |
+| Payload over budget | no stdin write, no message marked delivered |
+| Attachment adapter throws unsupported mime | user-visible attachment error, team remains alive |
+| Claude process exits after successful stdin write | existing runtime process close handling owns it |
+| PDF title contains slash/newline | sanitized title in content block |
+| Text file is empty | send empty text document or block? Prefer send with warning `empty text file` |
+| Message text empty but image present | allow if composer supports image-only send; text block can be empty or omitted consistently |
+| Multiple attachments include one invalid | block all, do not partial-send |
+| Optimized variant missing | rebuild from legacy base64 or block with retryable local error |
+
+### Why not change delivery proof
+
+Claude lead message delivery currently depends on process stdin write and subsequent assistant stream/result. This phase does not add proof. It only makes payload construction safe.
+
+Do not add new notifications like “image delivered” because it would imply semantic understanding.
+
+### Regression traps
+
+- Accidentally using optimized JPEG for transparent PNG without user-visible warning.
+- Forgetting to include `title` for documents.
+- Throwing generic `Internal attachment planner returned...` to user instead of diagnostics.
+- Double-validating text-only messages and blocking them due missing attachment metadata.
+- Logging full stream-json payload in debug output.
+
+## File-by-file implementation plan
+
+### 1. Add adapter
+
+Create:
+
+```text
+src/features/agent-attachments/main/adapters/output/ClaudeStreamJsonAttachmentAdapter.ts
+```
+
+This file should depend only on feature contracts/core and small shared helpers.
+
+### 2. Add facade method
+
+In feature composition, expose:
+
+```ts
+prepareClaudeStreamJsonContentBlocks(input): Promise[]>
+```
+
+or a generic:
+
+```ts
+prepareForRuntime(ctx, attachments): Promise
+```
+
+Prefer generic if Phase 3/4 will reuse it soon. Prefer Claude-specific if generic abstraction becomes too abstract too early. The plan's recommendation remains generic, but keep the public facade small.
+
+### 3. Update TeamProvisioningService
+
+Change only the attachment serialization part of `sendMessageToRun()`.
+
+Do not change:
+
+- run tracking;
+- process liveness checks;
+- stdin writable checks;
+- lead activity updates;
+- close/error handling.
+
+### 4. Add focused tests
+
+Update existing `TeamProvisioningService.test.ts` only around send message attachment cases. Add adapter unit tests under feature tests.
+
+## Compatibility shim
+
+Because Phase 1 may still use legacy payloads, adapter should accept normalized attachments from a shim.
+
+```ts
+async function normalizeForClaudeAdapter(
+ legacy: LegacyTeamMessageAttachment[],
+): Promise {
+ return this.normalizer.normalizeLegacyPayloads(legacy, {
+ preferredRuntime: 'claude-stream-json',
+ });
+}
+```
+
+## Detailed failure cases and expected messages
+
+| Failure | User message | Internal diagnostic |
+|---|---|---|
+| payload over serialized budget | `Attachments are too large for Claude input after optimization.` | include estimated bytes and limit |
+| unsupported MIME | `This attachment type is not supported by Claude.` | include MIME and filename sanitized |
+| corrupt image missed by renderer | `Cannot send image because it could not be decoded.` | include attachment id only |
+| stdin not writable | existing `Team process stdin is not writable` | not attachment diagnostic |
+| Claude API says image invalid | preserve provider error | not rewritten as optimizer error |
+
+## Review checklist
+
+- Adapter output equals previous content block shape for same input.
+- Payload budget check happens before `stdin.write`.
+- Error handling does not mark team offline.
+- No base64 in thrown error message.
+- No tests require Claude live auth.
+- Text-only send test still passes without creating feature attachments.
+
+## More examples
+
+### Image block
+
+```ts
+const block = adapter.toClaudeContentBlock(imageAttachment);
+expect(block).toMatchObject({
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: 'image/jpeg',
+ },
+});
+expect(String((block.source as any).data)).toHaveLength(imageBase64.length);
+```
+
+### Full payload budget assertion
+
+```ts
+const payload = buildClaudeStreamJsonPayload([{ type: 'text', text }, imageBlock]);
+expect(() => validator.assertWithinBudget(payload)).not.toThrow();
+```
+
+### Negative payload budget assertion
+
+```ts
+const huge = makeFakeBase64(8_000_000);
+expect(() => buildAndValidatePayload(huge)).toThrowAgentAttachmentError(
+ 'attachment_serialized_payload_too_large',
+);
+```
+
+## Phase 2 exit criteria
+
+Phase 2 is complete only when:
+
+- old Claude image/PDF/text content block shapes are preserved;
+- text-only sends bypass attachment adapter;
+- oversized attachment blocks before stdin write;
+- adapter errors do not mark team offline;
+- copied diagnostics include attachment summary but no base64;
+- no Codex/OpenCode path changes are included.
+
+## Migration seam
+
+Replace only this concern in `TeamProvisioningService`:
+
+```text
+legacy attachments -> Claude content blocks
+```
+
+Do not touch:
+
+```text
+run selection
+stdin lifecycle
+process close handling
+lead activity state
+message persistence
+```
+
+## Claude adapter detailed API
+
+```ts
+export interface ClaudeContentBlockBuildInput {
+ messageText: string;
+ attachments: NormalizedAgentAttachment[];
+ budget: AgentAttachmentBudget;
+}
+
+export interface ClaudeContentBlockBuildOutput {
+ contentBlocks: Record[];
+ estimatedSerializedBytes: number;
+ diagnostics: string[];
+}
+```
+
+This allows tests to assert payload size without writing to stdin.
+
+## Safe payload builder
+
+```ts
+export function buildClaudeStreamJsonUserPayload(
+ contentBlocks: Record[],
+): string {
+ return JSON.stringify({
+ type: 'user',
+ message: {
+ role: 'user',
+ content: contentBlocks,
+ },
+ });
+}
+```
+
+Keep this helper tiny and deterministic.
+
+## Stdin write failure handling
+
+Attachment errors happen before write. Stdin write errors are runtime errors.
+
+```ts
+try {
+ const payload = buildClaudeStreamJsonUserPayload(blocks);
+ this.agentAttachments.assertPayloadBudget(payload);
+ await writeLine(stdin, payload);
+} catch (error) {
+ if (isAgentAttachmentError(error)) throw error;
+ throw new Error(`Team "${run.teamName}" process stdin is not writable`);
+}
+```
+
+Do not wrap provider/runtime errors as attachment errors.
+
+## More Claude-specific edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| `image/webp` sent to Claude | allow only if current existing path allowed it; otherwise block consistently |
+| `image/gif` animated | preserve existing behavior if under budget, but warn in Phase 1 |
+| empty message with image | allow only if current composer allows it; otherwise composer-level validation |
+| PDF over budget | block with attachment size message |
+| text file with invalid UTF-8 | fallback base64 document as current code did |
+| Claude returns `Could not process image` | show provider error, do not blame optimizer unless image validation failed locally |
+| CLI output includes image processing error | include bounded stderr tail in diagnostics through existing runtime mechanisms |
+
+## Test skeleton for no stdin write on budget failure
+
+```ts
+it('does not write to stdin when attachment payload exceeds Claude budget', async () => {
+ const stdin = fakeWritable();
+ await expect(service.sendMessageToRun(runWithStdin(stdin), 'x', [hugeImage]))
+ .rejects.toThrow(/too large/i);
+ expect(stdin.write).not.toHaveBeenCalled();
+});
+```
+
+## Code review notes
+
+If the diff shows a new `if (mimeType)` ladder inside `TeamProvisioningService`, the refactor failed. That logic belongs in adapter/helper tests.
+
+## Detailed Implementation Checklist
+
+### Step 1 - Locate and isolate current Claude message serialization
+
+Do not rewrite the full delivery path. Add a seam where text and attachments are converted into Claude input blocks.
+
+Target shape:
+
+```ts
+export function buildClaudeInputBlocks(input: {
+ text: string;
+ attachments: AgentAttachmentPayload[];
+}): ClaudeInputBlock[] {
+ return [
+ { type: 'text', text: input.text },
+ ...input.attachments.map(toClaudeImageBlock),
+ ];
+}
+```
+
+### Step 2 - Use provider-native image blocks only
+
+For Claude, image data should be represented as structured image blocks for the SDK/stream-json path. Do not paste base64 into text.
+
+```ts
+function toClaudeImageBlock(attachment: AgentAttachmentPayload): ClaudeInputBlock {
+ const artifact = selectBestImageArtifact(attachment, 'claude');
+ return {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: artifact.mimeType,
+ data: artifact.base64,
+ },
+ };
+}
+```
+
+Important: this function should live in a Claude adapter, not in the composer or generic delivery service.
+
+### Step 3 - Preserve text-only path
+
+Guard the new block builder so text-only Claude messages produce byte-for-byte equivalent semantic behavior.
+
+```ts
+if (attachments.length === 0) {
+ return buildExistingClaudeTextPayload(text);
+}
+```
+
+Only remove this branch after tests prove the generic block path is fully equivalent.
+
+### Step 4 - Add exact diagnostics
+
+Claude attachment failures should say what failed:
+
+- `Claude image artifact missing`
+- `Claude image MIME type unsupported: image/webp`
+- `Claude image payload exceeds budget after optimization`
+- `Claude stream-json image delivery rejected: `
+
+Avoid saying “teammate crashed” unless process liveness confirms that.
+
+## Claude-Specific Edge Cases
+
+| Case | Risk | Safe behavior |
+|---|---|---|
+| Claude subscription not logged in | Could be misread as attachment failure | Preserve existing auth diagnostic. |
+| Multiple images | Token/latency spike | Enforce count and total byte budget before SDK call. |
+| Image plus task delegation | Tool-call response still expected | Existing proof gates stay unchanged. |
+| Lead prompt with images | Lead may consume image but not delegate | This is normal model behavior, not transport failure. |
+| Assistant refuses visual task | Delivery succeeded, model response is semantic failure | Do not retry transport automatically. |
+| Claude CLI path lacking image support | Attachment delivery blocked with provider capability error | Do not fallback to base64 text. |
+
+## Golden Serialization Tests
+
+Add tests that validate payload shape without hitting Claude.
+
+```ts
+it('serializes a png as a Claude image block', () => {
+ const blocks = buildClaudeInputBlocks({
+ text: 'What color?',
+ attachments: [fakePngAttachment({ base64: 'abc' })],
+ });
+
+ expect(blocks).toEqual([
+ { type: 'text', text: 'What color?' },
+ {
+ type: 'image',
+ source: { type: 'base64', media_type: 'image/png', data: 'abc' },
+ },
+ ]);
+});
+```
+
+Negative test:
+
+```ts
+it('does not serialize image as plain base64 text', () => {
+ const payload = JSON.stringify(buildClaudeInputBlocks(input));
+ expect(payload).not.toContain('data:image/png;base64');
+});
+```
+
+## Manual Claude QA
+
+Use the known-good red-card PNG:
+
+1. Send to Claude lead: “What color is the square? Answer one word.”
+2. Expected answer: `red` or equivalent.
+3. Send two images if supported by budget.
+4. Send oversized image and verify UI blocks before provider call.
+5. Temporarily break auth and verify auth error is preserved, not attachment error.
+
+## Phase 2 Exit Criteria
+
+- Claude text-only delivery remains green.
+- Claude image delivery answers visual smoke prompt.
+- Claude oversized image is blocked before SDK/provider call.
+- Claude provider rejection is shown as delivery failure, not launch failure.
+- No Codex or OpenCode code path changes are required for Phase 2 to pass.
+
+
+## Implementation Safeguards
+
+### Claude adapter should be additive
+
+Do not refactor all Claude runtime code just to add image support. Add a small adapter and call it from the existing send path only when attachments are present.
+
+```ts
+if (validatedAttachments.length > 0) {
+ const payload = await claudeAttachmentAdapter.buildDeliveryParts({
+ text,
+ attachments: validatedAttachments,
+ budget,
+ });
+ return sendClaudeStructuredPayload(payload);
+}
+
+return sendClaudeTextOnlyPayload(text);
+```
+
+This limits blast radius.
+
+### Do not infer readiness from Claude image response
+
+A Claude member answering an image prompt proves message delivery, not bootstrap readiness. Keep these concepts separate:
+
+- `bootstrapConfirmed`: launch/runtime proof.
+- `messageDelivered`: prompt delivery proof.
+- `visibleReply`: user-visible response proof.
+
+### Claude streaming failure mapping
+
+| Failure source | User-facing classification |
+|---|---|
+| SDK rejects image MIME | `attachment_type_unsupported` |
+| SDK rejects payload size | `attachment_too_large` |
+| Auth token invalidated | existing provider auth error |
+| Runtime exits after request | runtime crash/exit diagnostic |
+| Assistant answers “cannot view image” | semantic model response, not transport failure |
+| Stream closes without response | delivery failure, eligible for existing bounded retry only if text path already retries |
+
+### Claude PR review checklist
+
+- Does the text-only path avoid new serialization code?
+- Are images sent as structured image blocks, not text?
+- Is artifact content loaded as late as possible?
+- Are size and MIME checked before reading large file into memory?
+- Is provider error redacted?
+- Does delivery failure preserve the inbox message?
+
+### Additional Claude tests
+
+```ts
+it('keeps text-only Claude path unchanged', async () => {
+ const result = await buildClaudeDeliveryRequest({ text: 'hello', attachments: [] });
+ expect(result.kind).toBe('legacy_text');
+});
+
+it('classifies missing optimized artifact before provider call', async () => {
+ await expect(buildClaudeDeliveryRequest({
+ text: 'see image',
+ attachments: [fakeAttachmentWithMissingPath()],
+ })).rejects.toMatchObject({ code: 'attachment_artifact_missing' });
+});
+```
+
+
+## Failure Injection Tests for Phase 2
+
+```ts
+describe('Claude attachment delivery failures', () => {
+ it('does not mark teammate offline when Claude rejects image payload', async () => {
+ const result = await deliverClaudeMessageWithAttachment(fakeProviderRejectsImage());
+
+ expect(result.delivery.status).toBe('failed');
+ expect(result.delivery.failureCode).toBe('attachment_provider_rejected');
+ expect(result.memberPatch).toBeUndefined();
+ });
+
+ it('preserves existing auth error when Claude token is invalid', async () => {
+ const result = await deliverClaudeMessageWithAttachment(fakeInvalidAuth());
+
+ expect(result.error.message).toMatch(/authentication|sign in|token/i);
+ expect(result.error.code).not.toBe('attachment_provider_rejected');
+ });
+});
+```
+
+## Claude Serialization Gotchas
+
+- Some Claude SDK/CLI surfaces accept message arrays, some accept stream-json lines, and some accept plain prompt text. Attachments must use the surface that truly supports image blocks.
+- If the current runtime path cannot send image blocks safely, Phase 2 must block image send with a clear message instead of falling back to base64 text.
+- Do not mix `@file` syntax with image block syntax unless that exact runtime path has been tested.
+- If multiple Claude launch contexts exist, validate the one used by the app, not only a standalone prototype.
+
+## Claude Runtime Exit Correlation
+
+If Claude process exits shortly after image delivery, diagnostics should show both facts separately:
+
+```text
+Image delivery was attempted using Claude image blocks.
+The Claude runtime exited 18s later.
+Last stderr:
+```
+
+Do not claim the image caused the exit unless provider stderr explicitly says so.
+
+## Phase 2 Stop Conditions
+
+Stop and reassess if:
+
+- Claude implementation requires changing team bootstrap prompts.
+- Claude text-only path must be rewritten broadly.
+- Auth/session diagnostics change unexpectedly.
+- Provider image block support is not available in the actual app runtime path.
+
+
+## File-Level Implementation Plan
+
+Suggested files:
+
+```text
+src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts
+src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts
+```
+
+Existing delivery call site should import only the adapter public function, not internal attachment storage helpers.
+
+### Adapter skeleton
+
+```ts
+export async function buildClaudeAttachmentDeliveryParts(input: {
+ text: string;
+ attachments: AgentAttachmentPayload[];
+ readArtifact: AttachmentArtifactReader;
+}): Promise {
+ const blocks: ClaudeInputBlock[] = [];
+ if (input.text.trim().length > 0) {
+ blocks.push({ type: 'text', text: input.text });
+ }
+
+ for (const attachment of input.attachments) {
+ const artifact = selectProviderImageArtifact(attachment, 'anthropic');
+ const bytes = await input.readArtifact.readBytes(artifact.artifactId);
+ blocks.push(toClaudeImageBlock(artifact.mimeType, bytes));
+ }
+
+ return { kind: 'claude_structured_blocks', blocks };
+}
+```
+
+### Artifact reader abstraction
+
+```ts
+export interface AttachmentArtifactReader {
+ readBytes(artifactId: string): Promise;
+ stat(artifactId: string): Promise<{ sizeBytes: number }>;
+}
+```
+
+This makes adapter unit tests independent from filesystem.
+
+### Claude image block conversion
+
+```ts
+function toClaudeImageBlock(mimeType: string, bytes: Buffer): ClaudeInputBlock {
+ if (mimeType !== 'image/png' && mimeType !== 'image/jpeg') {
+ throw new AttachmentDeliveryError('attachment_type_unsupported', `Claude image MIME unsupported: ${mimeType}`);
+ }
+
+ return {
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: mimeType,
+ data: bytes.toString('base64'),
+ },
+ };
+}
+```
+
+This base64 is provider-native structured payload data, not prompt text. That distinction should be documented in code comments because it is easy to confuse.
+
+### Claude phase review traps
+
+- If a reviewer sees `data:image` in prompt text, reject.
+- If a reviewer sees `spawnStatus` or `launchState` imports in adapter, reject.
+- If a reviewer sees attachment adapter imported by bootstrap/provisioning code, reject.
+- If text-only Claude messages start going through `readArtifact`, reject.
+
+
+## Phase 2 Deep Review Addendum
+
+### Claude adapter contract tests
+
+Test adapter without real Claude first. The real smoke test should only prove provider behavior after contract is stable.
+
+```ts
+describe('buildClaudeAttachmentDeliveryParts', () => {
+ it('preserves text order before images', async () => {
+ const parts = await buildClaudeAttachmentDeliveryParts(fakeInputWithOneImage());
+ expect(parts.blocks[0]).toMatchObject({ type: 'text' });
+ expect(parts.blocks[1]).toMatchObject({ type: 'image' });
+ });
+
+ it('rejects webp until explicitly supported', async () => {
+ await expect(buildClaudeAttachmentDeliveryParts(fakeWebpInput()))
+ .rejects.toMatchObject({ code: 'attachment_type_unsupported' });
+ });
+});
+```
+
+### Claude delivery proof matrix
+
+| Message target | Required proof after Claude response |
+|---|---|
+| Direct user ask | visible reply or safe plain text materialization |
+| Delegate to teammate | structured relay/message proof if current flow requires it |
+| Work-sync | existing work-sync proof, not visual answer |
+| Task progress | existing task/progress proof |
+
+Image support must not weaken these proof gates.
+
+### Live smoke minimum
+
+Use a prompt that cannot be answered correctly without image access:
+
+```text
+Look at the attached image. What is the single dominant color of the square? Answer with one English word.
+```
+
+Passing answer: `red`.
+
+Failing answers:
+
+- `I cannot view images`.
+- Any generic guess not grounded in image.
+- Empty turn.
+- Tool-only response without visible answer for direct ask.
+
+
+## Phase 2 Implementation Contract Addendum
+
+### Runtime path decision tree
+
+Before implementing, identify the exact Claude runtime path used by team messages.
+
+```text
+Does app path support structured image blocks?
+ yes -> implement native Claude image blocks
+ no -> block image send for Claude with clear unsupported runtime message
+```
+
+Do not implement a fallback that pastes base64 text.
+
+### Claude provider adapter return shape
+
+```ts
+export type ClaudeAttachmentDeliveryParts =
+ | { kind: 'legacy_text'; text: string }
+ | { kind: 'structured_blocks'; blocks: ClaudeInputBlock[] };
+```
+
+Rules:
+
+- `legacy_text` only when no attachments.
+- `structured_blocks` when attachments exist.
+- Call site must handle both explicitly.
+- Any unsupported attachment throws typed `AgentAttachmentError` before provider call.
+
+### Claude provider smoke record format
+
+Record smoke results in PR notes:
+
+```text
+Provider: Claude subscription
+Runtime path:
+Model:
+Prompt: What color is the square? One word.
+Image: red-square.png
+Expected: red
+Observed: red
+Date: 2026-05-09
+Result: pass
+```
+
+This avoids relying on stale memory about prototype success.
+
+
+## Implementation Readiness Addendum
+
+### Definition of Ready for Phase 2
+
+Before coding Phase 2:
+
+- Phase 1 normalized attachment payload exists and is tested.
+- Backend can read optimized artifact bytes by attachment id.
+- Exact Claude app runtime path for team messages is identified.
+- Text-only Claude delivery tests are green before changes.
+
+### Mocking strategy
+
+Use a fake artifact reader and fake Claude sender.
+
+```ts
+const fakeArtifactReader: AttachmentArtifactReader = {
+ async readBytes(id) {
+ if (id === 'missing') throw new AgentAttachmentError('attachment_artifact_missing', 'missing');
+ return Buffer.from([1, 2, 3]);
+ },
+ async stat() {
+ return { sizeBytes: 3 };
+ },
+};
+```
+
+Do not unit-test by invoking real Claude. Real Claude belongs to smoke/e2e only.
+
+### Claude fallback decision
+
+If structured image blocks are unavailable in the actual app runtime:
+
+- Block image send for Claude.
+- Keep text-only send working.
+- Add diagnostic: `Claude runtime path does not support image attachments yet.`
+- Do not fallback to `@file`, Markdown image links, or base64 text unless separately proven.
+
+### Claude additional edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| Empty text with image | Allow if UX supports image-only prompt, otherwise require text. Do not crash adapter. |
+| Multiple images | Preserve order. |
+| Unsupported MIME after hydration | Typed pre-provider error. |
+| Artifact read fails | Message saved, delivery fails actionable. |
+| Claude returns visible refusal | Delivery succeeded with refusal visible to user. |
+
+
+## Final Phase 2 Acceptance Specs
+
+### Spec 1 - Claude text-only no regression
+
+```gherkin
+Given a user sends a text-only message to a Claude target
+When the message is delivered
+Then the legacy Claude text path is used
+And no artifact reader is invoked
+And existing proof gates are unchanged
+```
+
+### Spec 2 - Claude image delivered through structured block
+
+```gherkin
+Given a user sends a PNG image to a Claude target
+When the Claude adapter builds delivery parts
+Then it returns structured blocks
+And the first block is the text prompt
+And the second block is a provider-native image block
+And no data:image text is present in the prompt
+```
+
+### Spec 3 - Claude artifact missing
+
+```gherkin
+Given a message references an optimized image artifact
+And the artifact file is missing
+When delivery is attempted
+Then delivery fails with attachment_artifact_missing
+And the member launch state is unchanged
+And the message remains available for user action
+```
+
+### Phase 2 exact PR contract
+
+The Phase 2 PR is acceptable only if:
+
+- It adds a Claude adapter with fake artifact-reader tests.
+- It preserves text-only fast path.
+- It maps provider image rejection to attachment delivery failure.
+- It preserves Claude auth/session diagnostics.
+- It includes one real smoke note or explicitly marks smoke as pending.
+- It does not touch Codex/OpenCode delivery code except shared types.
+
+### Claude provider-native base64 comment requirement
+
+If implementation converts bytes to base64 for Claude structured image blocks, add a comment explaining why this is not the forbidden base64-in-text fallback.
+
+```ts
+// Claude expects image bytes inside the structured image block as base64.
+// This is provider-native payload data, not text appended to the user prompt.
+```
+
+
+## Phase 2 Pre-Mortem and Extra Safeguards
+
+### Likely Claude mistakes
+
+| Mistake | Concrete prevention |
+|---|---|
+| Testing standalone Claude path but shipping different app path | Smoke actual app-managed team delivery path. |
+| Treating Claude visible refusal as transport failure | Visible refusal is delivered response. |
+| Weakening relay/work-sync proof gates | Keep existing proof gates after response. |
+| Reading large artifact before budget validation | Validate size before reading bytes. |
+| Logging structured payload with base64 | Redact image block data in diagnostics. |
+
+### Redaction helper requirement
+
+Claude structured payload may contain base64. Any debug output must redact it.
+
+```ts
+export function redactClaudeBlocksForDiagnostics(blocks: ClaudeInputBlock[]): unknown[] {
+ return blocks.map((block) => {
+ if (block.type !== 'image') return block;
+ return {
+ ...block,
+ source: {
+ ...block.source,
+ data: `[redacted image bytes: ${block.source.media_type}]`,
+ },
+ };
+ });
+}
+```
+
+### Claude adapter test for redaction
+
+```ts
+it('redacts image bytes in diagnostics', () => {
+ const redacted = redactClaudeBlocksForDiagnostics([fakeClaudeImageBlock('SECRET_BASE64')]);
+ expect(JSON.stringify(redacted)).not.toContain('SECRET_BASE64');
+});
+```
+
+### Claude stream handling edge case
+
+If Claude streams partial text then errors:
+
+- Preserve partial visible text only if existing delivery layer already supports partials safely.
+- Otherwise report delivery failed with provider diagnostic.
+- Do not mark message read if required visible proof was not committed.
+
diff --git a/docs/team-management/agent-attachments-phase-3-codex-native-plan.md b/docs/team-management/agent-attachments-phase-3-codex-native-plan.md
new file mode 100644
index 00000000..9402c899
--- /dev/null
+++ b/docs/team-management/agent-attachments-phase-3-codex-native-plan.md
@@ -0,0 +1,1218 @@
+# Phase 3 - Codex native image attachment delivery
+
+## Summary
+
+Goal: support image attachments for Codex native teammates by using Codex CLI's supported `--image ` transport rather than embedding base64 in prompt text.
+
+Chosen approach: **optimized image artifact files + Codex native exec args extension + text-only fallback errors for unsupported attachment kinds**.
+
+🎯 8.6 🛡️ 8.4 🧠 6.6
+Estimated change size: `260-440` LOC across two repos.
+
+Repos:
+
+- `/Users/belief/dev/projects/claude/claude_team`
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+## Live proof
+
+Validated manually:
+
+```bash
+printf '%s\n' 'Look at the attached image. Reply with exactly one word: red, green, or blue.' \
+ | codex exec --json --skip-git-repo-check -C /tmp \
+ --model gpt-5.4-mini \
+ --image /tmp/agent-attachment-prototypes/red-card-valid.png \
+ --output-last-message /tmp/agent-attachment-prototypes/codex-last.txt \
+ -
+```
+
+Result:
+
+```text
+red
+```
+
+Therefore Codex adapter should pass file paths.
+
+## Current blocker
+
+`agent_teams_orchestrator` currently rejects non-text prompts in Codex native:
+
+```text
+Codex native phase 0 only supports text-only prompts. Images, documents, and structured input are not wired yet.
+```
+
+Likely locations:
+
+```text
+agent_teams_orchestrator/src/services/codexNative/turnExecutor.ts
+agent_teams_orchestrator/src/services/codexNative/execRunner.ts
+```
+
+Do not remove this guard globally. Replace it with structured extraction for supported image content only.
+
+## Data contract
+
+Add a Codex native input shape that can represent text plus image files.
+
+```ts
+export interface CodexNativeTurnInput {
+ promptText: string;
+ imagePaths: string[];
+}
+```
+
+If the current internal API only accepts text, introduce a narrow overload or adapter:
+
+```ts
+export type CodexNativePromptInput =
+ | { kind: 'text'; text: string }
+ | { kind: 'text-with-images'; text: string; imagePaths: string[] };
+```
+
+Do not pass base64 into Codex prompt text.
+
+## Claude-team side adapter
+
+`CodexNativeAttachmentAdapter` should prepare image artifacts.
+
+```ts
+export class CodexNativeAttachmentAdapter implements AttachmentDeliveryAdapter {
+ readonly runtimeKind = 'codex-native' as const;
+
+ canDeliver(ctx: AttachmentRuntimeContext, attachment: NormalizedAgentAttachment) {
+ if (attachment.kind !== 'image') {
+ return block('Codex native currently supports image attachments only.');
+ }
+ return allowIfMime(attachment, ['image/png', 'image/jpeg', 'image/webp']);
+ }
+
+ async prepare(ctx: AttachmentRuntimeContext, attachment: NormalizedAgentAttachment) {
+ const variant = selectCodexImageFileVariant(attachment);
+ const path = await this.artifactStore.materializeFileVariant(variant, {
+ teamName: ctx.teamName,
+ runtime: 'codex-native',
+ });
+
+ return {
+ runtimeKind: this.runtimeKind,
+ attachmentId: attachment.id,
+ part: { kind: 'codex-image-arg', path },
+ diagnostics: [`prepared image file for Codex native: ${formatBytes(variant.byteSize)}`],
+ };
+ }
+}
+```
+
+Artifact directory should be app-owned and not user-editable:
+
+```text
+~/.claude/teams//attachments///.
+```
+
+If existing team data conventions prefer another base path, use that. The key is deterministic metadata and cleanup safety.
+
+## Orchestrator changes
+
+### Extract Codex image paths from content blocks
+
+```ts
+export function toCodexNativeTurnInput(input: string | ContentBlockParam[]): CodexNativeTurnInput {
+ if (typeof input === 'string') {
+ return { promptText: input, imagePaths: [] };
+ }
+
+ const textParts: string[] = [];
+ const imagePaths: string[] = [];
+
+ for (const block of input) {
+ if (block.type === 'text') {
+ textParts.push(block.text);
+ continue;
+ }
+
+ if (block.type === 'image') {
+ const path = materializeCodexImageBlockToTempFile(block);
+ imagePaths.push(path);
+ continue;
+ }
+
+ throw new Error(`Codex native does not support ${block.type} attachments yet.`);
+ }
+
+ return {
+ promptText: textParts.join('\n\n').trim(),
+ imagePaths,
+ };
+}
+```
+
+Preferred path: desktop already materializes artifacts and passes paths, so orchestrator should not need to decode base64 except for compatibility with direct SDK/fork calls.
+
+### Extend exec args
+
+```ts
+export function buildCodexNativeExecArgs(options: CodexNativeExecOptions): string[] {
+ return [
+ 'exec',
+ '--json',
+ '--skip-git-repo-check',
+ '-C', options.cwd,
+ ...options.imagePaths.flatMap(path => ['--image', path]),
+ '-',
+ ];
+}
+```
+
+### Preserve stdin prompt behavior
+
+Keep:
+
+```ts
+child.stdin.end(options.prompt);
+```
+
+Do not switch to putting the prompt in argv if it can be long.
+
+## Edge cases
+
+### Image file path missing before Codex starts
+
+Expected behavior:
+
+- fail before spawn with a clear error;
+- do not start Codex with missing `--image` path.
+
+### Multiple images
+
+Codex CLI supports repeatable `--image `. Pass one arg pair per image.
+
+### Unsupported document/PDF
+
+Expected behavior:
+
+```text
+Codex native does not support PDF attachments yet. Send text or images only.
+```
+
+Do not silently convert PDF to text in this phase.
+
+### OpenAI account/session issue
+
+Attachment code must not mask auth errors. If Codex says login required, show Codex auth error unchanged.
+
+### Artifact cleanup
+
+Do not delete image files immediately after spawn. Codex may read after process start. Keep artifacts with message/team data and clean with team cleanup or retention policy.
+
+### Project path sandbox
+
+Codex gets `--image` absolute paths outside project. Confirm current Codex CLI accepts this. Live test used `/tmp`, so it does. If future sandbox blocks, copy artifacts into an app-owned allowed directory.
+
+## Test plan
+
+### Orchestrator unit
+
+- text-only input produces no `--image` args;
+- text plus one image produces one `--image` arg;
+- multiple images produce repeated args in order;
+- unsupported document block throws clear error;
+- missing image path throws before spawn;
+- prompt still goes to stdin.
+
+### Desktop unit/service
+
+- Codex adapter chooses file variant;
+- artifact materialization writes expected file;
+- planner blocks PDF for Codex;
+- error messages do not include base64.
+
+### Live e2e
+
+Only when explicitly requested:
+
+```bash
+codex exec --json --skip-git-repo-check -C /tmp --model gpt-5.4-mini --image red-card-valid.png -
+```
+
+Expected final message:
+
+```text
+red
+```
+
+Suggested focused checks:
+
+```bash
+# claude_team
+pnpm vitest run src/features/agent-attachments/**/*.test.ts test/main/services/team/TeamProvisioningService.test.ts
+pnpm typecheck --pretty false
+
+# agent_teams_orchestrator
+bun test src/services/codexNative/*.test.ts
+```
+
+## Safety checklist
+
+- Text-only Codex path unchanged.
+- Auth/session errors preserved.
+- No base64 in prompt text.
+- No immediate cleanup of image files after spawn.
+- Unsupported files fail before model call.
+
+## Deep implementation details
+
+### Two-repo boundary
+
+Desktop should decide and materialize attachment artifacts. Orchestrator should execute Codex with prepared input.
+
+```text
+claude_team:
+ normalize/optimize/store image
+ decide Codex supports image
+ pass prepared prompt + image artifact refs into runtime handoff
+
+agent_teams_orchestrator:
+ accept text + imagePaths
+ validate files exist/readable
+ append --image args
+ keep prompt on stdin
+```
+
+Avoid making orchestrator depend on desktop feature internals.
+
+### Minimal orchestrator type extension
+
+```ts
+export interface CodexNativeExecOptions {
+ cwd: string;
+ prompt: string;
+ model?: string;
+ imagePaths?: string[];
+ env?: NodeJS.ProcessEnv;
+}
+```
+
+Default `imagePaths = []` preserves existing callers.
+
+### Args builder exact behavior
+
+```ts
+export function buildCodexNativeExecArgs(options: CodexNativeExecOptions): string[] {
+ const args = [
+ 'exec',
+ '--json',
+ '--skip-git-repo-check',
+ '-C',
+ options.cwd,
+ ];
+
+ if (options.model) {
+ args.push('--model', options.model);
+ }
+
+ for (const imagePath of options.imagePaths ?? []) {
+ args.push('--image', imagePath);
+ }
+
+ args.push('-');
+ return args;
+}
+```
+
+Order matters. Keep `-` last so stdin is prompt.
+
+### File validation before spawn
+
+```ts
+async function assertCodexImageFilesReady(paths: string[]): Promise {
+ for (const imagePath of paths) {
+ const stat = await fs.promises.stat(imagePath).catch(() => null);
+ if (!stat?.isFile()) {
+ throw new Error(`Codex image attachment is missing: ${path.basename(imagePath)}`);
+ }
+ if (stat.size <= 0) {
+ throw new Error(`Codex image attachment is empty: ${path.basename(imagePath)}`);
+ }
+ if (stat.size > CODEX_IMAGE_FILE_MAX_BYTES) {
+ throw new Error(`Codex image attachment is too large: ${path.basename(imagePath)}`);
+ }
+ }
+}
+```
+
+Do not include full absolute paths in user messages unless copied diagnostics need them and they are redacted/safe.
+
+### Desktop artifact store contract
+
+```ts
+export interface AttachmentArtifactStore {
+ materializeVariantFile(input: {
+ teamName: string;
+ messageId: string;
+ attachmentId: string;
+ variantId: string;
+ filename: string;
+ base64: string;
+ expectedSha256: string;
+ }): Promise<{ path: string; bytes: number; sha256: string }>;
+}
+```
+
+Validation:
+
+- directory created with recursive mkdir;
+- filename sanitized;
+- write to temp file then rename;
+- sha256 verified after write;
+- if existing file has same sha256, reuse;
+- if existing file mismatch, rewrite from original.
+
+### Artifact write pattern
+
+```ts
+const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
+await fs.promises.writeFile(tmp, bytes, { flag: 'wx' }).catch(async error => {
+ if (error.code === 'EEXIST') {
+ await fs.promises.rm(tmp, { force: true });
+ await fs.promises.writeFile(tmp, bytes, { flag: 'wx' });
+ return;
+ }
+ throw error;
+});
+await fs.promises.rename(tmp, target);
+```
+
+Prefer a shared atomic write helper if one already exists.
+
+### More edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| Codex login expires | Codex auth error shown unchanged |
+| Image path contains spaces | args array handles it, no shell quoting needed |
+| Artifact deleted between validation and spawn | Codex may fail; surface exact stderr, but pre-spawn validation reduces probability |
+| Multiple Codex members use same attachment | artifact store can reuse same variant path by hash |
+| User sends image to Codex lead while lead busy | existing lead busy/message delivery semantics remain unchanged |
+| Codex model selected is text-only in future | capability gate should block when catalog knows; otherwise live model may error, preserve exact error |
+| Image is WebP | if Codex accepts through `--image`, allow; otherwise convert to PNG/JPEG in Phase 1/adapter policy |
+| PDF attached to Codex | block in v1 with clear message |
+
+### Test additions in orchestrator
+
+```ts
+test('adds repeated --image args before stdin marker', () => {
+ expect(buildCodexNativeExecArgs({ cwd: '/tmp', prompt: 'x', imagePaths: ['/a.png', '/b.jpg'] }))
+ .toContainSequence(['--image', '/a.png', '--image', '/b.jpg', '-']);
+});
+```
+
+```ts
+test('keeps text-only args unchanged', () => {
+ expect(buildCodexNativeExecArgs({ cwd: '/tmp', prompt: 'x' }))
+ .not.toContain('--image');
+});
+```
+
+### Regression traps
+
+- Passing prompt as argv and accidentally truncating/escaping long prompts.
+- Deleting artifact file in finally before Codex has read it.
+- Allowing arbitrary renderer-supplied paths into `--image`.
+- Hiding Codex auth errors behind `Attachment failed`.
+- Treating Codex CLI `turn.completed` as image-understood proof without response content.
+
+## File-by-file implementation plan
+
+### claude_team
+
+Potential files:
+
+```text
+src/features/agent-attachments/main/adapters/output/CodexNativeAttachmentAdapter.ts
+src/features/agent-attachments/main/infrastructure/AttachmentArtifactStore.ts
+src/main/services/team/TeamProvisioningService.ts
+src/main/ipc/teams.ts
+```
+
+Keep the desktop side responsible for app-owned artifact paths.
+
+### agent_teams_orchestrator
+
+Potential files:
+
+```text
+src/services/codexNative/turnExecutor.ts
+src/services/codexNative/execRunner.ts
+src/services/codexNative/*.test.ts
+```
+
+Make the orchestrator change backward compatible by defaulting `imagePaths` to `[]`.
+
+## Integration contract between repos
+
+If the desktop already invokes orchestrator through a structured prompt, add image paths explicitly rather than hiding them in text.
+
+Preferred:
+
+```ts
+interface NativeRuntimePromptEnvelope {
+ text: string;
+ attachments?: Array<{
+ kind: 'image-file';
+ path: string;
+ mimeType: 'image/png' | 'image/jpeg' | 'image/webp';
+ sha256: string;
+ }>;
+}
+```
+
+Avoid:
+
+```text
+Here is an image: /tmp/foo.png
+```
+
+unless the runtime explicitly cannot accept images and user chose a textual fallback.
+
+## Artifact security rules
+
+- Renderer never supplies final file path.
+- Backend chooses artifact path under app-owned directory.
+- Path traversal in filename is sanitized.
+- Artifact path passed to Codex is absolute.
+- Artifact checksum is verified after write.
+- Artifact metadata does not include API keys or user prompt text.
+
+## More detailed edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| Codex process starts but exits before reading image | surface exact Codex stderr/exit code |
+| Artifact file exists but unreadable due permissions | fail before spawn if detectable |
+| Two sends with same image and same message id | reuse same artifact variant |
+| Two sends with same image but different message id | allow separate metadata, optionally same content-addressed blob |
+| Image path has non-ASCII filename | store sanitized ASCII filename plus metadata originalName |
+| User cancels send during artifact write | abort write if supported, cleanup temp file |
+| Codex CLI changes `--image` flag | tests fail at args builder/live smoke before release |
+
+## Test code skeleton
+
+```ts
+describe('CodexNativeAttachmentAdapter', () => {
+ it('materializes image variant and returns codex image arg', async () => {
+ const adapter = new CodexNativeAttachmentAdapter(fakeArtifactStore);
+ const part = await adapter.prepare(ctx, imageAttachment);
+ expect(part.part).toEqual({ kind: 'codex-image-arg', path: '/tmp/app/att/red.png' });
+ });
+
+ it('blocks PDF attachments', () => {
+ const decision = adapter.canDeliver(ctx, pdfAttachment);
+ expect(decision.allowed).toBe(false);
+ expect(decision.blockers[0].code).toBe('attachment_runtime_unsupported');
+ });
+});
+```
+
+```ts
+describe('buildCodexNativeExecArgs', () => {
+ it('keeps stdin marker last with image args before it', () => {
+ expect(buildCodexNativeExecArgs({ cwd: '/tmp', prompt: 'x', imagePaths: ['/a.png'] }))
+ .toEqual(expect.arrayContaining(['--image', '/a.png', '-']));
+ });
+});
+```
+
+## Review checklist
+
+- Existing text-only Codex tests still pass.
+- `imagePaths` default is empty.
+- No shell string command building for image paths.
+- Missing image file fails before spawn where possible.
+- Auth errors are not converted to attachment errors.
+- The feature works with Codex subscription auth, not only API key.
+
+## Phase 3 exit criteria
+
+Phase 3 is complete only when:
+
+- text-only Codex native still uses the same exec path;
+- Codex image send uses `--image ` and stdin prompt;
+- image paths come only from app-owned artifacts;
+- missing artifact fails before spawn;
+- unsupported PDFs/documents are blocked before Codex call;
+- Codex auth errors remain exact;
+- no OpenCode/Claude code changes are included except shared interfaces.
+
+## Cross-repo sequencing
+
+Recommended order:
+
+1. Orchestrator: add optional `imagePaths` to Codex exec runner with tests.
+2. Orchestrator: keep turn executor text-only behavior unless image paths are explicitly supplied.
+3. Desktop: add Codex adapter that materializes image files.
+4. Desktop: wire Codex adapter only for Codex native send path.
+5. Live smoke Codex with `gpt-5.4-mini`.
+
+Do not wire desktop before orchestrator can safely accept `imagePaths`.
+
+## Backward compatibility in orchestrator
+
+```ts
+function normalizeExecOptions(options: CodexNativeExecOptions): Required> {
+ return {
+ imagePaths: options.imagePaths ?? [],
+ };
+}
+```
+
+Existing tests should not need imagePaths.
+
+## Handling structured content blocks
+
+If orchestrator receives Anthropic-style content blocks, only support image blocks when they already point to app-owned artifacts or can be materialized safely.
+
+V1 preference:
+
+```text
+Desktop passes file paths, not base64 blocks, to Codex native.
+```
+
+If a direct orchestrator caller passes base64 image blocks, fail with clear TODO unless implementing materialization there too.
+
+```ts
+throw new Error('Codex native image blocks must be materialized to file paths before execution.');
+```
+
+This prevents duplicate artifact stores across repos.
+
+## Codex path validation nuance
+
+Do not require image file to be inside project cwd. Live test showed `/tmp` works. Requiring cwd-only would break app-owned artifact store. Instead require:
+
+- absolute path;
+- file exists;
+- file extension/MIME allowed;
+- size under budget;
+- path was produced by trusted desktop adapter or trusted test input.
+
+## More Codex tests
+
+```ts
+it('rejects relative image paths', async () => {
+ await expect(runCodexNativeExec({ prompt: 'x', imagePaths: ['foo.png'] }))
+ .rejects.toThrow(/absolute/i);
+});
+```
+
+```ts
+it('does not include image path in prompt stdin', async () => {
+ const child = fakeCodexChild();
+ await runner.run({ prompt: 'describe', imagePaths: ['/tmp/a.png'] });
+ expect(child.stdin.end).toHaveBeenCalledWith('describe');
+});
+```
+
+## More Codex bug traps
+
+| Trap | Prevention |
+|---|---|
+| prompt accidentally becomes argv | assert `-` remains final arg |
+| image path included twice | test exact args |
+| artifact path deleted too early | keep artifacts with message retention |
+| base64 path from renderer | backend-only artifact store |
+| Codex auth failure hidden | do not catch provider errors as attachment errors |
+| unsupported PDF converted to prompt text silently | block explicitly |
+
+## Detailed Implementation Checklist
+
+### Step 1 - Define desktop to orchestrator attachment contract
+
+Codex native invocation details should stay in the runtime/orchestrator boundary. Desktop should pass managed artifact references, not raw arbitrary paths.
+
+```ts
+export interface CodexNativeAttachmentRequest {
+ kind: 'image';
+ artifactId: string;
+ mimeType: 'image/png' | 'image/jpeg';
+ absolutePath: string;
+ sizeBytes: number;
+}
+```
+
+Validation before crossing process boundary:
+
+- Path must be under app-managed attachment artifact directory.
+- MIME type must be PNG/JPEG for Phase 3.
+- File must exist and match expected size budget.
+- File path must not come directly from renderer.
+
+### Step 2 - Add Codex adapter in desktop
+
+Desktop adapter should produce a provider-neutral runtime request, not CLI args directly.
+
+```ts
+export function buildCodexAttachmentRuntimeRequest(input: {
+ text: string;
+ attachments: AgentAttachmentPayload[];
+}): CodexRuntimePromptRequest {
+ return {
+ text: input.text,
+ images: input.attachments.map((attachment) => ({
+ path: selectBestImageArtifact(attachment, 'codex').path,
+ mimeType: selectBestImageArtifact(attachment, 'codex').mimeType,
+ })),
+ };
+}
+```
+
+### Step 3 - Add orchestrator CLI serialization
+
+In orchestrator, serialize to Codex-native image args only at the final command builder.
+
+Expected conceptual shape:
+
+```ts
+const args = ['exec', '--json', '--model', model];
+for (const image of request.images) {
+ args.push('--image', image.path);
+}
+args.push('-');
+```
+
+Do not use base64 text fallback.
+
+### Step 4 - Preserve Codex auth behavior
+
+Codex attachment changes must not modify:
+
+- `CODEX_HOME` selection.
+- ChatGPT subscription vs API key logic.
+- `forced_login_method=chatgpt` propagation.
+- existing diagnostics for “ChatGPT login is required”.
+
+If Codex auth fails, the error should remain auth-specific, not attachment-specific.
+
+## Codex-Specific Edge Cases
+
+| Case | Expected behavior |
+|---|---|
+| Codex ChatGPT subscription logged out | Preserve existing login required diagnostic. |
+| Codex API key mode selected but native subscription expected | Preserve existing auth mode diagnostic. |
+| Image artifact path deleted before send | Delivery fails with artifact missing. |
+| Large image after optimization | Block before Codex CLI. |
+| Non-image file | Phase 3 does not route it through `--image`. |
+| Multiple images | Pass repeated `--image` args if Codex supports them, otherwise enforce count 1 with clear warning. |
+| Codex model without vision | Block based on capability matrix if known. |
+
+## Cross-Repo Contract Test Idea
+
+Add a small pure test in orchestrator for command building:
+
+```ts
+it('passes codex images as repeated --image args', () => {
+ const command = buildCodexExecCommand({
+ prompt: 'What color?',
+ images: ['/tmp/a.png', '/tmp/b.jpg'],
+ });
+
+ expect(command.args).toContainSequence(['--image', '/tmp/a.png']);
+ expect(command.args).toContainSequence(['--image', '/tmp/b.jpg']);
+ expect(command.stdin).toBe('What color?');
+});
+```
+
+Add desktop-side test for path safety:
+
+```ts
+it('rejects codex image paths outside managed artifact directory', () => {
+ expect(() => validateManagedArtifactPath('/etc/passwd')).toThrow(/outside managed attachment directory/);
+});
+```
+
+## Manual Codex QA
+
+1. Confirm dashboard shows Codex ChatGPT account ready.
+2. Send red-card image to Codex lead or member.
+3. Expected answer: `red` or equivalent.
+4. Send unsupported model if available and verify warning.
+5. Send oversized optimized image and verify block before runtime call.
+6. Temporarily invalidate Codex login and verify auth diagnostic remains clear.
+
+## Phase 3 Exit Criteria
+
+- Codex text-only prompt path unchanged.
+- Codex image prompt uses native image channel, not base64 text.
+- Codex auth diagnostics still match current behavior.
+- Desktop never passes renderer-provided arbitrary file paths to orchestrator.
+- One real Codex subscription visual smoke test passes.
+
+
+## Implementation Safeguards
+
+### Keep Codex login/session handling untouched
+
+The recent launch stability work around Codex auth is fragile and should not be mixed with attachment delivery.
+
+Do not change:
+
+- Codex auth discovery.
+- ChatGPT account vs API key selection.
+- `CODEX_HOME` propagation.
+- `forced_login_method=chatgpt` settings.
+- preflight auth status copy.
+
+Attachment support should sit after auth has already resolved.
+
+### Avoid stdin bloat
+
+Codex image paths should be CLI args or native request fields. The prompt on stdin should stay text-only.
+
+Bad:
+
+```ts
+stdin = `${prompt}\n\nIMAGE_BASE64=${base64}`;
+```
+
+Good:
+
+```ts
+args.push('--image', managedImagePath);
+stdin = prompt;
+```
+
+### Artifact lifetime
+
+Codex runtime may read image files after process spawn. Do not delete artifacts immediately after creating command args.
+
+Safe policy:
+
+- Keep managed artifacts at least until delivery result is terminal.
+- If message remains retryable, keep artifacts until retry window expires.
+- Garbage collect old artifacts by age and reachability from message records.
+
+### Codex retry edge cases
+
+| Case | Safe behavior |
+|---|---|
+| Retry after artifact GC | Fail with artifact missing, do not send text-only replacement silently. |
+| Retry after model switch | Revalidate capability and budgets. |
+| Retry after Codex logout | Show auth error, preserve message. |
+| Runtime exits after receiving image | Runtime diagnostic, not attachment validation failure. |
+
+### Cross-repo PR checklist
+
+Desktop repo:
+
+- Produces provider-neutral image request.
+- Validates managed artifact paths.
+- Preserves message/ledger semantics.
+
+Orchestrator repo:
+
+- Converts request to Codex native args.
+- Does not inspect renderer metadata.
+- Redacts command diagnostics.
+- Tests command builder with one and multiple images.
+
+
+## Failure Injection Tests for Phase 3
+
+```ts
+describe('Codex native image delivery', () => {
+ it('keeps prompt on stdin and images as args', () => {
+ const command = buildCodexNativeCommand({
+ prompt: 'What color?',
+ images: [managedImage('/tmp/app/attachments/a.png')],
+ });
+
+ expect(command.stdin).toBe('What color?');
+ expect(command.args).toContain('--image');
+ expect(command.stdin).not.toContain('base64');
+ });
+
+ it('does not mask Codex login failure as attachment failure', async () => {
+ const result = await runCodexImageDelivery(fakeCodexLoggedOut());
+
+ expect(result.error.message).toMatch(/codex login|ChatGPT/i);
+ expect(result.error.code).not.toBe('attachment_provider_rejected');
+ });
+});
+```
+
+## Codex Command Builder Invariants
+
+- `--image` args must appear before prompt stdin is consumed if Codex CLI requires it.
+- Paths must be absolute managed artifact paths.
+- Do not shell-concatenate paths. Use argv array.
+- Do not quote manually inside argv array.
+- Do not pass images through environment variables.
+- Do not write temp prompt files containing base64 image text.
+
+## Codex Existing-Team Edge Case
+
+Existing teams may have members created before attachment support. That must not matter. Attachment capability is based on current provider/model/runtime, not team creation date.
+
+If old metadata lacks provider/model:
+
+- Use existing compatibility probe behavior.
+- Do not infer vision support from old inbox names.
+- Block image send until team/member metadata is stable.
+
+## Phase 3 Stop Conditions
+
+Stop and reassess if:
+
+- Codex image support requires changing auth detection.
+- Codex CLI version in app runtime does not support `--image`.
+- Image args only work in standalone shell but not app-managed runtime.
+- Provider diagnostics start saying API key mode when ChatGPT subscription was selected.
+
+
+## File-Level Implementation Plan
+
+Desktop suggested files:
+
+```text
+src/features/agent-attachments/main/providers/codexAttachmentAdapter.ts
+src/features/agent-attachments/main/providers/codexAttachmentAdapter.test.ts
+```
+
+Orchestrator suggested files:
+
+```text
+src/runtime/codex/codexImageArgs.ts
+src/runtime/codex/codexImageArgs.test.ts
+```
+
+Use actual repo structure names during implementation, but keep this separation: desktop plans delivery, orchestrator serializes runtime command.
+
+### Desktop adapter skeleton
+
+```ts
+export function buildCodexNativeAttachmentRequest(input: {
+ text: string;
+ attachments: AgentAttachmentPayload[];
+}): CodexNativePromptRequest {
+ return {
+ text: input.text,
+ images: input.attachments.map((attachment) => {
+ const artifact = selectProviderImageArtifact(attachment, 'codex');
+ return {
+ artifactId: artifact.artifactId,
+ path: artifact.absolutePath,
+ mimeType: artifact.mimeType,
+ sizeBytes: artifact.sizeBytes,
+ };
+ }),
+ };
+}
+```
+
+### Orchestrator command args skeleton
+
+```ts
+export function appendCodexImageArgs(args: string[], images: CodexRuntimeImage[]): void {
+ for (const image of images) {
+ assertManagedRuntimeImagePath(image.path);
+ args.push('--image', image.path);
+ }
+}
+```
+
+Never build a shell string like:
+
+```ts
+`codex exec --image ${path}`
+```
+
+Use argv arrays only.
+
+### Codex provider proof interaction
+
+Codex image delivery should reuse existing delivery proof gates. Image delivery itself is not success.
+
+Success examples:
+
+- Visible direct reply to user.
+- Correct `message_send` relay for delegated/peer path.
+- Correct work-sync report for work-sync path.
+
+Failure examples:
+
+- Codex command exits before assistant message.
+- Codex auth fails.
+- Codex returns text saying it cannot inspect image.
+
+The last one may still be a visible reply. Treat it as delivered but semantically unhelpful, not as transport failure.
+
+
+## Phase 3 Deep Review Addendum
+
+### Codex runtime compatibility probe
+
+Before enabling Codex image delivery in UI, the app should know whether the configured Codex runtime supports image args.
+
+Conservative options:
+
+1. Static capability by known runtime version - 🎯 8 🛡️ 8.5 🧠 4, примерно `80-160` строк.
+2. One-time local CLI help parse/cache - 🎯 7.5 🛡️ 8 🧠 5, примерно `120-220` строк.
+3. Always attempt and surface provider error - 🎯 6 🛡️ 6.5 🧠 2, примерно `30-80` строк.
+
+Recommended for release: option 1 if runtime version is already controlled by the app, otherwise option 2 with short cache.
+
+Do not run expensive live Codex image probes on every composer render.
+
+### Codex request lifecycle
+
+```text
+Composer send
+ -> persist message and artifacts
+ -> backend validates Codex capability
+ -> desktop builds Codex runtime request
+ -> orchestrator builds argv with --image
+ -> Codex runtime produces response
+ -> existing delivery proof gates decide delivered/failed
+```
+
+Every arrow should have tests or explicit diagnostics.
+
+### Codex diagnostics examples
+
+Good:
+
+```text
+Codex image delivery failed: optimized image artifact is missing. The message was saved but cannot be retried with the image.
+```
+
+```text
+Codex ChatGPT login is required before sending image attachments.
+```
+
+Bad:
+
+```text
+Agent failed.
+```
+
+```text
+Spawn failed.
+```
+
+unless process launch actually failed.
+
+
+## Phase 3 Implementation Contract Addendum
+
+### Codex DTO between desktop and orchestrator
+
+Use a stable JSON-serializable shape.
+
+```ts
+export interface CodexImageAttachmentForRuntime {
+ artifactId: string;
+ path: string;
+ mimeType: 'image/png' | 'image/jpeg';
+ sizeBytes: number;
+}
+
+export interface CodexPromptRuntimeRequest {
+ text: string;
+ images?: CodexImageAttachmentForRuntime[];
+}
+```
+
+Rules:
+
+- `images` omitted or empty means exact text-only legacy behavior.
+- `images` present means orchestrator appends native `--image` args.
+- Unknown fields should be ignored or rejected consistently, not partially consumed.
+
+### Codex compatibility check
+
+If the app bundles/controls Codex CLI version, use static support knowledge. If not, parse `codex exec --help` once and cache whether `--image` exists.
+
+Pseudo-code:
+
+```ts
+export async function detectCodexImageArgSupport(runtime: CodexRuntime): Promise {
+ const cached = codexImageSupportCache.get(runtime.binaryPath);
+ if (cached) return cached.supported;
+
+ const help = await runtime.run(['exec', '--help'], { timeoutMs: 5000 });
+ const supported = /--image\b/.test(help.stdout + help.stderr);
+ codexImageSupportCache.set(runtime.binaryPath, { supported, checkedAt: Date.now() });
+ return supported;
+}
+```
+
+Do not run this on every message send if it is slow.
+
+### Codex error mapping table
+
+| Error text category | Final classification |
+|---|---|
+| login required / ChatGPT session | provider auth/session error |
+| unknown option `--image` | runtime does not support image attachments |
+| file not found | attachment artifact missing |
+| max tokens/quota | provider rejected message |
+| model cannot inspect image | visible semantic response if delivered |
+
+Business logic should not depend on fragile regex except for display cleanup. Prefer structured exit codes/known adapter failures where available.
+
+
+## Implementation Readiness Addendum
+
+### Definition of Ready for Phase 3
+
+Before coding Phase 3:
+
+- Phase 1 normalized artifact storage is in place.
+- Codex text-only delivery tests are green.
+- Orchestrator branch/worktree is aligned with desktop branch.
+- Actual app-managed Codex runtime has been checked for image support.
+
+### Cross-repo compatibility rule
+
+Desktop must tolerate orchestrator without image support during development by failing safely before send, not by crashing runtime.
+
+```ts
+if (!runtimeCapabilities.codexImageArgs) {
+ throw new AgentAttachmentError(
+ 'attachment_runtime_transport_failed',
+ 'Current Codex runtime does not support image attachments.'
+ );
+}
+```
+
+### Codex no-regression assertions
+
+- Text-only Codex request shape unchanged when `images` is empty.
+- Codex ChatGPT account mode still selected when user chose subscription.
+- No `OPENAI_API_KEY` fallback is introduced for subscription image send.
+- `CODEX_HOME` still points to expected local auth state.
+- Provider auth errors are not swallowed by attachment adapter.
+
+### Codex additional edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| `--image` unsupported | Block with runtime unsupported diagnostic. |
+| Two images but Codex supports one only | Block or reduce with explicit user choice, no silent drop. |
+| Image path contains spaces | argv array handles it. Test it. |
+| Image path contains shell metacharacters | argv array handles it. No shell interpolation. |
+| Codex returns answer in non-English | Accept semantic visual answer if it clearly identifies image. |
+
+
+## Final Phase 3 Acceptance Specs
+
+### Spec 1 - Codex text-only no regression
+
+```gherkin
+Given a user sends a text-only message to a Codex target
+When the runtime request is built
+Then images is omitted or empty
+And the existing Codex text-only path is used
+And auth/session handling is unchanged
+```
+
+### Spec 2 - Codex image uses native args
+
+```gherkin
+Given a user sends a PNG image to Codex
+When the orchestrator command is built
+Then the prompt remains text-only stdin
+And the image path is passed with --image argv
+And no shell string interpolation is used
+And no base64 appears in stdin
+```
+
+### Spec 3 - Codex runtime lacks image support
+
+```gherkin
+Given the configured Codex runtime does not support --image
+When the user tries to send an image
+Then the send is blocked or delivery fails before runtime prompt
+And the diagnostic says Codex runtime does not support image attachments
+And text-only Codex messages still work
+```
+
+### Phase 3 exact PR contract
+
+The Phase 3 PR is acceptable only if:
+
+- Desktop and orchestrator agree on DTO shape.
+- Orchestrator command builder has argv tests.
+- Codex auth tests or diagnostics are not weakened.
+- Runtime support detection is cached or static, not repeated on every render.
+- Paths with spaces are covered in tests.
+- No API-key fallback is introduced for subscription mode.
+
+### Codex failure copy examples
+
+```text
+Codex image delivery is unavailable because the configured Codex runtime does not support --image.
+```
+
+```text
+Codex ChatGPT login is required before this image can be sent.
+```
+
+```text
+Prepared image file is missing. Remove and attach the image again.
+```
+
+
+## Phase 3 Pre-Mortem and Extra Safeguards
+
+### Likely Codex mistakes
+
+| Mistake | Concrete prevention |
+|---|---|
+| `--image` command built through shell string | Use argv arrays only. |
+| Image support check runs too often | Cache runtime capability. |
+| API key mode accidentally used for subscription | Preserve selected auth mode and existing env rules. |
+| Orchestrator receives desktop fields it ignores | Add contract test across DTO. |
+| Runtime does not support image but UI allows send | Capability gate checks runtime support. |
+
+### Cross-version DTO tolerance
+
+During development, desktop and orchestrator can briefly be out of sync. The final merged state must be compatible, but code should fail clearly if mismatch happens.
+
+```ts
+export function assertCodexRuntimeUnderstandsImages(runtimeCaps: RuntimeCapabilities): void {
+ if (!runtimeCaps.codexImages) {
+ throw new AgentAttachmentError(
+ 'attachment_runtime_transport_failed',
+ 'This Codex runtime does not support image attachments yet.'
+ );
+ }
+}
+```
+
+### Codex diagnostic redaction
+
+If command args are included in diagnostics, redact paths if needed and never include prompt text with secrets.
+
+Safe diagnostic:
+
+```json
+{
+ "provider": "codex",
+ "model": "gpt-5.4-mini",
+ "imageCount": 1,
+ "imageArgsUsed": true,
+ "stdinBytes": 31
+}
+```
+
+Unsafe diagnostic:
+
+```json
+{
+ "stdin": "full user prompt with private text",
+ "imageBase64": "..."
+}
+```
+
diff --git a/docs/team-management/agent-attachments-phase-4-opencode-vision-plan.md b/docs/team-management/agent-attachments-phase-4-opencode-vision-plan.md
new file mode 100644
index 00000000..4782bf65
--- /dev/null
+++ b/docs/team-management/agent-attachments-phase-4-opencode-vision-plan.md
@@ -0,0 +1,1337 @@
+# Phase 4 - OpenCode file parts and model vision capability gate
+
+## Summary
+
+Goal: support image attachments for OpenCode teammates only when the selected model/runtime can actually accept image parts, and show clear UI errors for text-only models.
+
+Chosen approach: **OpenCode file-part adapter + curated model vision capability catalog + unknown capability fail-safe + optional live smoke tooling**.
+
+🎯 8.3 🛡️ 8.0 🧠 7.2
+Estimated change size: `320-560` LOC across two repos.
+
+Repos:
+
+- `/Users/belief/dev/projects/claude/claude_team`
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+## Live proof
+
+Validated manually:
+
+```text
+opencode run --model openai/gpt-5.4-mini -f red-card-valid.png -> red
+opencode run --model openrouter/moonshotai/kimi-k2.6 -f red-card-valid.png -> red
+opencode run --model openrouter/z-ai/glm-4.5v -f red-card-valid.png -> red
+opencode run --model openrouter/z-ai/glm-5.1 -f red-card-valid.png -> model says it cannot view images
+```
+
+OpenCode session export shows file part shape:
+
+```json
+{
+ "type": "file",
+ "mime": "image/png",
+ "url": "data:image/png;base64,...",
+ "filename": "red-card-valid.png"
+}
+```
+
+Therefore transport can carry images, but model capability must gate usage.
+
+## Current behavior
+
+Current desktop delivery blocks OpenCode secondary attachments:
+
+```text
+opencode_attachments_not_supported_for_secondary_runtime
+```
+
+This was safe. Phase 4 replaces the blanket block with a capability-aware path.
+
+## Capability catalog
+
+Create a pure catalog in the attachment feature or shared OpenCode model utilities.
+
+```ts
+export type VisionCapability =
+ | { kind: 'supported'; source: 'curated' | 'provider-metadata' | 'live-probe' }
+ | { kind: 'unsupported'; source: 'curated' | 'model-response' | 'provider-metadata'; reason: string }
+ | { kind: 'unknown'; reason: string };
+
+export function resolveOpenCodeVisionCapability(modelId: string): VisionCapability {
+ const normalized = normalizeOpenCodeModelId(modelId);
+
+ if (normalized === 'openrouter/z-ai/glm-5.1') {
+ return {
+ kind: 'unsupported',
+ source: 'curated',
+ reason: 'GLM 5.1 did not accept image input in live OpenCode/OpenRouter smoke test.',
+ };
+ }
+
+ if (
+ normalized === 'openai/gpt-5.4-mini' ||
+ normalized === 'openrouter/moonshotai/kimi-k2.6' ||
+ normalized === 'openrouter/z-ai/glm-4.5v'
+ ) {
+ return { kind: 'supported', source: 'curated' };
+ }
+
+ if (/\b(vl|vision|image)\b/i.test(normalized)) {
+ return { kind: 'supported', source: 'provider-metadata' };
+ }
+
+ return {
+ kind: 'unknown',
+ reason: 'Image capability for this OpenCode model has not been verified.',
+ };
+}
+```
+
+Policy decision:
+
+- `supported`: allow image delivery.
+- `unsupported`: block with clear error.
+- `unknown`: block by default for production sends, but allow future manual override only if explicitly designed.
+
+Before release, do not allow unknown models to receive images silently.
+
+## OpenCode adapter
+
+```ts
+export class OpenCodeAttachmentAdapter implements AttachmentDeliveryAdapter {
+ readonly runtimeKind = 'opencode' as const;
+
+ canDeliver(ctx: AttachmentRuntimeContext, attachment: NormalizedAgentAttachment) {
+ if (attachment.kind !== 'image') {
+ return block('OpenCode currently supports image attachments only.');
+ }
+
+ const capability = resolveOpenCodeVisionCapability(ctx.modelId);
+ if (capability.kind === 'unsupported') {
+ return block(`${ctx.modelId} does not support image input. ${capability.reason}`);
+ }
+ if (capability.kind === 'unknown') {
+ return block(`Image input support for ${ctx.modelId} is unknown. Choose a verified vision model.`);
+ }
+
+ return allowIfMime(attachment, ['image/png', 'image/jpeg', 'image/webp']);
+ }
+
+ async prepare(ctx: AttachmentRuntimeContext, attachment: NormalizedAgentAttachment) {
+ const variant = selectOpenCodeFilePartVariant(attachment);
+ return {
+ runtimeKind: 'opencode',
+ attachmentId: attachment.id,
+ part: {
+ kind: 'opencode-file-part',
+ value: {
+ type: 'file',
+ mime: variant.mimeType,
+ url: toDataUrl(variant),
+ filename: attachment.originalName,
+ },
+ },
+ diagnostics: [`prepared OpenCode file part for ${ctx.modelId}`],
+ };
+ }
+}
+```
+
+## Orchestrator OpenCode bridge changes
+
+Current bridge sends text-only parts. Extend to accept file parts.
+
+```ts
+export interface OpenCodePromptPart {
+ type: 'text' | 'file';
+ text?: string;
+ mime?: string;
+ url?: string;
+ filename?: string;
+}
+
+export interface SendOpenCodePromptInput {
+ sessionId: string;
+ parts: OpenCodePromptPart[];
+}
+```
+
+When no attachments:
+
+```ts
+parts: [{ type: 'text', text: params.text }]
+```
+
+When attachments:
+
+```ts
+parts: [
+ { type: 'text', text: params.text },
+ { type: 'file', mime: 'image/png', url: 'data:image/png;base64,...', filename: 'screenshot.png' },
+]
+```
+
+Do not change proof gates. OpenCode delivery success still requires existing response proof:
+
+- visible reply;
+- safe plain-text materialization;
+- `message_send` with correct relay id;
+- work-sync report;
+- task/progress evidence.
+
+Attachment accepted by OpenCode is not response proof.
+
+## UI capability behavior
+
+Examples:
+
+```text
+GLM 5.1 does not support image input. Choose GLM 4.5V, Kimi K2.6, GPT-5.4-mini, Claude, or Codex.
+```
+
+```text
+Image input support for this OpenCode model is not verified. Choose a verified vision model before sending screenshots.
+```
+
+```text
+This image will be sent to Kimi K2.6 through OpenCode/OpenRouter.
+```
+
+Keep this next to attachment previews and also validate at send time.
+
+## Edge cases
+
+### OpenRouter provider missing
+
+If OpenCode has no OpenRouter key/provider, model list may not include `openrouter/*`. This is provider auth/config issue, not attachment issue.
+
+Expected error:
+
+```text
+OpenRouter is not connected in OpenCode. Connect OpenRouter before using this model.
+```
+
+### OpenRouter credits exhausted
+
+Preserve exact provider error. Do not rewrite it as attachment failure.
+
+### Model accepts file part but says cannot see image
+
+Treat this as model capability failure and update curated deny list only after repeated confirmed cases.
+
+Do not mark delivery transport failed if OpenCode accepted the prompt and model responded.
+
+### Unknown model
+
+Block image attachments by default. Text-only messages still work.
+
+### Multiple images
+
+Allow only if total optimized budget is safe. Do not send partial images.
+
+### File part size
+
+Use same optimized variants as Claude/Codex. OpenCode data URL still has base64 overhead, so backend serialized budget applies.
+
+### Retry
+
+Retry must reuse the same original/variant metadata. Do not recompress differently on every retry unless original variant is missing.
+
+## Tests
+
+### Unit
+
+- `openrouter/moonshotai/kimi-k2.6` is supported;
+- `openrouter/z-ai/glm-4.5v` is supported;
+- `openrouter/z-ai/glm-5.1` is unsupported;
+- unknown OpenRouter model is blocked for image;
+- OpenCode adapter emits `file` part with data URL;
+- no base64 appears in diagnostics;
+- text-only OpenCode messages remain unchanged.
+
+### Bridge tests
+
+- OpenCode bridge accepts text-only parts;
+- OpenCode bridge accepts text plus file parts;
+- unsupported part type rejects before API call;
+- response observer/proof semantics unchanged.
+
+### Desktop service tests
+
+- direct OpenCode secondary image send blocks unsupported model;
+- direct OpenCode secondary image send prepares file part for supported model;
+- provider/API error is preserved;
+- attachment accepted does not set delivery success without response proof.
+
+### Live e2e
+
+Only on explicit request:
+
+```bash
+OPENROUTER_API_KEY=... opencode run --pure --format json --dir /tmp --model openrouter/moonshotai/kimi-k2.6 "..." -f red-card-valid.png
+OPENROUTER_API_KEY=... opencode run --pure --format json --dir /tmp --model openrouter/z-ai/glm-4.5v "..." -f red-card-valid.png
+OPENROUTER_API_KEY=... opencode run --pure --format json --dir /tmp --model openrouter/z-ai/glm-5.1 "..." -f red-card-valid.png
+```
+
+Expected:
+
+```text
+Kimi K2.6 -> red
+GLM 4.5V -> red
+GLM 5.1 -> unsupported/refusal
+```
+
+## Safety checklist
+
+- OpenCode proof gates unchanged.
+- Unsupported models blocked before send.
+- Provider auth errors preserved.
+- No silent fallback to text base64.
+- No model-specific prompt hacks.
+- Capability catalog is pure and unit-tested.
+
+## Deep implementation details
+
+### Capability resolver should be pure
+
+Do not call OpenCode or OpenRouter inside the send hot path.
+
+```ts
+export interface OpenCodeVisionCapabilityResolver {
+ resolve(modelId: string): VisionCapability;
+}
+```
+
+Use static curated rules plus model id heuristics. Live probing belongs in Phase 5 tooling, not every send.
+
+### Capability result examples
+
+```ts
+resolve('openrouter/moonshotai/kimi-k2.6')
+// { kind: 'supported', source: 'curated' }
+
+resolve('openrouter/z-ai/glm-4.5v')
+// { kind: 'supported', source: 'curated' }
+
+resolve('openrouter/z-ai/glm-5.1')
+// { kind: 'unsupported', source: 'curated', reason: 'Live smoke returned text-only refusal.' }
+
+resolve('openrouter/qwen/qwen2.5-vl-72b-instruct')
+// { kind: 'supported', source: 'provider-metadata' }
+
+resolve('openrouter/minimax/minimax-m2.5')
+// { kind: 'unknown', reason: 'No verified vision capability.' }
+```
+
+### Model normalization
+
+OpenCode may expose ids in different forms:
+
+```text
+openrouter/moonshotai/kimi-k2.6
+moonshotai/kimi-k2.6 via openrouter provider context
+z-ai/glm-4.5v
+```
+
+Normalize consistently:
+
+```ts
+export function normalizeOpenCodeModelRef(input: string, providerId?: string): string {
+ const trimmed = input.trim().toLowerCase();
+ if (trimmed.startsWith('openrouter/')) return trimmed;
+ if (providerId === 'openrouter') return `openrouter/${trimmed}`;
+ return trimmed;
+}
+```
+
+### OpenCode bridge part schema
+
+Use narrow supported schema. Do not allow arbitrary JSON parts from renderer/main.
+
+```ts
+export type OpenCodeBridgePromptPart =
+ | { type: 'text'; text: string }
+ | { type: 'file'; mime: 'image/png' | 'image/jpeg' | 'image/webp'; url: string; filename: string };
+
+function assertOpenCodeBridgePromptPart(part: OpenCodeBridgePromptPart): void {
+ if (part.type === 'file') {
+ if (!part.url.startsWith(`data:${part.mime};base64,`)) {
+ throw new Error('Invalid OpenCode file part data URL.');
+ }
+ if (part.url.length > OPENCODE_FILE_PART_DATA_URL_MAX_CHARS) {
+ throw new Error('OpenCode file part is too large.');
+ }
+ }
+}
+```
+
+### Delivery result semantics
+
+Do not change existing OpenCode delivery proof. Attachment support is input preparation only.
+
+```text
+file part accepted != response delivered
+response text exists != relay proof for peer messages
+empty assistant turn != success
+provider auth failure != retryable prompt repair
+```
+
+### UI capability copy
+
+Keep model-specific text concise:
+
+```text
+Images are not supported by GLM 5.1 in OpenCode. Choose GLM 4.5V, Kimi K2.6, GPT-5.4-mini, Claude, or Codex.
+```
+
+For unknown:
+
+```text
+Image support for this OpenCode model is not verified. Send text only or choose a verified vision model.
+```
+
+For missing provider auth:
+
+```text
+OpenRouter is not connected for OpenCode. Connect OpenRouter before sending images to this model.
+```
+
+### More edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| OpenCode provider exists only through env key during tests | live smoke works, app-managed config still needs explicit provider setup |
+| User pastes image to OpenCode text-only model | composer blocks send before creating ledger record |
+| User sends text-only to GLM 5.1 | allowed, no image capability warning blocking send |
+| Kimi accepts image but returns low-quality answer | delivery success if response proof exists; not attachment bug |
+| OpenRouter key has quota limit | preserve exact provider error and show advisory/notification if runtime error path triggers |
+| OpenCode returns `ProviderModelNotFoundError` | provider/model config error, not attachment serialization |
+| File part accepted but no assistant response | existing OpenCode repair policy handles no response, not attachment feature |
+| Unsupported image MIME like SVG | block before OpenCode bridge |
+| Data URL too large | block before OpenCode API call |
+
+### Negative test for GLM 5.1
+
+```ts
+test('blocks GLM 5.1 image send before OpenCode prompt', async () => {
+ const result = planner.canPrepare({ runtimeKind: 'opencode', modelId: 'openrouter/z-ai/glm-5.1' }, [image]);
+ expect(result.allowed).toBe(false);
+ expect(result.blockers[0].code).toBe('attachment_model_vision_unsupported');
+});
+```
+
+### Positive test for Kimi
+
+```ts
+test('allows Kimi K2.6 image send', async () => {
+ const result = planner.canPrepare({ runtimeKind: 'opencode', modelId: 'openrouter/moonshotai/kimi-k2.6' }, [image]);
+ expect(result.allowed).toBe(true);
+});
+```
+
+### Regression traps
+
+- Making unknown OpenCode models permissive by default.
+- Marking ledger `delivered` just because OpenCode accepted a file part.
+- Adding model-specific prompt text like “you can see image” instead of capability gating.
+- Writing OpenRouter API key into logs while running live smoke.
+- Updating app-managed OpenCode auth store from ephemeral env without explicit user action.
+
+## File-by-file implementation plan
+
+### claude_team
+
+Potential files:
+
+```text
+src/features/agent-attachments/core/domain/OpenCodeVisionCapability.ts
+src/features/agent-attachments/main/adapters/output/OpenCodeAttachmentAdapter.ts
+src/main/services/team/TeamProvisioningService.ts
+src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts
+src/renderer/components/team/messages/MessageComposer.tsx
+```
+
+Do not place the capability map inside `TeamProvisioningService`.
+
+### agent_teams_orchestrator
+
+Potential files:
+
+```text
+src/services/opencode/OpenCodeSessionBridge.ts
+src/services/opencode/OpenCodeBridgeCommandHandler.ts
+src/services/opencode/OpenCodeDeliveryResponseObserver.ts
+src/services/opencode/*.test.ts
+```
+
+Bridge accepts structured parts. Observer/proof logic should remain unchanged except diagnostics may include attachment context.
+
+## Capability map structure
+
+Use a compact data file or pure module.
+
+```ts
+const OPENCODE_VISION_CAPABILITY_OVERRIDES: Record = {
+ 'openai/gpt-5.4-mini': { kind: 'supported', source: 'curated' },
+ 'openrouter/moonshotai/kimi-k2.6': { kind: 'supported', source: 'curated' },
+ 'openrouter/z-ai/glm-4.5v': { kind: 'supported', source: 'curated' },
+ 'openrouter/z-ai/glm-5.1': {
+ kind: 'unsupported',
+ source: 'curated',
+ reason: 'Live smoke returned model response saying image input is unsupported.',
+ },
+};
+```
+
+Heuristics only after exact overrides:
+
+```ts
+if (/\b(vl|vision|image)\b/i.test(modelId)) {
+ return { kind: 'supported', source: 'provider-metadata' };
+}
+```
+
+If no exact/heuristic match:
+
+```ts
+return { kind: 'unknown', reason: 'No verified vision capability for this model.' };
+```
+
+## UI state transitions
+
+| State | UI |
+|---|---|
+| supported | normal send enabled |
+| unsupported | send disabled with model-specific message |
+| unknown | send disabled with `not verified` message |
+| provider missing | send disabled with provider connect message |
+| provider quota/auth error after send | runtime error notification/advisory, not preflight warning |
+
+## Direct teammate delivery with attachments
+
+For OpenCode secondary direct user message:
+
+```text
+user -> OpenCode member
+```
+
+The send path must:
+
+1. resolve member provider/model from live config/meta;
+2. run attachment capability gate;
+3. prepare OpenCode file parts;
+4. call bridge with text + file parts;
+5. keep existing ledger proof semantics.
+
+Do not let UI-provided model labels drive capability. Backend must resolve actual member model.
+
+## Peer/lead relay with attachments
+
+For v1, be conservative.
+
+If user sends attachment to lead and lead delegates to OpenCode member, do not automatically forward original binary attachment unless a later phase explicitly implements attachment relay. The lead can describe or ask user to send directly.
+
+This avoids accidental broad binary propagation across agents.
+
+## More detailed tests
+
+### Capability resolver
+
+```ts
+it.each([
+ ['openrouter/moonshotai/kimi-k2.6', 'supported'],
+ ['openrouter/z-ai/glm-4.5v', 'supported'],
+ ['openrouter/z-ai/glm-5.1', 'unsupported'],
+ ['openrouter/minimax/minimax-m2.5', 'unknown'],
+])('resolves %s', (modelId, expected) => {
+ expect(resolveOpenCodeVisionCapability(modelId).kind).toBe(expected);
+});
+```
+
+### Bridge part validation
+
+```ts
+expect(() => assertOpenCodeBridgePromptPart({
+ type: 'file',
+ mime: 'image/png',
+ url: 'not-a-data-url',
+ filename: 'x.png',
+})).toThrow('Invalid OpenCode file part data URL');
+```
+
+### Ledger preservation
+
+```ts
+it('does not mark delivery successful only because file part was accepted', async () => {
+ const result = await delivery.sendWithAttachment(...);
+ expect(result.delivered).toBe(false);
+ expect(result.responsePending).toBe(true);
+});
+```
+
+## Review checklist
+
+- Unknown OpenCode model does not receive image by default.
+- GLM 5.1 image send is blocked before API call.
+- Kimi K2.6 and GLM 4.5V are allowed.
+- Text-only sends to all models still work.
+- OpenCode provider errors are preserved exactly.
+- Ledger/proof semantics unchanged.
+- No OpenRouter key printed in live test logs.
+
+## Phase 4 exit criteria
+
+Phase 4 is complete only when:
+
+- text-only OpenCode delivery path is unchanged;
+- OpenCode image send is allowed only for supported/verified vision models;
+- GLM 5.1 image send is blocked before OpenCode call;
+- Kimi K2.6 and GLM 4.5V image sends serialize to file parts in tests;
+- OpenCode delivery proof semantics are unchanged;
+- provider auth/quota/model errors remain exact;
+- no automatic live probe runs on normal send.
+
+## Cross-repo sequencing
+
+Recommended order:
+
+1. Orchestrator: extend OpenCode bridge to accept typed `parts` while preserving text-only API.
+2. Orchestrator: test file part serialization with fake client.
+3. Desktop: add OpenCode capability resolver.
+4. Desktop: add OpenCode adapter.
+5. Desktop: wire direct OpenCode secondary attachment path.
+6. Add UI warnings/blockers for unsupported/unknown models.
+7. Run live smoke only after unit/fixture tests pass.
+
+## Backward-compatible bridge API
+
+Keep old text API as wrapper.
+
+```ts
+async sendPromptText(input: { sessionId: string; text: string }) {
+ return this.sendPromptParts({
+ sessionId: input.sessionId,
+ parts: [{ type: 'text', text: input.text }],
+ });
+}
+```
+
+New API:
+
+```ts
+async sendPromptParts(input: { sessionId: string; parts: OpenCodeBridgePromptPart[] }) {
+ validateParts(input.parts);
+ return this.client.sendSessionMessage(input.sessionId, { parts: input.parts });
+}
+```
+
+## Capability resolver detail
+
+Return both machine code and user copy.
+
+```ts
+export interface OpenCodeVisionCapabilityDecision {
+ kind: 'supported' | 'unsupported' | 'unknown';
+ code:
+ | 'opencode_vision_supported'
+ | 'opencode_model_text_only'
+ | 'opencode_model_vision_unknown';
+ source: 'curated' | 'heuristic' | 'provider-metadata';
+ userMessage?: string;
+ diagnostic: string;
+}
+```
+
+This prevents UI from string-matching diagnostics.
+
+## Provider auth vs capability
+
+Do not require provider auth to decide static capability. A model can be known vision-capable even if auth is missing. Send still fails/preflights on auth separately.
+
+Example:
+
+```text
+openrouter/moonshotai/kimi-k2.6 capability = supported
+OpenRouter auth missing = provider setup blocker
+```
+
+Both may appear in UI, but auth blocker is operational and capability is model-level.
+
+## More OpenCode tests
+
+```ts
+it('keeps text-only API as wrapper around parts API', async () => {
+ await bridge.sendPromptText({ sessionId: 's', text: 'hello' });
+ expect(client.sendSessionMessage).toHaveBeenCalledWith('s', {
+ parts: [{ type: 'text', text: 'hello' }],
+ });
+});
+```
+
+```ts
+it('serializes image file part without changing response observer', async () => {
+ await bridge.sendPromptParts({
+ sessionId: 's',
+ parts: [
+ { type: 'text', text: 'what color?' },
+ { type: 'file', mime: 'image/png', url: 'data:image/png;base64,AAAA', filename: 'x.png' },
+ ],
+ });
+ expect(observer).not.toHaveNewSuccessRule();
+});
+```
+
+## More OpenCode bug traps
+
+| Trap | Prevention |
+|---|---|
+| unknown model allowed and silently fails | unknown blocks by default |
+| bridge accepts arbitrary part JSON | strict union validation |
+| file part accepted marks ledger delivered | tests assert proof unchanged |
+| provider key logged in smoke | redactor in smoke script |
+| model capability string hardcoded in UI only | backend resolver is source of truth |
+| OpenRouter model id normalization inconsistent | shared `normalizeOpenCodeModelRef` tests |
+
+## Detailed Implementation Checklist
+
+### Step 1 - Add OpenCode capability matrix
+
+OpenCode support depends on provider and model, not just provider id.
+
+```ts
+export interface OpenCodeModelVisionCapability {
+ provider: 'openai' | 'openrouter' | string;
+ modelPattern: RegExp;
+ vision: 'yes' | 'no' | 'unknown';
+ evidence: 'live-smoke' | 'docs' | 'manual' | 'default';
+}
+```
+
+Initial known evidence from prototype:
+
+- `openai/gpt-5.4-mini`: vision yes.
+- `openrouter/moonshotai/kimi-k2.6`: vision yes.
+- `openrouter/z-ai/glm-4.5v`: vision yes.
+- `openrouter/z-ai/glm-5.1`: vision no or unreliable for images based on smoke result.
+
+Unknown models should not silently accept images. Use warning/block depending on UX decision:
+
+- For release-safe behavior: block unknown vision models and explain.
+- For exploratory dev behavior: allow with explicit “model may not support images” warning only if user confirms. Not recommended before release.
+
+### Step 2 - Route OpenCode attachments through file parts
+
+Use OpenCode file attachment mechanism, not base64 text.
+
+Conceptual orchestrator command:
+
+```ts
+const args = ['run', '--format', 'json', '--model', model, prompt];
+for (const image of request.images) {
+ args.push('-f', image.path);
+}
+```
+
+If using HTTP/server API instead of CLI, keep the same contract: file path/part is transport-native.
+
+### Step 3 - Integrate with delivery ledger safely
+
+Attachment delivery result must still satisfy the existing prompt delivery proof rules.
+
+| Result | Ledger behavior |
+|---|---|
+| Model returns visible reply | Existing success path. |
+| Model returns plain text direct user reply | Existing safe materialization path. |
+| Model says it cannot view image | Delivery succeeded, semantic unsupported response. Mark delivered if visible reply exists. |
+| Provider rejects file | Delivery failed with provider diagnostic. |
+| Empty assistant turn | Existing bounded repair policy applies. |
+| Non-visible tool without progress | Existing proof-directed repair applies. |
+
+### Step 4 - Add exact diagnostics
+
+OpenCode attachment diagnostics should include provider/model context but redact secrets.
+
+Examples:
+
+- `OpenCode model openrouter/z-ai/glm-5.1 is not marked vision-capable for image attachments.`
+- `OpenCode image artifact missing before delivery.`
+- `OpenCode provider rejected image attachment: `
+- `OpenCode returned a visible reply saying it cannot inspect images.`
+
+Do not turn a model “I cannot see images” answer into transport failure if the model produced a visible answer. That is useful user feedback.
+
+## OpenCode Edge Cases
+
+| Case | Expected behavior |
+|---|---|
+| OpenRouter key quota exceeded | Provider failure with exact redacted diagnostic. |
+| OpenCode OAuth stale | Auth/session failure, not attachment failure. |
+| Model vision unknown | Block or warn according to release setting, default block. |
+| Model claims no image support despite capability yes | Visible semantic answer, not retry-loop. |
+| File path accepted but model returns empty turn | Existing bounded empty-turn repair. |
+| Concurrent messages with images | Ledger correlation by original message id remains authoritative. |
+| Retry of failed image delivery | Reuse same managed artifact if still present, otherwise fail artifact missing. |
+| User restarts member after image failure | Restart does not delete inbox message or attachment metadata. |
+
+## Capability Governance
+
+Do not scatter model lists across UI and backend.
+
+Recommended single source:
+
+```text
+src/features/agent-attachments/shared/opencodeVisionCapabilities.ts
+```
+
+Export both backend and renderer-safe functions:
+
+```ts
+export function resolveOpenCodeVisionCapability(input: {
+ providerId: string;
+ model: string;
+}): AgentAttachmentCapability {
+ // Pure function, no filesystem, no network.
+}
+```
+
+When adding a new model:
+
+1. Add capability entry with evidence comment.
+2. Add unit test.
+3. Add manual smoke result to this plan or docs.
+4. Do not infer full provider support from one model.
+
+## Manual OpenCode QA
+
+Run at least these visual prompts:
+
+1. `openai/gpt-5.4-mini`: red-card image -> expected `red`.
+2. `openrouter/moonshotai/kimi-k2.6`: red-card image -> expected `red`.
+3. `openrouter/z-ai/glm-4.5v`: red-card image -> expected `red`.
+4. `openrouter/z-ai/glm-5.1`: red-card image -> expected block or clear unsupported warning.
+5. Quota/key failure simulation -> exact provider error visible, no teammate offline unless runtime exits.
+
+## Phase 4 Exit Criteria
+
+- OpenCode text-only prompt path unchanged.
+- Vision-capable OpenCode models receive real image files.
+- Non-vision/unknown OpenCode models do not silently hallucinate image answers.
+- Existing OpenCode repair policy remains bounded.
+- Provider quota/auth errors remain exact and do not become generic “spawn failed”.
+
+
+## Implementation Safeguards
+
+### Capability result must be explainable
+
+Users need to understand why one OpenCode model supports screenshots and another does not.
+
+```ts
+export interface CapabilityExplanation {
+ supported: boolean;
+ reason: 'known_vision_model' | 'known_non_vision_model' | 'unknown_model' | 'unsupported_provider';
+ displayText: string;
+}
+```
+
+Examples:
+
+- `openai/gpt-5.4-mini supports image attachments.`
+- `openrouter/z-ai/glm-5.1 is not marked as image-capable. Choose a vision-capable model or remove images.`
+- `This OpenCode model has unknown image support. Image delivery is blocked for reliability.`
+
+### Do not couple OpenCode vision support to weak-model repair
+
+The proof-directed repair policy handles missing response proof. It should not decide model vision support.
+
+Keep separate:
+
+- `OpenCodeVisionCapability`: can this model receive images?
+- `OpenCodePromptDeliveryRepairPolicy`: did a delivered prompt produce required proof?
+- `OpenCodeDeliveryLedger`: what happened to this message attempt?
+
+### OpenCode provider rejection mapping
+
+OpenRouter/OpenCode errors should remain exact but redacted.
+
+| Provider error | Mapping |
+|---|---|
+| Quota/credits/token budget | provider rejected attachment/message, exact diagnostic. |
+| Model not found | model/provider config diagnostic. |
+| Unsupported file/input | attachment provider rejected. |
+| OAuth/session stale | provider auth/session diagnostic. |
+| Empty assistant turn | existing bounded no-response repair. |
+
+No regex classification is required to decide business logic. Regex/string matching can be used only for safe redaction and known diagnostic display cleanup.
+
+### OpenCode PR checklist
+
+- Unknown models do not silently receive images.
+- Supported models use native file parts.
+- No model-specific prompt hacks are added.
+- Existing text-only OpenCode tests still pass.
+- Existing empty-turn retry remains bounded.
+- Provider quota/auth errors are not masked by attachment code.
+
+### Additional OpenCode tests
+
+```ts
+it('blocks unknown OpenCode vision model before delivery', () => {
+ const decision = resolveOpenCodeVisionCapability({
+ providerId: 'openrouter',
+ model: 'some/new-model',
+ });
+
+ expect(decision.supported).toBe(false);
+ expect(decision.reason).toBe('unknown_model');
+});
+
+it('does not invoke repair policy for model capability block', () => {
+ const result = planOpenCodeAttachmentDelivery(unknownVisionModelInput());
+ expect(result.status).toBe('blocked');
+ expect(result.retryable).toBe(false);
+});
+```
+
+
+## Failure Injection Tests for Phase 4
+
+```ts
+describe('OpenCode image capability and delivery', () => {
+ it('blocks a known non-vision OpenCode model before prompt delivery', () => {
+ const result = planOpenCodeImageDelivery({
+ providerId: 'openrouter',
+ model: 'z-ai/glm-5.1',
+ attachments: [fakeImageAttachment()],
+ });
+
+ expect(result.status).toBe('blocked');
+ expect(result.failureCode).toBe('attachment_model_unsupported');
+ });
+
+ it('does not retry provider quota rejection as proof repair', async () => {
+ const result = await deliverOpenCodeImage(fakeOpenRouterQuotaError());
+
+ expect(result.retryKind).not.toBe('proof_directed_repair');
+ expect(result.failureCode).toBe('attachment_provider_rejected');
+ });
+});
+```
+
+## OpenCode Multi-Hop Edge Cases
+
+| Scenario | Safe behavior |
+|---|---|
+| User sends image to lead, lead delegates to OpenCode member | Lead message contains image; delegated message should include image only if explicitly attached/forwarded by lead protocol. Do not automatically leak user image to all teammates. |
+| User sends direct image to OpenCode member | Direct delivery uses OpenCode file parts. |
+| OpenCode member replies with tool call but no visible reply | Existing proof policy decides, not image layer. |
+| OpenCode member starts task from image | Task evidence/progress still required for task state. |
+| OpenCode model answers “I cannot access images” | Visible reply delivered; UI can show model limitation, no retry loop. |
+
+## Privacy Boundary for Delegation
+
+Do not automatically fan out attachments to every teammate. Attachment propagation should follow explicit message routing.
+
+Rules:
+
+- Direct `user -> member`: deliver attachment to that member only.
+- `user -> lead`: deliver attachment to lead only.
+- Lead delegation: attachment forwarded only if the generated structured message includes or references the attachment intentionally.
+- System nudges/work-sync: do not include user attachments.
+
+This avoids accidental data exposure across teammates.
+
+## Phase 4 Stop Conditions
+
+Stop and reassess if:
+
+- OpenCode requires model-specific prompt hacks to see images.
+- Unknown models must be allowed silently for UX convenience.
+- Existing proof repair tests start changing because of image capability logic.
+- Provider quota errors get converted into generic delivery proof failures.
+
+
+## File-Level Implementation Plan
+
+Desktop suggested files:
+
+```text
+src/features/agent-attachments/main/providers/opencodeAttachmentAdapter.ts
+src/features/agent-attachments/shared/opencodeVisionCapabilities.ts
+src/features/agent-attachments/main/providers/opencodeAttachmentAdapter.test.ts
+```
+
+Orchestrator suggested files:
+
+```text
+src/runtime/opencode/opencodeFileParts.ts
+src/runtime/opencode/opencodeFileParts.test.ts
+```
+
+### Capability matrix skeleton
+
+```ts
+const OPENCODE_VISION_MODELS: OpenCodeModelVisionCapability[] = [
+ {
+ provider: 'openai',
+ modelPattern: /^gpt-5\.4-mini$/,
+ vision: 'yes',
+ evidence: 'live-smoke',
+ },
+ {
+ provider: 'openrouter',
+ modelPattern: /^moonshotai\/kimi-k2\.6$/,
+ vision: 'yes',
+ evidence: 'live-smoke',
+ },
+ {
+ provider: 'openrouter',
+ modelPattern: /^z-ai\/glm-4\.5v$/,
+ vision: 'yes',
+ evidence: 'live-smoke',
+ },
+ {
+ provider: 'openrouter',
+ modelPattern: /^z-ai\/glm-5\.1$/,
+ vision: 'no',
+ evidence: 'live-smoke',
+ },
+];
+```
+
+Keep comments near each entry with date and smoke summary. Do not infer provider-wide support.
+
+### OpenCode adapter skeleton
+
+```ts
+export function buildOpenCodeAttachmentPromptRequest(input: {
+ text: string;
+ providerId: string;
+ model: string;
+ attachments: AgentAttachmentPayload[];
+}): OpenCodePromptRequest {
+ const capability = resolveOpenCodeVisionCapability({ providerId: input.providerId, model: input.model });
+ if (!capability.supported) {
+ throw new AttachmentDeliveryError('attachment_model_unsupported', capability.displayText);
+ }
+
+ return {
+ text: input.text,
+ files: input.attachments.map((attachment) => selectProviderImageArtifact(attachment, 'opencode').absolutePath),
+ };
+}
+```
+
+### OpenCode warning copy examples
+
+Use precise user-facing copy:
+
+```text
+This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.
+```
+
+```text
+OpenCode provider rejected the image attachment:
+```
+
+Avoid vague copy:
+
+```text
+Message failed.
+```
+
+```text
+OpenCode error.
+```
+
+
+## Phase 4 Deep Review Addendum
+
+### OpenCode capability decision must be cached but refreshable
+
+Model capability matrix is mostly static, but the user may change provider/model in Edit Team.
+
+Rules:
+
+- Resolve capability from current member metadata at send time.
+- Do not cache capability per member forever.
+- If member model changes, next send uses new model capability.
+- UI warnings should refresh after Edit Team save/restart.
+
+### OpenCode direct vs lead-routed images
+
+Direct user to OpenCode member:
+
+```text
+user -> alice(OpenCode) with image
+```
+
+Deliver image to Alice only.
+
+User to lead:
+
+```text
+user -> lead with image
+```
+
+Deliver image to lead only. Lead may choose to ask teammate, but automatic image forwarding should not happen unless future protocol explicitly supports attachment references.
+
+This is a privacy and predictability decision.
+
+### OpenCode unsupported model message examples
+
+For direct send:
+
+```text
+Alice is using openrouter/z-ai/glm-5.1, which is not verified for image attachments. Remove the image or switch Alice to a vision-capable model.
+```
+
+For team send to lead where lead is not OpenCode:
+
+```text
+The lead can receive this image. It will not be automatically forwarded to OpenCode teammates unless the lead explicitly sends it.
+```
+
+### OpenCode model capability update procedure
+
+When adding a new OpenCode vision model:
+
+1. Run red-square smoke test.
+2. Record provider, exact model id, date, result.
+3. Add capability matrix entry.
+4. Add unit test.
+5. If model is unreliable, mark unsupported or unknown, not supported.
+
+
+## Phase 4 Implementation Contract Addendum
+
+### Provider/model canonicalization
+
+OpenCode model ids can appear with or without provider prefix depending on UI/runtime source. Normalize before capability lookup.
+
+```ts
+export function canonicalizeOpenCodeModel(input: {
+ providerId: string;
+ model: string;
+}): { providerId: string; model: string } {
+ const providerId = input.providerId.toLowerCase();
+ const model = input.model.replace(/^openrouter\//, '').replace(/^openai\//, '');
+ return { providerId, model };
+}
+```
+
+Capability tests must cover both forms:
+
+- `providerId=openrouter`, `model=moonshotai/kimi-k2.6`
+- `providerId=openrouter`, `model=openrouter/moonshotai/kimi-k2.6`
+
+### OpenCode DTO between desktop and orchestrator
+
+```ts
+export interface OpenCodePromptRuntimeRequest {
+ text: string;
+ files?: Array<{
+ artifactId: string;
+ path: string;
+ mimeType: 'image/png' | 'image/jpeg';
+ sizeBytes: number;
+ }>;
+}
+```
+
+Rules:
+
+- `files` are provider-native attachments, not arbitrary user paths.
+- `files` empty means text-only behavior.
+- Orchestrator validates path before passing `-f` or HTTP file part.
+
+### OpenCode ledger interaction examples
+
+```ts
+if (capability.supported === false) {
+ return {
+ accepted: false,
+ delivered: false,
+ retryable: false,
+ failureCode: 'attachment_model_unsupported',
+ };
+}
+```
+
+This must not create a ledger retry attempt because the prompt was not delivered.
+
+If provider accepts prompt but returns empty assistant turn, existing ledger retry applies:
+
+```ts
+return {
+ accepted: true,
+ delivered: false,
+ retryable: true,
+ failureCode: 'empty_assistant_turn',
+};
+```
+
+
+## Implementation Readiness Addendum
+
+### Definition of Ready for Phase 4
+
+Before coding Phase 4:
+
+- Phase 1 normalized artifacts are stable.
+- Existing OpenCode delivery ledger tests are green.
+- OpenCode text-only direct reply path is green.
+- Capability matrix initial entries are accepted.
+- Orchestrator OpenCode file-part mechanism is identified.
+
+### OpenCode mocked sender strategy
+
+Use fake OpenCode sender outcomes:
+
+```ts
+type FakeOpenCodeOutcome =
+ | { kind: 'visible_reply'; text: string }
+ | { kind: 'empty_assistant_turn' }
+ | { kind: 'provider_error'; message: string }
+ | { kind: 'non_visible_tool' };
+```
+
+Test attachment adapter and ledger separately:
+
+- Adapter tests: capability and file-part request building.
+- Ledger tests: how delivery outcome affects retry/proof.
+- Do not combine both in one huge brittle test unless it is e2e.
+
+### OpenCode additional edge cases
+
+| Edge case | Expected behavior |
+|---|---|
+| Model id casing differs | Canonicalize before capability lookup. |
+| Provider prefix duplicated | Canonicalize. |
+| OpenRouter key missing | Existing provider auth diagnostic. |
+| OpenRouter credits exhausted | Provider diagnostic, no proof repair. |
+| Vision model returns empty | Existing bounded empty-turn retry. |
+| Non-vision model says cannot see image | If blocked before send, this should not occur. If it occurs from stale capability, visible reply is shown and capability should be corrected. |
+
+### Capability matrix change control
+
+Do not accept capability entries without evidence.
+
+Required evidence for `vision: yes`:
+
+- Real smoke prompt.
+- Model id exactly as runtime uses it.
+- Date.
+- Observed answer.
+
+Required evidence for `vision: no`:
+
+- Real smoke or provider documentation.
+- Observed refusal/unsupported behavior.
+
+
+## Final Phase 4 Acceptance Specs
+
+### Spec 1 - known vision OpenCode model
+
+```gherkin
+Given a user sends an image to openrouter/moonshotai/kimi-k2.6
+And the capability matrix marks it vision-capable
+When delivery is planned
+Then the image is sent as a native file part
+And existing ledger proof gates decide delivery success
+```
+
+### Spec 2 - known non-vision OpenCode model
+
+```gherkin
+Given a user sends an image to openrouter/z-ai/glm-5.1
+And the capability matrix marks it unsupported
+When delivery is planned
+Then delivery is blocked before prompt send
+And no retry ledger attempt is created
+And the UI explains that the model is not verified for image attachments
+```
+
+### Spec 3 - OpenCode provider quota error
+
+```gherkin
+Given an OpenCode provider rejects a message due to quota
+When an image message is sent
+Then the exact redacted provider diagnostic is shown
+And the failure is not converted into proof repair
+And the member launch state is unchanged
+```
+
+### Phase 4 exact PR contract
+
+The Phase 4 PR is acceptable only if:
+
+- Capability matrix has tests for every entry.
+- Unknown models default safe.
+- File-part request builder has tests.
+- Existing OpenCode ledger bounded retry tests remain green.
+- Unsupported capability block does not create a retry attempt.
+- Direct and lead-routed privacy rules are documented in tests or code comments.
+
+### OpenCode capability copy examples
+
+```text
+Jack is using openrouter/z-ai/glm-5.1, which is not verified for image attachments. Switch to a vision-capable model or remove the image.
+```
+
+```text
+Alice can receive this image because openai/gpt-5.4-mini is verified for image attachments.
+```
+
+
+## Phase 4 Pre-Mortem and Extra Safeguards
+
+### Likely OpenCode mistakes
+
+| Mistake | Concrete prevention |
+|---|---|
+| Unknown model allowed because “maybe vision” | Unknown defaults blocked. |
+| Capability lookup misses provider prefix variant | Canonicalization tests. |
+| Provider quota error enters proof repair | Provider rejection is terminal for that attempt, not proof repair. |
+| Direct user image gets auto-forwarded to teammates | Explicit route-only attachment propagation. |
+| Non-vision model gets image-less prompt silently | Capability block before delivery. |
+
+### OpenCode file-part transport abstraction
+
+Do not let high-level delivery know whether OpenCode uses CLI `-f` or HTTP multipart internally.
+
+```ts
+export interface OpenCodeFilePartTransport {
+ sendPrompt(input: {
+ sessionId: string;
+ text: string;
+ files: OpenCodeRuntimeFilePart[];
+ }): Promise;
+}
+```
+
+This allows future switch from CLI to server API without changing capability or ledger logic.
+
+### OpenCode semantic refusal handling
+
+If model responds visibly with “I cannot inspect images”:
+
+- Direct user message: show response as delivered.
+- Add optional diagnostic warning that capability matrix may be wrong.
+- Do not retry automatically.
+- Consider downgrading capability entry after repeated smoke failure.
+
+### OpenCode tests for no silent drop
+
+```ts
+it('does not send text-only prompt when image is unsupported', async () => {
+ const sender = createFakeOpenCodeSender();
+ await expect(deliverImageToUnsupportedOpenCodeModel(sender)).rejects.toMatchObject({
+ code: 'attachment_model_unsupported',
+ });
+ expect(sender.calls).toHaveLength(0);
+});
+```
+
diff --git a/docs/team-management/agent-attachments-phase-5-e2e-and-polish-plan.md b/docs/team-management/agent-attachments-phase-5-e2e-and-polish-plan.md
new file mode 100644
index 00000000..af15e401
--- /dev/null
+++ b/docs/team-management/agent-attachments-phase-5-e2e-and-polish-plan.md
@@ -0,0 +1,1311 @@
+# Phase 5 - Cross-runtime attachment E2E, diagnostics, docs, and polish
+
+## Summary
+
+Goal: make the completed attachment system observable, testable, and understandable for users before release.
+
+Chosen approach: **small live smoke harness + deterministic diagnostics + UI copy polish + documentation**, with no new runtime semantics.
+
+🎯 8.8 🛡️ 8.7 🧠 5.4
+Estimated change size: `180-320` LOC plus tests/docs.
+
+This phase should happen after Claude, Codex, and OpenCode adapters are implemented. It should not introduce new delivery behavior.
+
+## Deliverables
+
+- live attachment smoke script;
+- reusable test fixture image generator;
+- user-visible diagnostics for unsupported models and oversized images;
+- docs for supported runtimes/models;
+- release checklist.
+
+## Live smoke harness
+
+Create a script that generates a deterministic image and runs each supported runtime.
+
+Suggested location:
+
+```text
+scripts/smoke/agent-attachments-smoke.mjs
+```
+
+Sketch:
+
+```ts
+const cases = [
+ {
+ id: 'claude-subscription-streaming',
+ runtime: 'claude',
+ model: 'claude-haiku-4-5',
+ expected: /red/i,
+ },
+ {
+ id: 'codex-native-gpt-5-4-mini',
+ runtime: 'codex',
+ model: 'gpt-5.4-mini',
+ expected: /red/i,
+ },
+ {
+ id: 'opencode-openai-gpt-5-4-mini',
+ runtime: 'opencode',
+ model: 'openai/gpt-5.4-mini',
+ expected: /red/i,
+ },
+ {
+ id: 'opencode-openrouter-kimi-k2-6',
+ runtime: 'opencode',
+ model: 'openrouter/moonshotai/kimi-k2.6',
+ envRequired: ['OPENROUTER_API_KEY'],
+ expected: /red/i,
+ },
+ {
+ id: 'opencode-openrouter-glm-4-5v',
+ runtime: 'opencode',
+ model: 'openrouter/z-ai/glm-4.5v',
+ envRequired: ['OPENROUTER_API_KEY'],
+ expected: /red/i,
+ },
+ {
+ id: 'opencode-openrouter-glm-5-1-negative',
+ runtime: 'opencode',
+ model: 'openrouter/z-ai/glm-5.1',
+ envRequired: ['OPENROUTER_API_KEY'],
+ expectedUnsupported: true,
+ },
+];
+```
+
+The harness must:
+
+- redact keys;
+- use timeouts;
+- kill child processes on timeout;
+- write structured JSON result;
+- skip cases when required auth/env is missing;
+- never print base64 image content.
+
+## Deterministic fixture image
+
+Do not depend on external image files.
+
+Generate a small valid PNG with Node `zlib` and CRC32, like the prototype did.
+
+```ts
+export function writeRedCardPng(path: string): void {
+ // 320x240 red card with white center marker.
+}
+```
+
+This avoids flaky fixtures and keeps smoke tests self-contained.
+
+## Diagnostics UX
+
+Add compact diagnostics wherever attachments are shown or rejected.
+
+Examples:
+
+```text
+Sent 1 optimized image: screenshot.jpg, 1920x1080, 612 KB.
+```
+
+```text
+Images are not supported by openrouter/z-ai/glm-5.1. Choose GLM 4.5V, Kimi K2.6, GPT-5.4-mini, Claude, or Codex.
+```
+
+```text
+Attachment payload is too large after optimization: 8.4 MB serialized. Limit is 7.5 MB.
+```
+
+```text
+OpenRouter is not connected in OpenCode. Connect OpenRouter before using this model.
+```
+
+## Copy diagnostics
+
+When user copies diagnostics for a failed send, include:
+
+```text
+Attachment summary:
+- files: 2
+- optimized bytes: 1.2 MB
+- estimated serialized payload: 1.7 MB
+- target runtime: opencode
+- target model: openrouter/z-ai/glm-5.1
+- capability decision: unsupported image input
+```
+
+Do not include:
+
+- base64;
+- full API keys;
+- bearer tokens;
+- raw data URLs.
+
+## Documentation
+
+Add docs under:
+
+```text
+docs/team-management/agent-attachments.md
+```
+
+Contents:
+
+- supported runtimes;
+- supported model examples;
+- unsupported model examples;
+- why images may be resized;
+- why some models cannot receive screenshots;
+- troubleshooting auth/provider issues;
+- how to run smoke tests.
+
+## Release checklist
+
+Before release:
+
+- text-only messages still work for Claude/Codex/OpenCode;
+- oversized image blocked before send;
+- Claude image send works;
+- Codex image send works;
+- OpenCode OpenAI image send works;
+- OpenCode OpenRouter Kimi works if key configured;
+- OpenCode GLM 5.1 image is blocked or clearly marked unsupported;
+- no base64 appears in logs, copied diagnostics, or UI error text;
+- retry with attachments reuses artifacts or fails loudly;
+- removing attachments clears warnings;
+- unsupported model warning updates when model changes.
+
+## E2E scenarios
+
+### Scenario 1 - Claude lead screenshot
+
+```text
+Create/launch Claude team -> send screenshot to lead -> lead answers about image.
+```
+
+Expected:
+
+- no process crash;
+- message visible;
+- optimized attachment notice visible;
+- lead response received.
+
+### Scenario 2 - Codex lead screenshot
+
+```text
+Create/launch Codex team -> send screenshot -> Codex sees image via --image.
+```
+
+Expected:
+
+- artifact file created;
+- Codex args include `--image`;
+- no base64 in prompt text;
+- response received.
+
+### Scenario 3 - OpenCode supported model
+
+```text
+OpenCode Kimi K2.6 secondary -> direct user message with screenshot.
+```
+
+Expected:
+
+- file part delivered;
+- delivery proof still required;
+- response visible.
+
+### Scenario 4 - OpenCode unsupported model
+
+```text
+OpenCode GLM 5.1 secondary -> attempt screenshot send.
+```
+
+Expected:
+
+- send blocked before model call;
+- message explains model does not support image input;
+- no fake queued/pending delivery;
+- text-only send still works.
+
+### Scenario 5 - Oversized multi-image send
+
+```text
+Attach 5 large screenshots.
+```
+
+Expected:
+
+- optimizer reduces where safe;
+- if still too large, send blocked;
+- no partial delivery.
+
+## Test plan
+
+Suggested focused checks:
+
+```bash
+pnpm vitest run src/features/agent-attachments/**/*.test.ts test/main/ipc/teams.test.ts test/renderer/components/team/messages/MessageComposer.test.tsx
+pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/OpenCodePromptDeliveryLedger.test.ts
+pnpm typecheck --pretty false
+```
+
+Live smoke only when requested:
+
+```bash
+node scripts/smoke/agent-attachments-smoke.mjs --case claude-subscription-streaming
+node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini
+OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-kimi-k2-6
+```
+
+## Safety checklist
+
+- Smoke harness redacts secrets.
+- Live tests have timeouts and cleanup.
+- Docs clearly separate transport support from model vision support.
+- No new runtime behavior is introduced in this phase.
+
+## Deep implementation details
+
+### Live smoke output contract
+
+The smoke script should write machine-readable JSON and concise console output.
+
+```ts
+export interface AttachmentSmokeResult {
+ id: string;
+ runtime: 'claude' | 'codex' | 'opencode';
+ model: string;
+ status: 'passed' | 'failed' | 'skipped';
+ reason?: string;
+ responseText?: string;
+ durationMs: number;
+ diagnostics: string[];
+}
+```
+
+Console output example:
+
+```text
+PASS claude-subscription-streaming -> red
+PASS codex-native-gpt-5-4-mini -> red
+SKIP opencode-openrouter-kimi-k2-6 -> OPENROUTER_API_KEY not set
+FAIL opencode-openrouter-glm-5-1-negative -> expected unsupported but got red
+```
+
+Never print secrets.
+
+### Timeout wrapper
+
+```ts
+async function runWithTimeout(label: string, timeoutMs: number, run: (signal: AbortSignal) => Promise): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(new Error(`${label} timed out`)), timeoutMs);
+ try {
+ return await run(controller.signal);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+```
+
+For child processes, abort must kill process group when possible.
+
+### Redaction helper
+
+```ts
+export function redactAttachmentSmokeLog(input: string): string {
+ return input
+ .replace(/sk-or-v1-[A-Za-z0-9_-]+/g, 'sk-or-v1-[REDACTED]')
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [REDACTED]')
+ .replace(/data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[REDACTED];base64,[REDACTED]');
+}
+```
+
+### Docs structure
+
+`docs/team-management/agent-attachments.md` should include:
+
+```text
+# Agent attachments
+
+## Supported runtimes
+## Supported image models
+## Unsupported or unverified models
+## Why screenshots are optimized
+## Troubleshooting
+## Running smoke tests
+## Security and privacy notes
+```
+
+### UI polish details
+
+Attachment preview should show:
+
+```text
+screenshot.jpg
+1920x1080 - 612 KB - optimized
+```
+
+Unsupported model warning should include direct action:
+
+```text
+Change model
+Remove image
+```
+
+Do not show internal provider ids only. Use friendly label when available:
+
+```text
+GLM 5.1 via OpenRouter
+```
+
+But copied diagnostics should include exact model id:
+
+```text
+modelId=openrouter/z-ai/glm-5.1
+```
+
+### More e2e cases
+
+| Scenario | Expected |
+|---|---|
+| Text-only message after failed image send | succeeds normally |
+| User removes unsupported image and sends text | no stale warning blocks send |
+| User switches from GLM 5.1 to GLM 4.5V | warning clears and send allowed |
+| User switches from OpenCode to Claude | OpenCode model warning disappears, Claude budget warning remains if oversized |
+| OpenRouter key missing | OpenRouter smoke skipped, not failed |
+| OpenRouter quota exhausted | smoke failed with provider quota diagnostic, no secret printed |
+| Codex auth expired | Codex smoke failed with auth diagnostic, attachment system not blamed |
+| Claude subscription over limit | Claude smoke failed with provider limit diagnostic, attachment system not blamed |
+
+### Release readiness scoring
+
+Before shipping, score each area:
+
+| Area | Target score |
+|---|---:|
+| Text-only regression confidence | 9/10 |
+| Oversized image protection | 9/10 |
+| Claude image path | 8.5/10 |
+| Codex image path | 8/10 |
+| OpenCode OpenAI image path | 8/10 |
+| OpenCode OpenRouter model gating | 7.5/10 |
+| User-facing errors | 8.5/10 |
+
+If any score is below target, do not release the whole attachment feature. Ship only earlier phases.
+
+### Regression traps
+
+- Smoke tests accidentally depend on local user secrets and fail in CI.
+- UI says “image sent” when only optimization happened.
+- Diagnostics copy includes data URL.
+- Docs overpromise unknown OpenRouter models.
+- Negative model smoke becomes flaky because provider upgrades model capability. If GLM 5.1 starts supporting images, update catalog and test expectation.
+
+## File-by-file implementation plan
+
+### Smoke script
+
+Create:
+
+```text
+scripts/smoke/agent-attachments-smoke.mjs
+```
+
+Optional helper:
+
+```text
+scripts/smoke/lib/write-red-card-png.mjs
+scripts/smoke/lib/redact-smoke-log.mjs
+```
+
+Do not put live smoke in normal test suite by default.
+
+### Documentation
+
+Create:
+
+```text
+docs/team-management/agent-attachments.md
+```
+
+Link it from:
+
+```text
+docs/team-management/debugging-agent-teams.md
+```
+
+only if it helps support/debugging.
+
+### UI polish tests
+
+Potential tests:
+
+```text
+test/renderer/components/team/messages/MessageComposer.test.tsx
+test/renderer/utils/attachmentUtils.test.ts
+src/features/agent-attachments/**/*.test.ts
+```
+
+## Smoke script behavior details
+
+### CLI options
+
+```bash
+node scripts/smoke/agent-attachments-smoke.mjs --all
+node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini
+node scripts/smoke/agent-attachments-smoke.mjs --json /tmp/attachment-smoke.json
+```
+
+### Skip logic
+
+```ts
+if (case.envRequired?.some(name => !process.env[name])) {
+ return { status: 'skipped', reason: `${name} not set` };
+}
+```
+
+Missing auth should be `failed` if the runtime is expected to be locally logged in, but OpenRouter env cases can be `skipped` if key absent.
+
+### Child process cleanup
+
+```ts
+const child = spawn(command, args, { detached: true });
+try {
+ return await waitForResult(child, timeoutMs);
+} finally {
+ if (!child.killed) {
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch {}
+ }
+}
+```
+
+Be careful on macOS where process groups may differ. If not detached, kill child pid only.
+
+## Docs examples
+
+### Supported model section
+
+```md
+## Verified image-capable models
+
+- Claude subscription via stream-json
+- Codex native GPT-5.4-mini via `--image`
+- OpenCode OpenAI GPT-5.4-mini
+- OpenCode OpenRouter Kimi K2.6
+- OpenCode OpenRouter GLM 4.5V
+```
+
+### Unsupported model section
+
+```md
+## Known unsupported or text-only models
+
+- OpenCode OpenRouter GLM 5.1: accepts text but does not support image input in live smoke.
+```
+
+### Troubleshooting section
+
+```md
+If OpenCode says `Provider not found: openrouter`, connect OpenRouter in provider management or provide `OPENROUTER_API_KEY` for smoke tests.
+```
+
+## More polish edge cases
+
+| Edge case | UI/docs behavior |
+|---|---|
+| User sees “not verified” for a model they know supports vision | docs explain conservative default and how to request/verify model |
+| Live smoke passes for a previously unknown model | update capability catalog in separate commit |
+| Provider changes model behavior | negative smoke catches mismatch, catalog updated deliberately |
+| User reports model saw image but UI blocked | add override only after reproducing or provider metadata confirms |
+| User reports image too blurry | adjust Phase 1 quality policy, not provider adapters |
+| User reports process crashed with image | diagnostics should include payload bytes and runtime stderr tail, not base64 |
+
+## Final release decision tree
+
+```text
+If Phase 1 is green but Phase 2 is risky -> ship safer budget validation only.
+If Claude is green but Codex is flaky -> ship Claude only, keep Codex blocked.
+If Codex is green but OpenCode model gate is incomplete -> ship Claude+Codex, keep OpenCode blocked.
+If OpenCode OpenAI is green but OpenRouter is unstable -> allow OpenAI, block OpenRouter unknowns.
+```
+
+Do not hold safer early phases hostage to later dynamic OpenRouter model risk.
+
+## Phase 5 exit criteria
+
+Phase 5 is complete only when:
+
+- smoke harness can run selected cases independently;
+- smoke harness redacts secrets and data URLs;
+- docs list verified and unsupported models separately;
+- UI copy does not overpromise unknown models;
+- copied diagnostics include enough metadata to debug without leaking payload;
+- release checklist is green or explicitly scoped down.
+
+## Smoke harness case definitions
+
+```ts
+const cases: AttachmentSmokeCase[] = [
+ {
+ id: 'claude-streaming-haiku',
+ runtime: 'claude',
+ command: 'node',
+ args: ['scripts/smoke/runners/claude-sdk-image.mjs'],
+ expected: /\bred\b/i,
+ timeoutMs: 60_000,
+ },
+ {
+ id: 'codex-native-gpt-5-4-mini',
+ runtime: 'codex',
+ command: 'codex',
+ args: ['exec', '--json', '--skip-git-repo-check', '-C', '/tmp', '--model', 'gpt-5.4-mini', '--image', '$IMAGE', '-'],
+ stdin: 'Look at the attached image. Reply with exactly one word: red, green, or blue.',
+ expected: /\bred\b/i,
+ timeoutMs: 90_000,
+ },
+ {
+ id: 'opencode-openrouter-glm-5-1-negative',
+ runtime: 'opencode',
+ envRequired: ['OPENROUTER_API_KEY'],
+ expectCapabilityBlocked: true,
+ },
+];
+```
+
+For negative cases after Phase 4, prefer testing the app capability gate rather than spending OpenRouter tokens calling known unsupported models.
+
+## Diagnostics copy example
+
+```text
+Attachment delivery diagnostic
+team: atlas-hq
+recipient: jack
+runtime: opencode
+model: openrouter/z-ai/glm-5.1
+attachments: 1 image
+optimized bytes: 612 KB
+estimated serialized bytes: 842 KB
+capability: unsupported
+reason: GLM 5.1 is text-only for image input in verified OpenCode/OpenRouter smoke.
+```
+
+No base64, no data URL, no API key.
+
+## Documentation warnings
+
+Docs must say:
+
+```text
+Verified model support can change. If a model starts or stops accepting images, update the capability catalog and smoke expectations in a separate commit.
+```
+
+Docs must not say:
+
+```text
+All OpenRouter models support screenshots.
+```
+
+## Final pre-release manual checklist
+
+- Send text-only message to Claude lead.
+- Send optimized image to Claude lead.
+- Send text-only message to Codex lead.
+- Send image to Codex lead.
+- Send text-only direct message to OpenCode member.
+- Send image to OpenCode OpenAI member.
+- Send image to OpenCode Kimi K2.6 member if OpenRouter configured.
+- Attempt image to OpenCode GLM 5.1 and confirm it blocks before send.
+- Attempt oversized image and confirm it blocks before send.
+- Copy diagnostics and confirm no data URL/base64/key.
+
+## Phase 5 bug traps
+
+| Trap | Prevention |
+|---|---|
+| live smoke consumes tokens in normal CI | not part of default test command |
+| smoke fails due missing auth and blocks release | missing optional env is skipped, not failed |
+| docs become stale | capability catalog references live smoke date |
+| unsupported negative model changes behavior | update catalog/test explicitly |
+| copied diagnostics leak image data | redaction unit tests |
+
+## Detailed E2E Matrix
+
+Use one tiny deterministic image fixture: a red square with no metadata dependencies.
+
+| Provider path | Model | Prompt | Expected |
+|---|---|---|---|
+| Claude subscription | current configured Claude model | `What color is the square? One word.` | `red` |
+| Codex subscription | `gpt-5.4-mini` or available vision model | same | `red` |
+| OpenCode OpenAI | `openai/gpt-5.4-mini` | same | `red` |
+| OpenCode OpenRouter | `openrouter/moonshotai/kimi-k2.6` | same | `red` |
+| OpenCode OpenRouter | `openrouter/z-ai/glm-4.5v` | same | `red` |
+| OpenCode OpenRouter | `openrouter/z-ai/glm-5.1` | same | blocked/unsupported |
+
+## Negative E2E Matrix
+
+| Scenario | Expected |
+|---|---|
+| Oversized image | UI blocks before send with size/optimization details. |
+| Corrupt image | UI blocks with decode error. |
+| Missing optimized artifact | Delivery fails actionable, message preserved. |
+| Provider quota exhausted | Provider diagnostic shown, no generic spawn failed. |
+| Codex logged out | Existing Codex login diagnostic shown. |
+| OpenCode model no vision | Unsupported warning/block shown. |
+| Multiple images over total budget | Deterministic budget warning. |
+| Runtime exits after send | Runtime exit diagnostic separate from attachment validation. |
+
+## Automated Test Layers
+
+### Pure tests
+
+- Budget selection.
+- Capability resolution.
+- MIME/type classification.
+- Provider adapter serialization.
+- Redaction.
+
+### Main process tests
+
+- IPC validation rejects invalid attachment ids.
+- Backend never accepts raw renderer paths.
+- Missing artifacts produce controlled failure.
+- Delivery failure does not mutate launch state.
+
+### Renderer tests
+
+- Preview and optimization warnings.
+- Target switching recomputes capability warning.
+- Send button blocks unsupported attachments.
+- Removing attachments clears warnings.
+
+### Safe E2E tests
+
+- Provider command builders receive native attachment parts.
+- One real smoke per supported provider before release.
+
+## Release Checklist
+
+Before enabling broadly:
+
+- `pnpm typecheck --pretty false` passes.
+- Focused attachment pure tests pass.
+- Existing team launch tests still pass.
+- Existing OpenCode delivery tests still pass.
+- At least one real visual smoke is green for Claude, Codex, and OpenCode.
+- No new dependency is added without version/license check.
+- App logs do not contain raw base64 image data.
+- Copy diagnostics redacts paths/secrets where required.
+- User-facing warnings are clear and actionable.
+
+## Observability Additions
+
+Add lightweight diagnostics that help debug without leaking content.
+
+Safe diagnostic fields:
+
+```ts
+interface AttachmentDiagnosticSummary {
+ attachmentCount: number;
+ totalBytes: number;
+ optimizedTotalBytes: number;
+ providers: string[];
+ blockedReason?: AttachmentDeliveryFailureCode;
+ warningCodes: string[];
+}
+```
+
+Unsafe diagnostic fields:
+
+- Base64 content.
+- Full OCR/text extracted from images.
+- Provider API keys.
+- User home directory paths unless already part of explicit local diagnostics.
+- Full binary file hashes if privacy-sensitive.
+
+## Post-Release Monitoring
+
+Watch for these regressions after release:
+
+- Users report team going offline after sending images.
+- Users report image sent but model says it cannot see image on a supposedly vision-capable model.
+- Inbox/message JSON grows unexpectedly large.
+- Renderer memory spikes after attaching multiple screenshots.
+- Codex/OpenCode diagnostics become generic after attachment failures.
+- Retry loops repeat the same image message without bounded attempts.
+
+If any happen:
+
+1. Disable only affected provider adapter path.
+2. Keep normalization and UI warnings if stable.
+3. Preserve messages and attachment metadata for forensic debugging.
+4. Do not revert unrelated bootstrap/process backend changes in the same hotfix.
+
+## Final Safety Review Questions
+
+Before implementation starts, answer these in the PR description:
+
+- Does any provider receive base64 as plain text? If yes, why is that unavoidable?
+- Can renderer force backend to read an arbitrary file path? It must not.
+- What happens if optimized artifact is deleted before retry?
+- What happens if user switches target model after attaching image?
+- What happens if a provider supports text but not vision?
+- Does this change launch readiness or only message delivery?
+- Are all retries bounded by existing ledger/runtime rules?
+
+
+## Deep Verification Plan
+
+### Deterministic fixture generation
+
+Keep fixture generation local and deterministic so E2E does not depend on external image files.
+
+```ts
+export async function createRedSquareFixture(path: string): Promise {
+ // Use a tiny PNG fixture committed to test fixtures, or generate with deterministic bytes.
+}
+```
+
+Preferred:
+
+- Commit a tiny PNG fixture under test fixtures if repository policy allows binary fixtures.
+- Otherwise generate once in test setup using a deterministic encoder.
+
+### E2E assertion style
+
+Do not require exact model wording. Normalize response and assert semantic color.
+
+```ts
+function answerMentionsRed(text: string): boolean {
+ return /\bred\b/i.test(text) || /красн/i.test(text);
+}
+```
+
+Avoid accepting `I cannot view images` as pass.
+
+### E2E timeout strategy
+
+- Provider smoke tests should have generous but bounded timeout.
+- Failure should print provider/model, delivery path, and redacted stderr tail.
+- One provider failure should not hide other provider results.
+
+### Release-blocking vs non-blocking tests
+
+Release-blocking:
+
+- Pure unit tests.
+- Serialization tests.
+- One Claude smoke if Claude support is enabled.
+- One Codex smoke if Codex support is enabled.
+- One OpenCode OpenAI smoke if OpenCode image support is enabled for OpenAI.
+
+Non-blocking/manual before release:
+
+- Multiple OpenRouter model smokes.
+- Very large image performance test.
+- HEIC/clipboard platform-specific tests.
+
+### User documentation checklist
+
+Document these user-facing facts:
+
+- Screenshots are automatically optimized before sending.
+- Some OpenCode models do not support image attachments.
+- If a model cannot receive images, the UI will ask the user to switch model or remove images.
+- Raw files are not pasted as base64 into messages.
+- If delivery fails, the message is preserved and the error explains whether it was size, model support, auth, quota, or runtime.
+
+### Final PR template section
+
+Each implementation PR should include:
+
+```md
+## Attachment safety checklist
+
+- [ ] Text-only messages unchanged.
+- [ ] No base64 plain-text prompt fallback.
+- [ ] Backend validates attachment ids and paths.
+- [ ] Unsupported model behavior tested.
+- [ ] Provider auth errors remain provider auth errors.
+- [ ] Delivery failure does not mark teammate offline unless runtime exits.
+- [ ] Copy diagnostics redacts secrets.
+- [ ] E2E smoke listed or intentionally deferred.
+```
+
+
+## Failure Injection E2E Scenarios
+
+These should be run after unit coverage is green.
+
+### Runtime survives attachment failure
+
+1. Start a team with one known working member.
+2. Send unsupported oversized image.
+3. Verify send is blocked or delivery fails without marking team/member offline.
+4. Send text-only message afterward.
+5. Verify member still responds.
+
+### Provider auth failure remains auth failure
+
+1. Temporarily use stale/invalid provider auth in test environment.
+2. Send image message.
+3. Verify diagnostics mention auth/login, not image unsupported.
+4. Restore auth.
+
+### Non-vision model blocked
+
+1. Select known non-vision OpenCode model.
+2. Attach image.
+3. Verify UI blocks before delivery.
+4. Remove image.
+5. Verify text-only send works.
+
+### Artifact missing on retry
+
+1. Save message with image artifact.
+2. Delete artifact file in test harness before retry.
+3. Trigger retry.
+4. Verify `attachment_artifact_missing` and no text-only fallback.
+
+## Release Candidate Checklist
+
+A release candidate can include attachment support only if:
+
+- All Phase 1 pure/domain tests pass.
+- Provider enabled in UI has at least one real visual smoke test green.
+- Unsupported model UX has been manually verified.
+- Copy diagnostics includes attachment error code and provider/model, but no base64.
+- Team launch tests are unchanged or green.
+- Existing user message delivery tests are green.
+- OpenCode proof repair tests are green.
+- Codex auth/preflight tests are green.
+
+## Suggested rollout order in release notes
+
+1. “Image attachments are optimized before sending.”
+2. “Claude and Codex vision-capable models receive screenshots through native image channels.”
+3. “OpenCode image support depends on the selected model. The UI warns when a model is not image-capable.”
+4. “Unsupported images are blocked with actionable diagnostics instead of being pasted as text.”
+
+## Post-Implementation Audit Checklist
+
+After all phases are implemented, search the codebase for these anti-patterns:
+
+```text
+base64,
+data:image,
+--image,
+-f,
+attachment_provider_rejected,
+attachment_model_unsupported,
+launch-state,
+spawnStatus,
+bootstrapConfirmed
+```
+
+Audit goals:
+
+- `base64` appears only in provider-native block serialization or tests.
+- `data:image` is not inserted into prompt text.
+- `--image` appears only in Codex command builder and tests.
+- `-f` image usage appears only in OpenCode adapter/command builder and tests.
+- Attachment failures do not write launch-state/spawn status.
+- Bootstrap code does not import attachment modules.
+
+
+## Implementation Completion Checklist by Phase
+
+### Phase 1 completion evidence
+
+Attach to PR:
+
+- Unit test output for budget/validation/capability modules.
+- Screenshot of composer warning for unsupported/oversized image.
+- Screenshot of optimized image metadata display if UI exposes it.
+- Confirmation that no provider delivery code changed.
+
+### Phase 2 completion evidence
+
+Attach to PR:
+
+- Claude serialization test output.
+- Real Claude visual smoke result.
+- Text-only Claude regression result.
+- Diagnostic screenshot for oversized image block.
+
+### Phase 3 completion evidence
+
+Attach to PR:
+
+- Codex command builder test output.
+- Real Codex visual smoke result using subscription mode.
+- Confirmation Codex auth diagnostics unchanged.
+- Confirmation no shell string command construction was added.
+
+### Phase 4 completion evidence
+
+Attach to PR:
+
+- OpenCode capability matrix tests.
+- OpenCode OpenAI visual smoke result.
+- OpenRouter Kimi/GLM visual smoke results if enabled.
+- Unsupported GLM/non-vision model warning screenshot.
+- Confirmation OpenCode proof repair lifecycle unchanged.
+
+### Phase 5 completion evidence
+
+Attach to PR:
+
+- Full focused test list.
+- Manual smoke matrix with provider/model/date.
+- Known limitations section.
+- Release note draft.
+
+## Final bug-prevention checklist
+
+Before merging the final phase, manually inspect these concerns:
+
+- Does every provider adapter have tests for empty attachments and image attachments?
+- Does every adapter reject unsupported MIME types?
+- Does every adapter avoid changing text-only behavior?
+- Does any code parse provider error text to decide model capability? It should not.
+- Does any code write attachment failures into launch state? It must not.
+- Does any code drop attachments silently? It must not.
+- Does unsupported model UI block send or require explicit user action? It should.
+- Does retry preserve attachment identity? It must.
+- Does artifact missing produce a clear error? It must.
+
+## Honest risk estimate after this planning
+
+If implemented phase-by-phase exactly as planned:
+
+- Phase 1 risk: `2.5/10`
+- Phase 2 risk: `3/10`
+- Phase 3 risk: `4/10`
+- Phase 4 risk: `5/10`
+- Phase 5 risk: `2/10`
+- Overall release risk: `3.5/10`
+
+If implemented as one broad refactor:
+
+- Overall release risk: `7/10`
+
+Main reason: the dangerous part is not image resizing itself. The dangerous part is accidentally coupling attachments to delivery proofs, runtime liveness, auth diagnostics, or provider-specific launch code.
+
+
+## Phase 5 Deep Review Addendum
+
+### Cross-provider E2E script shape
+
+A single script can reduce manual drift, but it should not be required for normal unit tests.
+
+Conceptual CLI:
+
+```bash
+pnpm tsx scripts/smoke-agent-attachments.ts \
+ --provider claude \
+ --model current \
+ --image test/fixtures/red-square.png
+```
+
+Output should be machine-readable enough for logs:
+
+```json
+{
+ "provider": "codex",
+ "model": "gpt-5.4-mini",
+ "image": "red-square.png",
+ "result": "pass",
+ "answerPreview": "red",
+ "durationMs": 18432
+}
+```
+
+### E2E failure report template
+
+When an image smoke fails, report:
+
+```text
+Provider: OpenCode
+Model: openrouter/z-ai/glm-5.1
+Expected: red
+Observed: I cannot view images
+Classification: model_not_vision_capable
+Action: keep blocked/unsupported in capability matrix
+```
+
+Do not report it as generic runtime failure.
+
+### Manual release drill
+
+Before release, do this exact drill:
+
+1. Start app fresh.
+2. Create or open a simple team with Claude lead.
+3. Send text-only message, confirm normal reply.
+4. Send one screenshot to Claude, confirm visual answer.
+5. Switch to Codex member, send same screenshot, confirm visual answer.
+6. Switch to OpenCode vision member, send same screenshot, confirm visual answer.
+7. Switch to OpenCode unsupported model, confirm UI blocks image.
+8. Send text-only to unsupported model, confirm it still works.
+9. Restart app, verify messages render and attachments do not disappear.
+10. Copy diagnostics from failed unsupported send, verify no base64/secrets.
+
+### Final release confidence rating target
+
+Do not ship broad attachment support unless confidence reaches:
+
+- 🎯 at least `8.5/10` for Claude.
+- 🎯 at least `8/10` for Codex.
+- 🎯 at least `7.5/10` for OpenCode vision-capable known models.
+- 🛡️ at least `9/10` that unsupported models fail safely.
+- 🛡️ at least `9/10` that launch/readiness is unaffected.
+
+
+## Phase 5 Implementation Contract Addendum
+
+### Test command matrix
+
+Focused checks after each phase should be small and targeted.
+
+Phase 1:
+
+```bash
+pnpm vitest run test/features/agent-attachments/budgets.test.ts test/features/agent-attachments/validation.test.ts
+pnpm typecheck --pretty false
+```
+
+Phase 2:
+
+```bash
+pnpm vitest run test/features/agent-attachments/claudeAttachmentAdapter.test.ts
+pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts
+```
+
+Phase 3:
+
+```bash
+pnpm vitest run test/features/agent-attachments/codexAttachmentAdapter.test.ts
+# plus orchestrator codex command builder test in agent_teams_orchestrator
+```
+
+Phase 4:
+
+```bash
+pnpm vitest run test/features/agent-attachments/opencodeAttachmentAdapter.test.ts
+pnpm vitest run test/main/services/team/OpenCodePromptDeliveryLedger.test.ts test/main/services/team/OpenCodePromptDeliveryWatchdog.test.ts
+```
+
+Phase 5:
+
+```bash
+pnpm typecheck --pretty false
+pnpm vitest run test/features/agent-attachments/**/*.test.ts
+```
+
+Exact test paths may change during implementation, but the test categories should remain.
+
+### Smoke result ledger
+
+Create a simple markdown table in PR or docs after live smokes:
+
+| Date | Provider | Model | Runtime path | Result | Notes |
+|---|---|---|---|---|---|
+| 2026-05-09 | Claude | current | app team delivery | pass | red-square -> red |
+| 2026-05-09 | Codex | gpt-5.4-mini | app team delivery | pass | red-square -> red |
+| 2026-05-09 | OpenCode | openai/gpt-5.4-mini | app team delivery | pass | red-square -> red |
+
+This prevents accidental reliance on standalone prototype results when app path differs.
+
+### Final implementation order reminder
+
+Do not start with provider adapters. Start with Phase 1 domain and storage because provider adapters need a stable input contract.
+
+Correct order:
+
+1. Normalize and persist artifacts safely.
+2. Block unsupported/oversized sends.
+3. Add Claude adapter.
+4. Add Codex adapter.
+5. Add OpenCode adapter.
+6. Add E2E and docs polish.
+
+Wrong order:
+
+1. Hack provider CLI args.
+2. Later figure out storage.
+3. Later figure out UI warnings.
+4. Later discover retries lost files.
+
+
+## Implementation Readiness Addendum
+
+### Definition of Ready for Phase 5
+
+Before Phase 5:
+
+- All provider adapters have unit tests.
+- At least one provider image path works in app-managed runtime.
+- Unsupported model UX exists.
+- Diagnostics taxonomy is implemented.
+
+### Fixture strategy
+
+Prefer one committed tiny PNG fixture plus optional generated variants.
+
+Fixtures:
+
+```text
+test/fixtures/attachments/red-square.png
+test/fixtures/attachments/red-square-large.png
+test/fixtures/attachments/corrupt.png
+test/fixtures/attachments/not-image.txt
+```
+
+If binary fixtures are not desired, generate in test setup, but keep generation deterministic.
+
+### Smoke classification helper
+
+```ts
+export function classifyVisualSmokeAnswer(answer: string): 'pass' | 'refusal' | 'wrong' | 'empty' {
+ const normalized = answer.trim().toLowerCase();
+ if (!normalized) return 'empty';
+ if (/cannot|can't|unable|view|image/.test(normalized) && !/red/.test(normalized)) return 'refusal';
+ if (/\bred\b|красн/.test(normalized)) return 'pass';
+ return 'wrong';
+}
+```
+
+Do not overfit this helper to one provider. It is only for smoke classification.
+
+### Release notes limitations section
+
+Document limitations honestly:
+
+```md
+Known limitations:
+
+- Image support depends on selected provider and model.
+- Some OpenCode/OpenRouter models are intentionally blocked until verified for vision.
+- Very large images are optimized and may lose some detail.
+- Non-image files continue using existing file behavior and are not part of the new image pipeline.
+```
+
+### Final post-merge watchlist
+
+For the first release after merge, watch for:
+
+- Increased renderer memory usage after attaching screenshots.
+- Any report of team launch failures after sending images.
+- Any report that images appear in message text as base64.
+- Any OpenCode model that should be marked unsupported.
+- Any Codex auth diagnostic regression.
+- Any failed retry due to missing artifacts.
+
+
+## Final Phase 5 Acceptance Specs
+
+### Spec 1 - cross-provider visual smoke matrix
+
+```gherkin
+Given the red-square fixture
+When visual smoke runs for each enabled provider path
+Then every supported provider/model answers red
+And every unsupported model is blocked or classified unsupported
+And no failure is reported as generic spawn failure
+```
+
+### Spec 2 - diagnostics are safe
+
+```gherkin
+Given an attachment delivery fails
+When the user copies diagnostics
+Then diagnostics include provider, model, failure code, and redacted reason
+And diagnostics do not include base64 image bytes
+And diagnostics do not include API keys or bearer tokens
+```
+
+### Spec 3 - launch unaffected
+
+```gherkin
+Given a team launches without attachments
+When launch completes
+Then launch behavior matches pre-attachment behavior
+And no attachment module participates in bootstrap readiness
+```
+
+### Phase 5 exact PR contract
+
+The Phase 5 PR is acceptable only if:
+
+- Smoke results are recorded with date/provider/model/runtime path.
+- Unsupported model behavior is manually verified.
+- Existing launch and delivery focused tests are green.
+- Documentation explains limitations honestly.
+- Release notes avoid promising universal vision support.
+- Post-merge watchlist is included in release checklist.
+
+### Final “do not ship if” list
+
+Do not ship if any of these are true:
+
+- Text-only messages regress for any provider.
+- Any provider receives base64 as plain prompt text.
+- Unsupported OpenCode models silently accept image messages.
+- Attachment failure marks teammate spawn failed without runtime exit evidence.
+- Codex subscription auth diagnostics regress.
+- Image retry can silently send text without the original image.
+- Copy diagnostics leaks base64 or secrets.
+
+
+## Phase 5 Pre-Mortem and Extra Safeguards
+
+### Likely verification mistakes
+
+| Mistake | Prevention |
+|---|---|
+| Standalone provider smoke passes but app path fails | Smoke through app-managed team delivery. |
+| E2E accepts refusal as pass | Use classifier that rejects “cannot view image”. |
+| Only happy path tested | Include unsupported model, artifact missing, and auth failure. |
+| Logs leak base64 | Copy diagnostics test searches for base64 markers. |
+| Release notes overpromise | Explicit known limitations section. |
+
+### Copy diagnostics acceptance test
+
+```ts
+it('copy diagnostics omits image bytes and secrets', () => {
+ const text = buildAttachmentFailureDiagnostics(fakeImageFailure());
+ expect(text).toContain('attachment_model_unsupported');
+ expect(text).not.toContain('data:image');
+ expect(text).not.toMatch(/[A-Za-z0-9+/]{200,}={0,2}/);
+ expect(text).not.toMatch(/sk-[a-zA-Z0-9_-]+/);
+});
+```
+
+### App-path smoke script must prove route
+
+Smoke result should include route:
+
+```json
+{
+ "route": "user->lead",
+ "teamName": "attachment-smoke-claude",
+ "provider": "anthropic",
+ "model": "current",
+ "attachmentTransport": "claude_structured_blocks",
+ "result": "pass"
+}
+```
+
+If route is standalone CLI only, mark as prototype evidence, not release evidence.
+
+### Release manager checklist
+
+- Read all `Do not ship if` items.
+- Confirm no broad feature flag remains.
+- Confirm capability gates are user-visible product behavior.
+- Confirm no launch/provisioning files import attachment modules.
+- Confirm provider smoke evidence is app-path evidence.
+- Confirm rollback by provider is possible without data migration.
+
diff --git a/docs/team-management/agent-attachments.md b/docs/team-management/agent-attachments.md
new file mode 100644
index 00000000..9d5cb43d
--- /dev/null
+++ b/docs/team-management/agent-attachments.md
@@ -0,0 +1,139 @@
+# Agent attachments
+
+This document describes the v1 attachment path for Agent Teams.
+
+## Supported runtime paths
+
+- Claude lead/runtime: structured stream-json content blocks.
+- Codex native: optimized app-owned image files passed with repeatable `--image `.
+- OpenCode: model-gated `file` parts sent through the OpenCode session API.
+
+Do not append base64 to prompt text. Base64 is only valid inside provider-native structured payloads.
+
+## Current non-image file policy
+
+- Claude: `text/*` files and PDFs are allowed through structured document blocks.
+- Codex native: non-image files are blocked before provider delivery. Codex receives images only through the native image channel in this phase.
+- OpenCode: non-image files are blocked before provider delivery. OpenCode receives verified image file parts only in this phase.
+- Unknown or binary file types are blocked before provider delivery.
+
+This policy is intentionally conservative. It avoids silent text-only fallbacks, accidental huge stdin payloads, and provider-specific behavior that is not covered by live smokes.
+
+## Current image model policy
+
+- Claude: image attachments are allowed through structured image blocks.
+- Codex native: image attachments are allowed through native image args.
+- OpenCode `openai/gpt-5.4-mini`: allowed.
+- OpenCode `openrouter/moonshotai/kimi-k2.6`: allowed.
+- OpenCode `openrouter/z-ai/glm-4.5v`: allowed.
+- OpenCode `openrouter/z-ai/glm-5.1`: blocked for images.
+- Unknown OpenCode models: blocked for images until verified.
+
+Text-only messages continue to work for unsupported image models.
+
+## Size and optimization rules
+
+The renderer optimizes images before send. The backend still validates and owns final delivery decisions.
+
+- Original attachments are immutable.
+- Optimized variants are derived artifacts.
+- If optimized images exceed the runtime budget, sending must fail before provider delivery.
+- Multiple images must be delivered together or blocked together. No partial image delivery.
+
+## Diagnostics rules
+
+Diagnostics may include:
+
+- attachment count;
+- optimized bytes;
+- target runtime and model;
+- capability decision;
+- provider/runtime error text.
+
+Diagnostics must not include:
+
+- base64 payloads;
+- data URLs;
+- API keys;
+- bearer tokens.
+
+## Smoke tests
+
+The smoke harness generates a deterministic red PNG and checks real CLI transports.
+
+List cases:
+
+```bash
+node scripts/smoke/agent-attachments-smoke.mjs --list
+```
+
+Run all cases and save a JSON report:
+
+```bash
+node scripts/smoke/agent-attachments-smoke.mjs --all --json /tmp/agent-attachments-smoke.json
+```
+
+Run Codex native:
+
+```bash
+node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini
+node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini-multi-image
+```
+
+Run Claude subscription stream-json:
+
+```bash
+node scripts/smoke/agent-attachments-smoke.mjs --case claude-subscription-streaming
+node scripts/smoke/agent-attachments-smoke.mjs --case claude-subscription-streaming-multi-image
+```
+
+Run OpenCode OpenAI:
+
+```bash
+node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openai-gpt-5-4-mini
+```
+
+Run OpenRouter cases:
+
+```bash
+OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-kimi-k2-6
+OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-kimi-k2-6-multi-image
+OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-glm-4-5v
+OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-glm-5-1-negative
+```
+
+The script extracts assistant/result text from JSONL output before matching expected answers. This prevents false positives from prompts, base64 payloads, or diagnostics. It also redacts stdout/stderr tails for generated image bytes, data URLs, bearer tokens, API keys, environment-provided secrets, and long provider metadata signatures.
+
+## Live verification record
+
+Latest local verification: 2026-05-09.
+
+| Scope | Command or case | Result | Notes |
+| --- | --- | --- | --- |
+| Claude visual transport | `claude-subscription-streaming` | passed | Real Claude CLI `stream-json` run answered `red` for generated PNG. |
+| Claude multi-image transport | `claude-subscription-streaming-multi-image` | passed | Real Claude CLI `stream-json` run received three generated PNGs and answered `red` from extracted assistant text. |
+| Codex visual transport | `codex-native-gpt-5-4-mini` | passed | Real Codex native `--image` run answered `red` for generated PNG. |
+| Codex multi-image transport | `codex-native-gpt-5-4-mini-multi-image` | passed | Real Codex native run received three repeated `--image` args and answered `red` from extracted assistant text. |
+| OpenCode OpenAI visual transport | `opencode-openai-gpt-5-4-mini` | passed | Real OpenCode file attachment run answered `red` after local OpenCode OpenAI auth was refreshed. |
+| OpenRouter Kimi visual transport | `opencode-openrouter-kimi-k2-6` | passed | Real OpenCode file attachment run through OpenRouter answered `red` for generated PNG. |
+| OpenRouter Kimi multi-image transport | `opencode-openrouter-kimi-k2-6-multi-image` | passed | Real OpenCode file attachment run through OpenRouter received three generated PNGs and answered `red` from extracted assistant text. |
+| OpenRouter GLM vision transport | `opencode-openrouter-glm-4-5v` | passed | Real OpenCode file attachment run through OpenRouter answered `red` for generated PNG. |
+| OpenRouter GLM non-vision guard | `opencode-openrouter-glm-5-1-negative` | passed as guard | Model responded that it cannot process images. The app policy blocks this model for image attachments before app delivery. |
+| CLI process launch | `scripts/prove-agent-cli-launch.mjs` | passed | Real `opencode`, `codex`, and `claude` binaries launched through `execCli` and `spawnCli`. |
+| OpenCode team provisioning | `scripts/prove-opencode-team-provisioning.mjs` with `OPENCODE_E2E_MODEL=openai/gpt-5.4-mini` | passed | Real pure OpenCode team created through `TeamProvisioningService`, live members verified, then stopped. |
+| Mixed Anthropic + Codex + OpenCode team launch | `MixedProviderTeamLaunch.live.test.ts` | passed | Real mixed team launch passed with Claude subscription auth, Codex subscription auth, and OpenCode. |
+
+`--all` can return non-zero when local provider auth is invalidated. Treat the per-case rows above as the release signal when debugging local credential issues.
+
+## Release checklist
+
+- Text-only messages still work for Claude, Codex, and OpenCode.
+- Oversized images fail before provider delivery.
+- Claude image send uses structured image blocks.
+- Claude text/PDF file send uses structured document blocks.
+- Codex image send uses `--image`, not prompt base64.
+- Codex non-image files fail before provider delivery.
+- OpenCode image send is blocked for unknown/non-vision models.
+- OpenCode non-image files fail before provider delivery.
+- Attachment retry reuses the same artifacts or fails loudly.
+- Copied diagnostics do not include base64 or data URLs.
diff --git a/docs/team-management/debugging-agent-teams.md b/docs/team-management/debugging-agent-teams.md
index a6baac42..cf48676f 100644
--- a/docs/team-management/debugging-agent-teams.md
+++ b/docs/team-management/debugging-agent-teams.md
@@ -52,6 +52,34 @@ Primary launch and OpenCode secondary lanes are different paths.
When a launch hangs at `Prepared communication channels for X/Y members`, check whether `Y` incorrectly includes secondary OpenCode members. The filesystem monitor should wait for `effectiveMembers`, not every requested member.
+## Teammate Runtime Debug Mode
+
+Desktop launches use the app-managed process backend by default. That is the supported default for
+normal app launches because the app owns the process lifecycle, runtime logs, cleanup, and bootstrap
+evidence.
+
+For local debugging, force pane-backed teammates through `tmux`:
+
+```bash
+CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
+```
+
+For a single launch from the UI, add this to custom CLI args:
+
+```bash
+--teammate-mode tmux
+```
+
+Expected behavior:
+- `tmux` mode should remove `CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES` from the launch env.
+- The desktop app should pass `--teammate-mode tmux` to the runtime CLI.
+- The orchestrator should report `backend_type: "tmux"` and `tmux_pane_id` like `%1`.
+- If `tmux` is unavailable, the launch dialog should block explicit tmux mode with a tmux readiness message.
+
+Use this mode to inspect interactive CLI behavior, terminal prompts, and pane output. Do not treat it
+as equivalent to the process backend for recovery semantics; persisted pane IDs can help discovery,
+but app restart does not make old panes a fully app-owned runtime again.
+
## Member State Meanings
Common `launch-state.json` cases:
diff --git a/docs/team-management/member-log-stream-v2-implementation-plan.md b/docs/team-management/member-log-stream-v2-implementation-plan.md
new file mode 100644
index 00000000..ef9b460f
--- /dev/null
+++ b/docs/team-management/member-log-stream-v2-implementation-plan.md
@@ -0,0 +1,1231 @@
+# Member Log Stream V2 Implementation Plan
+
+## Goal
+
+Сделать в попапе участника новый формат логов, визуально и поведенчески близкий к `Task Log Stream`, но со scope "все доступные логи выбранного участника", а не "логи конкретной задачи".
+
+Ключевой продуктовый ответ:
+
+- Показываем логи только выбранного участника, а не всех агентов команды.
+- Если у выбранного участника есть безопасно доступные Claude/OpenCode/Codex источники, они могут быть показаны в одном stream.
+- Вариант 2 не обещает абсолютно полный provider-wide audit log для всех runtime. Для этого нужен вариант 3.
+- Старый `MemberLogsTab` не удаляем на первом шаге. Он остается fallback, чтобы снизить риск регрессий.
+
+## Decision
+
+Выбран вариант 2:
+
+🎯 8.5 🛡️ 8.5 🧠 6
+Оценка изменений: примерно 1500-2300 строк вместе с тестами, если делать canonical feature slice, аккуратную source-port архитектуру, dedupe cumulative subagent snapshots, вынести OpenCode projection mapper, покрыть fallback boundary, добавить member tracking activation, OpenCode/renderer in-flight protection и аккуратно отделить provider-neutral message hygiene от board/task sanitization.
+
+Почему не frontend-only:
+
+- Старый `MemberLogsTab` работает с session summaries и раскрытием `MemberExecutionLog`.
+- Новый stream требует готовый normalized response: participants, segments, chunks, source metadata.
+- Делать это в renderer означало бы тащить parsing/composition туда, где уже нет нужных main-process primitives и где выше риск UI freeze.
+
+Почему не вариант 3:
+
+- Полный member-wide Codex native stream требует отдельного trace index/read path по owner/member без taskId.
+- Полный OpenCode multi-lane stream требует либо безопасного list-all-lanes contract, либо явного lane resolution в desktop.
+- Это полезно, но заметно повышает риск смешать чужие логи или показать неполную картину как полную.
+
+## Options Considered
+
+| Вариант | Оценка | Пример LOC | Что делает | Главный риск |
+| --- | --- | ---: | --- | --- |
+| 1. Frontend-only reuse старых member logs | 🎯 6 🛡️ 5 🧠 3 | 150-250 | Renderer берет `getMemberLogs` и пытается отрисовать похоже на stream | Старый формат данных не равен stream, легко получить UI-псевдопаритет без реального качества |
+| 2. Canonical feature slice плюс source ports | 🎯 8.5 🛡️ 9 🧠 7 | 1500-2300 | `src/features/member-log-stream` owns contracts/core/source ports/renderer, app shell only integrates it | Нужно держать provider sources маленькими, dedupe cumulative logs, жестко ограничить response и не мигрировать task logs в этом PR |
+| 3. Maximum provider/runtime log stream | 🎯 6 🛡️ 6 🧠 9 | 1000-1800+ | Отдельный provider-wide member log index для Claude/OpenCode/Codex | Высокий риск ошибок в Codex/OpenCode attribution и долгий rollout |
+
+## Current Code Facts
+
+Точки интеграции в `claude_team`:
+
+- `src/renderer/components/team/members/MemberDetailDialog.tsx` сейчас показывает ``.
+- `src/renderer/components/team/members/MemberLogsTab.tsx` вызывает `api.teams.getMemberLogs(...)` и рендерит старые session logs.
+- `src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx` вызывает `api.teams.getTaskLogStream(teamName, taskId)` и рендерит новый stream через `MemberExecutionLog`.
+- `TaskLogStreamSection` only subscribes to `onTeamChange`; `TaskLogsPanel` separately enables `setTaskLogStreamTracking`. Member popup needs its own tracking activation.
+- `TeamLogSourceTracker` tracks activity only while at least one consumer is active. Its consumer counts are per team and per consumer name, so member stream should add a semantic `member_log_stream` consumer instead of starting a separate watcher layer.
+- `TaskLogStreamSection.normalizeResponse()` preserves only known task response fields, so a reused copy can drop member fields like `coverage`, `warnings`, `metadata`, `truncated`, `generatedAt` and `segment.source`.
+- `BoardTaskLogParticipant` is actor identity, not source identity. Provider/session/lane labels for member logs must stay in `MemberLogStreamSegment.source`, not in fake provider-specific participants.
+- `src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts` возвращает `BoardTaskLogStreamResponse`.
+- `src/shared/types/team.ts` уже содержит `BoardTaskLogParticipant`, `BoardTaskLogSegment`, `BoardTaskLogStreamResponse`.
+- `src/shared/types/team.ts` уже содержит `TeamMemberSnapshot.providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`, но `ResolvedTeamMember` пока не объявляет эти поля.
+- `src/renderer/store/slices/teamSlice.ts` в `buildResolvedMember()` возвращает `{ ...snapshot, status, messageCount, lastActiveAt }`, поэтому runtime object уже несет эти поля. Это почти type-contract change, а не новая data mapping ветка.
+- `src/main/services/team/TeamMemberLogsFinder.ts` уже умеет искать member logs и содержит attribution precedence.
+- `src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts` умеет strict-parse JSONL transcript files с cache и concurrency.
+- `BoardTaskExactLogStrictParser.parseFiles()` вызывает `cache.retainOnly(uniquePaths)`, а underlying `BoardTaskActivityParseCache.retainOnly()` удаляет не только parsed cache, но и `inFlight` entries вне текущего набора файлов. Поэтому parser instance нельзя шарить между task stream и member stream без изменения cache ownership.
+- `src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts` строит `EnhancedChunk[]`, которые уже понимает `MemberExecutionLog`.
+- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts` уже имеет `getOpenCodeTranscript(...)`, но пока без `laneId`.
+- `CodexNativeTraceReader.readTaskRuns()` is task-keyed and scans `processed//` plus optional `incoming//`, then caps recent candidates before parsing. It has no member-wide owner index.
+- `CodexNativeTraceProjector` only projects native tool events (`toolSource === 'native'`), not full Codex chat/runtime history.
+- `src/main/ipc/teams.ts`, `src/preload/index.ts`, `src/preload/constants/ipcChannels.ts` уже имеют аналогичные handlers/channels для `getTaskLogStream`.
+- `initializeTeamHandlers()` сейчас принимает много optional dependencies позиционно, и `test/main/ipc/teams.test.ts` передает их в этом порядке. Новую feature нельзя встраивать туда как очередной owned team service.
+- `src/renderer/api/httpClient.ts` имеет browser-mode stub для `getTaskLogStream`; для member stream нужен такой же safe fallback, иначе можно сломать browser compile/test path.
+- `findRecentMemberLogFileRefsByMember()` сейчас принимает третий аргумент только как numeric/null `mtimeSinceMs`, не object options; не передает `forceRefresh`; добавляет lead transcript до mtime filter; и возвращает refs без `kind`/`sizeBytes`/`messageCount`.
+- В OpenCode сейчас есть два mapper-like `toParsedMessage()`: richer private mapper в `OpenCodeTaskLogStreamSource` и более lossy mapper в `OpenCodeTaskStallEvidenceSource`. Канонический источник для shared mapper - task stream source, не stall monitor.
+
+Дополнительный deep research зафиксирован в:
+
+- `docs/team-management/member-log-stream-v2-research-addendum.md`.
+
+Самые важные уточнения оттуда:
+
+- для Claude/member transcript использовать `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()`, не `findMemberLogPaths()`;
+- для OpenCode передавать `laneId` из member snapshot в `getMemberLogStream` options;
+- `ResolvedTeamMember` type нужно явно расширить runtime/lane fields: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`;
+- Codex native в варианте 2 должен быть skipped или partial native-tool trace, но не "полный Codex log";
+- member stream source stack должен иметь отдельный `BoardTaskExactLogStrictParser`, потому что `parseFiles().retainOnly()` вычищает cache и `inFlight` parse entries вне текущего набора файлов.
+- архитектурно лучше выбрать canonical feature slice: `src/features/member-log-stream` owns contracts/core/application/source ports/renderer, while existing team surfaces remain thin app-shell integration points;
+- response нужно ограничивать на backend: `maxTranscriptFiles=40`, `maxSegments=30`, `maxChunks=250`, `maxSourceMessages=1200`, `maxMessagesPerSegment=300`, `openCodeMessageLimit=400`;
+- `laneId` нельзя валидировать как member name, нужен отдельный optional validator с поддержкой `secondary:opencode:`.
+- `findRecentMemberLogFileRefsByMember()` dedupe только by `filePath`, а cumulative subagent snapshots могут дублировать turn history;
+- лучше расширить `MemberLogFileRef` optional `kind`/`sizeBytes` и dedupe subagent refs by `memberName + sessionId` до parse;
+- OpenCode projection conversion сейчас private внутри task-specific source, поэтому нужен маленький shared mapper вместо копипаста.
+- shared OpenCode mapper нужно извлекать из `OpenCodeTaskLogStreamSource`; mapper в stall monitor не подходит как источник, потому что он теряет часть content/tool-result семантики.
+- `maxChunks` недостаточен как единственный budget, потому что один AI chunk может содержать сотни tool calls/messages;
+- member popup live refresh должен слушать same-team `log-source-change` и `task-log-change`, но не `tool-activity`.
+- `setMemberLogStreamTracking()` should map to a new `TeamLogSourceTracker` consumer `member_log_stream`. Reusing the task-named API would work technically but creates semantic coupling.
+- Codex native member stream remains skipped in first PR because current trace reader is task-first and current projector is native-tool-only.
+- новый IPC handler нужно регистрировать feature-owned способом и не сдвигать existing positional dependencies в `initializeTeamHandlers()`;
+- browser-mode API fallback должен возвращать полный empty `MemberLogStreamResponse`, а не бросать ошибку.
+
+Точки интеграции в orchestrator:
+
+- `runtime transcript` уже принимает `--team`, `--member`, `--lane`, `--projection-only`, `--limit`.
+- `resolveOpenCodeSessionRecordForCli()` специально требует `--lane`, если есть несколько session records для одного `team/member`.
+- Поэтому orchestrator менять кодом не нужно для варианта 2, если desktop начнет передавать `laneId` там, где он надежно известен.
+
+## Target User Experience
+
+В попапе участника раздел `Logs` должен выглядеть как новый stream:
+
+- header остается понятным для member popup: `Logs`, не `Task Log Stream`;
+- визуальный формат chunks, tool calls, message blocks и spacing должен быть как в `Task Log Stream`;
+- описание source не должно говорить "Task-scoped";
+- при пустом stream показываем спокойный empty state;
+- при ошибке нового stream показываем fallback-кнопку или inline fallback на старый `MemberLogsTab`;
+- для одного участника chips обычно не нужны;
+- если у участника несколько sessions/lanes, segment headers должны оставаться видимыми, чтобы не превратить разные сессии в один непрозрачный блок.
+
+⚠️ Важное поведение: stream не должен показывать "логи всех агентов вместе". Он показывает только выбранного member. Если внутри выбранного member есть разные provider/runtime источники, они могут быть объединены только после безопасной attribution/resolution.
+
+## Target Architecture
+
+```mermaid
+flowchart LR
+ A["MemberDetailDialog"] --> B["MemberLogStreamSection"]
+ B --> C["member-log-stream renderer hook"]
+ C --> D["feature preload/API bridge"]
+ D --> E["feature IPC getMemberLogStream"]
+ E --> S["GetMemberLogStreamUseCase"]
+ S --> F["MemberLogStreamBudget"]
+ S --> G["ClaudeMemberTranscriptStreamSource"]
+ S --> H["OpenCodeMemberRuntimeStreamSource"]
+ S --> I["CodexNativeMemberTraceStreamSource"]
+ G --> J["TeamMemberLogsFinder recent refs"]
+ G --> K["Ref dedupe by member/session/kind"]
+ G --> L["Dedicated BoardTaskExactLogStrictParser"]
+ G --> M["BoardTaskExactLogChunkBuilder"]
+ H --> N["ClaudeMultimodelBridgeService"]
+ H --> O["OpenCodeRuntimeProjectionMapper"]
+ N --> P["agent_teams_orchestrator runtime transcript --lane"]
+ I --> R["skipped coverage in first PR"]
+ S --> Q["MemberLogStreamResponse"]
+ Q --> B
+```
+
+Main-process feature adapters own source discovery, parsing, chunking, cache, provider fallback, response budget and truncation semantics. Renderer feature code owns loading state, visual rendering, source text and fallback UI.
+
+🧭 Architecture decision: create a canonical `src/features/member-log-stream` slice in the first implementation. This feature spans process boundaries, owns merge/budget/provider policy, needs transport wiring and has a provider roadmap, so it matches `docs/FEATURE_ARCHITECTURE_STANDARD.md`.
+
+Compatibility rule:
+
+- do not migrate existing task log stream or legacy `MemberLogsTab` in this PR;
+- app shell may keep small compatibility methods in `api.teams` if that is the least disruptive integration point, but those methods should delegate to feature-owned contracts/channels/use case;
+- outside callers import only feature public entrypoints: `@features/member-log-stream/contracts`, `@features/member-log-stream/main`, `@features/member-log-stream/preload`, `@features/member-log-stream/renderer`;
+- provider logic must live behind source ports, not in IPC handlers or React components.
+
+Expected feature layout:
+
+```text
+src/features/member-log-stream/
+ contracts/
+ api.ts
+ channels.ts
+ dto.ts
+ index.ts
+ normalize.ts
+ core/
+ domain/
+ policies/
+ models/
+ application/
+ ports/
+ use-cases/
+ main/
+ composition/
+ adapters/
+ input/ipc/
+ output/sources/
+ output/presenters/
+ infrastructure/
+ preload/
+ renderer/
+ adapters/
+ hooks/
+ ui/
+ utils/
+```
+
+## Architecture Compliance Checklist
+
+This feature must follow `docs/FEATURE_ARCHITECTURE_STANDARD.md` exactly enough to be reviewable by structure, imports and tests.
+
+Clean Architecture mapping:
+
+- `contracts/`: `MemberLogStreamResponse`, request options, API fragment, IPC channels and normalize helpers only.
+- `core/domain/`: pure policies for merge order, dedupe keys, source coverage, truncation decisions and render-safe source metadata.
+- `core/application/`: `GetMemberLogStreamUseCase`, `SetMemberLogStreamTrackingUseCase`, source ports, cache/clock/logger ports and budget models.
+- `main/adapters/input/ipc/`: validate IPC input, reject unknown option keys, call use cases and normalize IPC output.
+- `main/adapters/output/sources/`: Claude/OpenCode/Codex source adapters that implement `MemberLogStreamSource`.
+- `main/infrastructure/`: TTL/in-flight cache helpers, parser wrappers, bridge/runtime clients and filesystem-specific helpers.
+- `main/composition/`: `createMemberLogStreamFeature(...)` wires dependencies and exposes a small facade.
+- `preload/`: `createMemberLogStreamBridge(...)` only invokes feature channels and depends on contracts.
+- `renderer/hooks/`: loading, tracking, team-change subscription, reload coalescing and fallback state.
+- `renderer/adapters/`: DTO-to-view mapping and small renderer-only normalization.
+- `renderer/ui/`: presentational components only, no store/API/Electron access.
+
+Dependency direction:
+
+- core/domain imports no process, framework, adapter or infrastructure code.
+- core/application depends on domain models and ports, not on main/preload/renderer.
+- main adapters depend inward on core/application and contracts.
+- renderer UI depends on contracts/view models and local props only.
+- app shell imports only public feature entrypoints.
+- `src/main/ipc/teams.ts`, `src/preload/index.ts` and `src/renderer/components/team/members/MemberDetailDialog.tsx` may integrate the feature, but must not own member stream policy.
+
+SOLID guardrails:
+
+- SRP: one class/module has one reason to change. Provider IO changes source adapters; merge/budget changes core policies/use case; transport changes IPC/preload; visual changes renderer UI.
+- OCP: adding `CodexPartialMemberTraceSource` later means adding a new source adapter and registering it, not changing renderer or the use case switch logic.
+- LSP: every `MemberLogStreamSource` must return the same result contract with `included`, `partial` or `skipped`; no adapter should throw for expected provider absence.
+- ISP: keep narrow ports: source loading, tracking activation, clock/logger/cache. Do not pass `TeamDataService` or renderer member objects through core.
+- DIP: use cases depend on source/cache/logger ports. Concrete `TeamMemberLogsFinder`, `ClaudeMultimodelBridgeService`, parsers and trace readers stay in main adapters/infrastructure.
+
+DRY guardrails:
+
+- Reuse the stream renderer through a public `@features/member-log-stream/renderer` entrypoint instead of copying `TaskLogStreamSection`.
+- Extract the OpenCode projection mapper from `OpenCodeTaskLogStreamSource` once and reuse it for task/member streams.
+- Extract provider-neutral message hygiene helpers once, but keep board/task JSON cleanup scoped to task logs.
+- Do not duplicate DTOs in `src/shared/types/team.ts` and feature contracts. Feature contracts are the owner.
+- Do not duplicate feature API methods across `api.teams` and feature bridge unless the `api.teams` method is a thin compatibility delegate.
+
+Lint guardrails:
+
+- The repo already has generic feature boundary rules in `eslint.config.js` for `src/features/*`.
+- If implementation needs stricter member-log-stream-specific messages, mirror the `recent-projects` feature-specific guard rails.
+- Targeted verification should include `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content` plus full `pnpm lint` before merge.
+
+### Project Standard Traceability Matrix
+
+This matrix is the implementation checklist for `CLAUDE.md`, `docs/FEATURE_ARCHITECTURE_STANDARD.md`, existing `eslint.config.js` feature rules and the `src/features/recent-projects` reference slice.
+
+| Standard requirement | Member-log-stream implementation rule | Verification |
+| --- | --- | --- |
+| New medium/large cross-process features live in `src/features/` | Create `src/features/member-log-stream` and keep legacy team files as integration points only | `rg "@features/member-log-stream" src/main src/preload src/renderer` shows public entrypoint imports only |
+| `contracts/` owns cross-process API | DTOs, API fragment types, channel constants and normalize helpers live only in feature contracts | No duplicated member stream DTOs in `src/shared/types/team.ts`; contracts tests cover fallback shape |
+| `core/domain/` is pure | Merge order, dedupe keys, coverage state, budget/truncation policy and source metadata are pure functions/models | Domain tests plus `feature-core-domain-guards` |
+| `core/application/` owns use cases and ports | `GetMemberLogStreamUseCase`, `SetMemberLogStreamTrackingUseCase`, source/cache/clock/logger/tracking ports | Use-case tests with fake ports; no Electron, Fastify, React, Zustand or child process imports |
+| `main/adapters/input/` owns transport translation | IPC adapter validates unknown option keys, exact optional `laneId`, response normalization and fail-soft errors | Feature IPC tests register/remove feature channels without `initializeTeamHandlers()` ownership |
+| `main/adapters/output/` owns source adapters | Claude/OpenCode/Codex adapters implement `MemberLogStreamSource` and translate runtime data to core models | Adapter mapping tests for Claude refs, OpenCode projection and Codex skipped coverage |
+| `main/infrastructure/` owns concrete IO | Parser wrappers, runtime bridge clients, TTL/in-flight cache and filesystem helpers stay outside core | Source adapters are thin around infrastructure helpers |
+| `main/composition/` is the composition root | `createMemberLogStreamFeature(...)` wires sources, use cases, cache, logger and tracker facade | App main imports `@features/member-log-stream/main`, not feature internals |
+| `preload/` is a thin bridge | `createMemberLogStreamBridge()` invokes feature channels and depends on contracts | Preload does not import main composition or renderer code |
+| `renderer/hooks/` orchestrate interaction | Loading, tracking activation, reload coalescing, fallback and team-change cleanup live in hooks | Hook tests cover mount/unmount, team changes, reload coalescing and browser fallback |
+| `renderer/ui/` is presentational | `ExecutionLogStreamView` and section UI receive props/view models only | `feature-renderer-ui-guards`; no imports from `@renderer/api`, store, Electron or main |
+| Public entrypoints only | External code imports only `@features/member-log-stream/contracts`, `/main`, `/preload`, `/renderer` | `feature-public-entrypoints-only` and targeted eslint |
+| Reference feature shape matches `recent-projects` | Public indexes mirror the reference pattern: contracts/main/preload/renderer expose only supported surface | Review tree against `src/features/recent-projects` before implementation PR |
+
+## Public API And Types
+
+Add feature-owned contracts in `src/features/member-log-stream/contracts`, not by mutating task stream semantics:
+
+```ts
+export type MemberLogStreamProvider = 'claude_transcript' | 'opencode_runtime' | 'codex_native_trace';
+export type MemberLogStreamSource =
+ | 'member_transcript'
+ | 'member_mixed_runtime'
+ | 'member_runtime_only'
+ | 'member_empty';
+
+export interface MemberLogStreamCoverage {
+ provider: MemberLogStreamProvider;
+ status: 'included' | 'partial' | 'skipped';
+ reason?: string;
+}
+
+export interface MemberLogStreamWarning {
+ code:
+ | 'opencode_ambiguous_lane'
+ | 'opencode_missing_runtime_session'
+ | 'opencode_runtime_unavailable'
+ | 'opencode_runtime_timeout'
+ | 'codex_member_wide_not_supported'
+ | 'large_log_window_limited'
+ | 'segment_message_window_limited'
+ | 'message_content_limited'
+ | 'unreadable_transcript_file';
+ message: string;
+}
+
+export interface MemberLogStreamMetadata {
+ scannedTranscriptFileCount: number;
+ includedTranscriptFileCount: number;
+ droppedSegmentCount: number;
+ droppedChunkCount: number;
+ droppedMessageCount: number;
+}
+
+export interface MemberLogStreamSegmentSource {
+ provider: MemberLogStreamProvider;
+ label: string;
+ sessionId?: string;
+ laneId?: string;
+ messageCount?: number;
+ truncated?: boolean;
+}
+
+export interface MemberLogStreamSegment extends BoardTaskLogSegment {
+ source: MemberLogStreamSegmentSource;
+}
+
+export interface MemberLogStreamResponse {
+ participants: BoardTaskLogParticipant[];
+ defaultFilter: 'all' | string;
+ segments: MemberLogStreamSegment[];
+ source: MemberLogStreamSource;
+ coverage: MemberLogStreamCoverage[];
+ warnings: MemberLogStreamWarning[];
+ truncated: boolean;
+ generatedAt: string;
+ metadata: MemberLogStreamMetadata;
+}
+```
+
+Important TS boundary:
+
+- Do not make `MemberLogStreamResponse extends BoardTaskLogStreamResponse`.
+- `BoardTaskLogStreamResponse.source` is a task-only union: `transcript`, OpenCode task fallback values and Codex task trace values.
+- Reusing that response type would either reject member-specific source values or pollute task stream semantics with member values.
+- Share the low-level render units instead: `BoardTaskLogParticipant`, `BoardTaskLogSegment` and `EnhancedChunk`.
+- Prefer feature contracts for `MemberLogStreamResponse`; export them through `@features/member-log-stream/contracts`.
+- If `src/shared/types/api.ts` needs a temporary compatibility reference for `api.teams`, import the feature contract type there or use a narrow adapter type. Do not duplicate DTO definitions in `src/shared/types/team.ts` and feature contracts.
+
+Internal budget:
+
+```ts
+interface MemberLogStreamBudget {
+ maxTranscriptFiles: number;
+ maxSegments: number;
+ maxChunks: number;
+ maxSourceMessages: number;
+ maxMessagesPerSegment: number;
+ maxTotalContentChars: number;
+ maxMessageContentChars: number;
+ maxToolResultContentChars: number;
+ openCodeMessageLimit: number;
+ openCodeTimeoutMs: number;
+}
+```
+
+Extend finder metadata in a backward-compatible way:
+
+```ts
+interface MemberLogFileRef {
+ memberName: string;
+ sessionId: string;
+ filePath: string;
+ mtimeMs: number;
+ sizeBytes?: number;
+ messageCount?: number;
+ kind?: 'lead_session' | 'member_session' | 'subagent';
+}
+```
+
+This is needed because cumulative subagent snapshots can duplicate history. Existing callers can ignore the optional fields.
+
+Also adjust `findRecentMemberLogFileRefsByMember()` to include requested member names in the attribution `knownMembers` set. Syntax-only IPC validation is not enough for removed or historical members if they are no longer present in current config/meta/inbox.
+
+Do this without breaking existing finder callers. `TeamMemberRuntimeAdvisoryService` and existing tests already pass the third argument as positional `mtimeSinceMs`, including numeric values and `null`.
+
+```ts
+type FindRecentMemberLogFileRefsOptions =
+ | number
+ | null
+ | {
+ mtimeSinceMs?: number | null;
+ forceRefresh?: boolean;
+ };
+```
+
+Rules:
+
+- numeric third arg still means `mtimeSinceMs`;
+- `null` still means no mtime window;
+- object form is added for member stream and can pass `forceRefresh`;
+- only object form should bypass `discoverProjectSessions()` cache;
+- `mtimeSinceMs` must apply to lead transcript refs as well as member/subagent candidates;
+- do not migrate advisory callers in the same PR unless needed by tests.
+
+Recommended default:
+
+```ts
+const DEFAULT_MEMBER_LOG_STREAM_BUDGET = {
+ maxTranscriptFiles: 40,
+ maxSegments: 30,
+ maxChunks: 250,
+ maxSourceMessages: 1200,
+ maxMessagesPerSegment: 300,
+ maxTotalContentChars: 800_000,
+ maxMessageContentChars: 80_000,
+ maxToolResultContentChars: 120_000,
+ openCodeMessageLimit: 400,
+ openCodeTimeoutMs: 5_000,
+};
+```
+
+Add IPC/preload API:
+
+```ts
+getMemberLogStream(
+ teamName: string,
+ memberName: string,
+ options?: {
+ limitSegments?: number;
+ since?: string;
+ laneId?: string;
+ forceRefresh?: boolean;
+ }
+): Promise
+```
+
+Add member stream tracking API:
+
+```ts
+setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise
+```
+
+Why this is separate from `setTaskLogStreamTracking()`:
+
+- `TaskLogStreamSection` listens to `onTeamChange`, but `TaskLogsPanel` is what enables `TeamLogSourceTracker`.
+- Member popup has no `TaskLogsPanel` wrapper.
+- Reusing the task-named method from member UI would work mechanically, but it creates semantic drift.
+- A dedicated member tracking API can map to the same underlying watcher with a separate `member_log_stream` consumer count.
+
+Default behavior:
+
+- `limitSegments` defaults to 30 and is clamped to `1..80`.
+- `since` is optional and only used as a performance hint, but invalid dates should be rejected by IPC validation.
+- First renderer implementation should not pass `since` for background reload replacement. Without an explicit incremental-merge contract, replacing the visible stream with a since-filtered partial response can hide older segments.
+- `laneId` is optional and used only for safe OpenCode runtime projection.
+- `forceRefresh` is optional and should be used only by renderer background reloads after `log-source-change`.
+- Handler validates `teamName` and `memberName`.
+- Handler validates `laneId` separately from `memberName`, because valid runtime lanes can contain `:`.
+- Handler must trim but otherwise preserve `laneId`; do not lowercase or rewrite it.
+- Handler validates `forceRefresh` as boolean when present.
+- Put new validators in `src/main/ipc/guards.ts` as exported functions, or define local validator result types. `ValidationResult` is currently not exported from `guards.ts`.
+- Handler should not reject deleted/removed members just because they are absent from current config. Old logs are still useful.
+- Handler rejects unknown option keys, matching the stricter `TEAM_GET_DATA` options policy.
+- Tracking handler validates `teamName` and boolean `enabled`.
+
+Suggested lane validator:
+
+```ts
+export function validateOptionalRuntimeLaneId(value: unknown) {
+ if (value == null) return { valid: true, value: undefined };
+ if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' };
+ const trimmed = value.trim();
+ if (!trimmed) return { valid: true, value: undefined };
+ if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' };
+ if (/[\0-\x1F\x7F/\\]/.test(trimmed)) {
+ return { valid: false, error: 'laneId contains invalid characters' };
+ }
+ return { valid: true, value: trimmed };
+}
+```
+
+## Main Process Implementation
+
+Create the application use case and source ports inside `src/features/member-log-stream/core/application`. Main-process source adapters live in `src/features/member-log-stream/main/adapters/output/sources`.
+
+Existing task log stream services can still be reused as infrastructure dependencies where appropriate, but member stream orchestration must not live inside `src/main/ipc/teams.ts` or a monolithic `src/main/services/team` class.
+
+Recommended internal source-port interface:
+
+```ts
+interface MemberLogStreamSourceInput {
+ teamName: string;
+ memberName: string;
+ laneId?: string;
+ budget: MemberLogStreamBudget;
+ sinceMs?: number | null;
+ forceRefresh?: boolean;
+}
+
+interface MemberLogStreamSourceResult {
+ provider: MemberLogStreamProvider;
+ status: 'included' | 'partial' | 'skipped';
+ segments: MemberLogStreamSegment[];
+ warnings: MemberLogStreamWarning[];
+}
+
+interface MemberLogStreamSource {
+ readonly provider: MemberLogStreamProvider;
+ load(input: MemberLogStreamSourceInput): Promise;
+}
+```
+
+First PR sources:
+
+- `ClaudeMemberTranscriptStreamSource`;
+- `OpenCodeMemberRuntimeStreamSource`;
+- `CodexNativeMemberTraceStreamSource`, but only as skipped coverage adapter in first PR.
+
+Recommended responsibilities:
+
+- service normalizes already-validated options and budget;
+- source classes discover and load their provider/runtime data;
+- service merges source results in deterministic provider order;
+- service calls provider sources fail-soft through `Promise.allSettled()` or equivalent per-source try/catch;
+- service joins identical active requests by team/member/lane/limit/since key, with no long-lived response cache in the first PR;
+- service enforces `maxSourceMessages`, `maxMessagesPerSegment`, `maxSegments` and `maxChunks`;
+- service enforces content-size budgets before chunk build, because one huge tool result can freeze the renderer even when message count is low;
+- service uses provider-neutral `ParsedMessage` hygiene/truncation helpers and must not apply task/board-specific JSON payload cleanup across all member logs;
+- service sorts segments by timestamp ascending in the response;
+- service normalizes participant key to selected member;
+- service returns structured coverage/warnings instead of throwing for partial provider failures.
+
+Claude source responsibilities:
+
+- discover transcript files for selected member through `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()`;
+- extend/use optional ref metadata `kind` and `sizeBytes`;
+- ensure finder `mtimeSinceMs` filters lead transcript refs too, not only collected member/subagent candidates;
+- dedupe cumulative subagent refs by `memberName + sessionId`, keeping largest `messageCount` when available, otherwise largest `sizeBytes`, then newest `mtimeMs`;
+- do not parse every candidate just to compute `messageCount`; use `messageCount` only when existing attribution logic already knows it cheaply, otherwise use `sizeBytes` as the cumulative snapshot proxy;
+- cap candidate refs by `maxTranscriptFiles` after dedupe;
+- strict-parse only candidate files;
+- trim very large parsed files with pair-aware message truncation before chunk build;
+- trim or summarize oversized message content/tool-result content before chunk build, preserving tool ids and result pairing;
+- do not reuse `sanitizeJsonLikeToolResultPayloads()` wholesale for member stream, because board/task-specific sanitization can hide legitimate JSON tool outputs;
+- build one segment per session/log file by default;
+- populate segment `source` metadata with provider/session/message counts, but never absolute file paths;
+- include sidechain chunks through existing chunk builder.
+
+OpenCode source responsibilities:
+
+- use `laneId` when present;
+- call `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` with `--lane`, `--limit` and a popup-specific timeout;
+- keep a small source-local TTL cache and `inFlight` join keyed by team/member/lane/limit, matching the task source pattern closely enough to avoid repeated bridge calls;
+- treat ambiguous lane as skipped provider with warning;
+- treat runtime timeout/binary errors as skipped or partial provider warnings, not whole-popup failure;
+- map projection output into `MemberLogStreamSegment[]` through extracted `OpenCodeRuntimeProjectionMapper`.
+
+Codex source responsibilities in first PR:
+
+- return coverage status `skipped`;
+- add warning `codex_member_wide_not_supported` only when useful for diagnostics;
+- do not scan all Codex trace directories yet.
+- do not call `CodexNativeTraceReader.readTaskRuns()` from member stream, because it requires task ids and returns only task-keyed native tool traces.
+- if a follow-up adds partial Codex support, call it `codex_native_trace_partial` and cap `maxTaskDirs`, `maxTraceCandidates` and `maxTraceRuns` before parsing.
+
+Important implementation constraints:
+
+- Do not reuse one `BoardTaskExactLogStrictParser` instance between task stream and member stream unless cache ownership is changed. `parseFiles()` currently calls `retainOnly()`, so sharing the parser can evict both task-stream parsed cache and task-stream `inFlight` parse dedupe.
+- Do not use `findMemberLogPaths()` as the main source. It lacks `sessionId`, `mtimeMs`, and sorted recent refs.
+- Do not directly import renderer code into main.
+- Do not manually construct `EnhancedChunk` if `BoardTaskExactLogChunkBuilder` can build it.
+- Do not use task-specific filtering rules for member stream. Member stream scope is source/member attribution, not task interval.
+- Do not reuse `CodexNativeTaskLogStreamSource` for member logs. It is task-owner scoped and relies on `readTaskRuns({ taskIds })`.
+- Do not use `OpenCodeTaskLogStreamSource` directly for member logs. It is task-window and task-marker aware.
+- Do not copy `toParsedMessage()` into member source. Extract generic OpenCode projection mapping from `OpenCodeTaskLogStreamSource` and let both task/member sources import it. Do not base the shared mapper on `OpenCodeTaskStallEvidenceSource`, because that mapper is intentionally narrower and does not preserve all task-stream rendering data.
+- Keep main handler fail-soft. A single unreadable transcript file should become a warning, not a failed popup.
+- Keep response bounded. Any cap hit sets `truncated: true` and warning `large_log_window_limited`.
+- If a single file/session exceeds message budget, use `segment_message_window_limited` and drop the oldest message window before chunk build.
+- Do not add member stream policy or service ownership to `initializeTeamHandlers()`. The feature should have its own main composition and IPC registration path.
+
+Segment rules:
+
+- Segment id should be stable across refreshes: include provider, normalized team/member, session id or hashed file fingerprint, first timestamp.
+- Do not put absolute file paths in segment ids.
+- Segment timestamps come from first/last parsed message.
+- Empty parsed files are ignored.
+- Very large files are parsed through existing stream parser, not loaded wholesale in renderer.
+- If chunks are empty after normalization, skip segment.
+- If total chunks exceed `maxChunks`, keep recent useful chunks and mark response truncated.
+- Do not text-truncate inside `EnhancedChunk` in first PR.
+- Do not leave orphan tool results after message trimming. Keep matching assistant tool call by `sourceToolUseID`, or drop the orphan result.
+- Do not synthesize fake `Process` entries for OpenCode. Its projection should render through normal tool/output chunks.
+- Do not rely on collapsed UI state for safety. `MemberExecutionLog` keeps AI groups expanded by default and expanded tool rows can render full content.
+
+## Transport And Composition Integration
+
+This part is easy to underestimate. The feature is not only a service and renderer component; it must pass through feature contracts, preload, browser fallback and IPC registration without disturbing existing task-log handlers.
+
+Required transport edits:
+
+- Add feature channel constants in `src/features/member-log-stream/contracts/channels.ts`, for example `MEMBER_LOG_STREAM_GET` and `MEMBER_LOG_STREAM_SET_TRACKING`.
+- Prefer feature channel names such as `member-log-stream:getMemberLogStream` over new `TEAM_*` constants. If a compatibility alias is needed, it should still point to feature-owned contracts.
+- Add `MemberLogStreamApi` in `src/features/member-log-stream/contracts/api.ts`.
+- Add `createMemberLogStreamBridge()` in `src/features/member-log-stream/preload`.
+- Add feature IPC registration in `src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts`.
+- Register/remove feature IPC handlers from app main composition through `@features/member-log-stream/main`, not through `registerTeamHandlers()`.
+- Add `createMemberLogStreamFeature(...)` in `src/features/member-log-stream/main/composition`.
+- Instantiate the feature facade in `src/main/index.ts` or the existing main composition root after finder/parser/bridge/tracker dependencies are available.
+- Expose renderer access through a feature-friendly API path, for example `api.memberLogStream`. If `api.teams.getMemberLogStream(...)` is kept for compatibility, it must be a thin delegate to the feature API.
+- Add browser-mode fallback returning a complete empty `MemberLogStreamResponse`, not `null` and not a thrown error.
+- Add browser-mode no-op fallback for member stream tracking.
+- Add `member_log_stream` to `TeamLogSourceTrackingConsumer`.
+- Map feature tracking enable to `TeamLogSourceTracker.enableTracking(teamName, 'member_log_stream')`.
+- Map feature tracking disable to `TeamLogSourceTracker.disableTracking(teamName, 'member_log_stream')`.
+- Keep the tracker consumer team-scoped, not member-scoped. `TeamLogSourceTracker` already scopes watch targets by team sessions and uses reference counts, so multiple open member popups for the same team should increment/decrement the same `member_log_stream` consumer count.
+- Cleanup must run for the exact team used on mount. If `teamName` changes while the dialog stays mounted, disable tracking for the previous team before enabling it for the next team.
+
+Important integration rule:
+
+🎯 8.5 🛡️ 9 🧠 4
+Примерно 80-180 LOC.
+
+Use feature-owned composition:
+
+```ts
+const memberLogStreamFeature = createMemberLogStreamFeature({
+ logsFinder,
+ logSourceTracker,
+ runtimeBridge,
+ logger,
+});
+
+registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature);
+```
+
+Do not insert member stream dependencies into `initializeTeamHandlers()`. Current tests and likely other setup code pass optional team services positionally; adding this feature there would create a false ownership boundary and risk confusing service assignment failures.
+
+Alternative:
+
+🎯 7 🛡️ 9 🧠 7
+Примерно 250-450 LOC.
+
+Refactor all existing `initializeTeamHandlers()` dependencies to a dependency object. This is cleaner long-term for legacy team IPC, but it is a separate IPC hygiene PR and should not be bundled unless the implementation intentionally pays that cost.
+
+Browser fallback shape:
+
+```ts
+const emptyMemberLogStreamResponse: MemberLogStreamResponse = {
+ participants: [],
+ defaultFilter: 'all',
+ segments: [],
+ source: 'member_empty',
+ coverage: [],
+ warnings: [],
+ truncated: false,
+ generatedAt: new Date().toISOString(),
+ metadata: {
+ scannedTranscriptFileCount: 0,
+ includedTranscriptFileCount: 0,
+ droppedSegmentCount: 0,
+ droppedChunkCount: 0,
+ droppedMessageCount: 0,
+ },
+};
+```
+
+This keeps browser-mode tests honest while making it obvious that real stream loading is Electron-only.
+
+## Renderer Implementation
+
+Refactor the stream UI so `TaskLogStreamSection` is not copied wholesale.
+
+Recommended shape:
+
+- Extract a generic render-only component, for example `ExecutionLogStreamView`.
+- It receives `title`, `description`, `stream`, `loading`, `error`, `emptyTitle`, `emptyDescription`, `teamName`, `forceSegmentHeaders`, `boundedHistoryNote`, and optional `buildSegmentRenderKey`.
+- It must not import `api.teams`, `MemberLogsTab`, feature gates, provider sources or task/member loading hooks.
+- It should receive already loaded `teamMembers` from the container, so pure view tests do not need the app store.
+- `TaskLogStreamSection` keeps task-specific loading logic and task-specific descriptions.
+- New `MemberLogStreamSection` owns member-specific loading logic and descriptions.
+- `ExecutionLogStreamView` should be exported from `@features/member-log-stream/renderer` as a public render primitive if legacy `TaskLogStreamSection` needs to reuse it.
+- `MemberDetailDialog` renders `MemberLogStreamSection` in the existing Logs tab area.
+- `MemberLogsTab` remains available as fallback.
+- Do not modify `MemberLogsTab` behavior in the first PR. It is also used by `ExecutionSessionsSection` in task logs, so changing it would widen the regression surface.
+- Put the renderer feature-gate decision at the `MemberDetailDialog` Logs tab boundary. `MemberLogStreamSection` should not import or own the legacy `MemberLogsTab`.
+- On first-load failure in the new stream, show the member stream error and an explicit legacy fallback panel below it. Do not silently replace the new stream with old logs, because that hides QA failures.
+- Do not add virtualization in the first PR. `MemberExecutionLog` currently renders all groups, so protection must come from backend `maxSegments`/`maxChunks`.
+- If `stream.truncated` is true or warning `large_log_window_limited` exists, show a short note that the popup is showing the recent bounded stream.
+- Keep `normalizeResponse()` generic enough to convert every segment `chunks` through `asEnhancedChunkArray`.
+- Generic response normalization must preserve the original stream shape with object spread. Do not reconstruct only task fields, because member-only metadata and `segment.source` are part of the contract.
+- Keep participant identity actor-based. For a selected member, prefer one member participant plus per-segment `source` labels; do not create provider/session pseudo-participants just to show Claude/OpenCode/Codex labels.
+- If source filtering is needed later, add a separate source filter. Do not overload participant chips with provider identity.
+- Preserve current task-stream render-key behavior by default: `participantKey:firstChunkId`. This keeps expanded state stable when a task segment grows.
+- For member stream, pass a source-aware render key based on safe `segment.id` plus first chunk id or start timestamp. This avoids collisions between providers/sessions for the same participant.
+- Keep date handling consistent with task stream. Electron IPC should preserve `Date` objects through structured clone, but renderer tests and browser-mode stubs may use JSON-like strings.
+- If `ExecutionLogStreamView` starts accepting JSON-like fixtures, add one shared chunk date normalizer instead of ad hoc `new Date()` calls in each component.
+- Keep `describeStreamSource()` source-specific. Do not reuse task copy in member popup.
+
+Member-specific UI text:
+
+- Loading: `Loading member log stream...`
+- Empty: `No log stream entries were found for this member yet.`
+- Error: `Failed to load member log stream`
+- Source description should mention member-scoped transcript/runtime logs, not task-scoped logs.
+
+Live refresh:
+
+- Enable feature tracking bridge while `MemberLogStreamSection` is mounted, then disable it on unmount. If `api.teams.setMemberLogStreamTracking` is kept for compatibility, it delegates to the feature bridge.
+- Do not rely on `TaskLogsPanel` to keep `TeamLogSourceTracker` active. Member popup can be opened without task logs panel mounted.
+- Do not call `setTaskLogStreamTracking()` from member UI. It would work technically, but it couples member stream lifecycle to task stream naming and tests.
+- Reuse the existing team change subscription path for now. Do not create a second event bus in this PR.
+- Reload on same-team `log-source-change` with `forceRefresh: true` to bypass the finder discovery cache.
+- Reload on same-team `task-log-change`, because that is a log freshness signal and does not include memberName.
+- Do not reload on `tool-activity`.
+- Debounce 500-750ms, slightly more conservative than task stream.
+- Background reload replaces the full bounded response. Do not request a `since`-only response from renderer until the service exposes a response merge contract.
+- Coalesce duplicate background reloads while one member stream request is active. Keep at most one pending reload and run it after the active request settles.
+- Background reload with `forceRefresh: true` should not start a parallel request if a request for the same member key is already running.
+- Do not clear old stream on background refresh failure.
+
+## Provider Coverage
+
+| Provider/runtime | Variant 2 coverage | Reliability | Notes |
+| --- | --- | --- | --- |
+| Claude JSONL transcript | Good | 🛡️ 9 | Existing member attribution, recent refs, session ids and JSONL parsing are enough for first release |
+| OpenCode runtime projection | Good if session/lane is resolvable | 🛡️ 8 | Safe when desktop passes `laneId`; ambiguous records are skipped, not guessed |
+| Codex native trace | Explicitly skipped in first PR | 🛡️ 8 | Honest coverage is safer than partial trace presented as full Codex logs |
+
+Claude transcript path:
+
+- Use `TeamMemberLogsFinder` as the source locator.
+- Do not re-implement member attribution in renderer.
+- Trust existing precedence: process/team metadata over routing sender over teammate id over text mention.
+
+OpenCode path:
+
+- Extend `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` params with optional `laneId`.
+- If desktop knows a safe lane for the member, pass `--lane`. Renderer can pass `member.laneId` from `TeamMemberSnapshot` through `getMemberLogStream` options.
+- If lane is unknown, calling without lane is allowed only as best-effort and must catch the orchestrator "pass --lane" error.
+- On ambiguity, skip OpenCode runtime projection and add `opencode_ambiguous_lane` warning.
+- Do not merge multiple OpenCode lanes unless a later orchestrator contract exposes safe list-by-member output.
+
+Codex native path:
+
+- Do not claim full member-wide Codex support in variant 2.
+- Existing trace headers include `ownerName`, so partial member-wide native tool trace is possible as a follow-up.
+- Current `CodexNativeTraceReader` is task-first and `CodexNativeTraceProjector` is native-tool-only.
+- First PR should return coverage status `skipped` with `codex_member_wide_not_supported`.
+- Optional second PR can add `readMemberRuns()` and label it as partial native tool trace, not full Codex logs.
+
+## Edge Cases And Mitigations
+
+1. Member removed from team.
+ - Keep handler validation syntax-only for `memberName`.
+ - Allow logs finder to search historical logs.
+ - UI should still render if popup can open from historical data.
+
+2. Member renamed.
+ - Variant 2 uses exact current/historical name passed by caller.
+ - Do not guess aliases.
+ - Future work can add alias map from team config history.
+
+3. Lead member.
+ - Use existing `isLeadMember` and participant role conventions.
+ - Do not assume lead means non-sidechain in every transcript.
+
+4. Multiple members with similar names.
+ - Do not use loose substring matching in the new service.
+ - Rely on `TeamMemberLogsFinder` attribution signals.
+
+5. OpenCode multiple lanes.
+ - Treat as ambiguous unless lane is known.
+ - Do not silently pick first record.
+
+6. OpenCode runtime unavailable or slow.
+ - Missing binary returns transcript-only stream with warning, not hard error.
+ - Use popup-specific `openCodeTimeoutMs`.
+ - Return Claude transcript segments even when OpenCode times out.
+ - Add `opencode_runtime_timeout` warning.
+
+7. Huge member history.
+ - Limit transcript refs, source messages, per-segment messages, segment count and total chunks.
+ - Limit content characters as well as message count.
+ - Prefer most recent files first if finder supports mtime.
+ - Expose warning `large_log_window_limited`.
+ - Set `truncated: true`.
+ - Avoid rendering thousands of chunks in one popup.
+
+7.1 Huge single tool result or output.
+ - A single parsed message can contain a massive tool result or markdown output.
+ - Apply `maxMessageContentChars`, `maxToolResultContentChars` and `maxTotalContentChars` before chunk build.
+ - Preserve tool call/result ids and replace truncated content with a clear bounded placeholder.
+ - Add `message_content_limited` warning and set `truncated: true`.
+ - Extract provider-neutral message hygiene helpers instead of applying board/task-specific JSON sanitization to every member log.
+ - JSON-looking Bash/API outputs should remain visible unless they exceed content budget, in which case they are truncated, not blanked.
+
+8. Partial or malformed JSONL.
+ - Strict parser already skips invalid/unreadable records.
+ - Service should continue with remaining files.
+
+9. Empty logs.
+ - Return `member_empty`, no exception.
+ - UI empty state should not look like failure.
+
+10. Provider duplication.
+ - If OpenCode runtime projection duplicates transcript-derived entries, prefer transcript when there is a stable message uuid.
+ - If no stable uuid, do not attempt risky fuzzy dedupe in first release.
+ - Deduplicate cumulative Claude subagent snapshots before parse, using ref metadata.
+
+11. Sorting across sessions.
+ - Response sorted ascending.
+ - Renderer may reverse for newest-first, matching task stream behavior.
+ - Mixed provider segments should be sorted by timestamp, not provider priority.
+
+12. Cache invalidation.
+ - Cache key should include team, member, candidate file mtimes/sizes and runtime projection identity.
+ - Do not keep stale stream after log-source-change event.
+ - If no explicit service-level cache is added in first PR, document that dedicated parser/runtime caches are the only caches.
+ - `TeamMemberLogsFinder` discovery cache has a 30s TTL, so member stream needs a force-refresh path after `log-source-change`.
+ - OpenCode member runtime source should have its own short TTL and in-flight join so repeated live refreshes do not spawn duplicate `runtime transcript` calls.
+ - `forceRefresh` may bypass completed OpenCode cache, but should still join an existing in-flight call for the same team/member/lane/limit.
+
+13. Renderer memory pressure.
+ - Keep `limitSegments` plus backend `maxChunks` and message budgets.
+ - Avoid storing raw logs in React state.
+ - Store only normalized stream response.
+ - Defer virtualization until there is a real need for audit-sized popup history.
+
+14. Old fallback hiding new bugs.
+ - Fallback should be visible as fallback, not silently replace new stream in a way that masks errors during QA.
+
+15. Browser mode API client.
+ - `src/renderer/api/httpClient.ts` should add unavailable stub like existing `getTaskLogStream`.
+ - Do not break browser-mode compile.
+
+16. Unsafe or malformed `laneId`.
+ - Do not validate with `validateMemberName`.
+ - Allow colon-separated runtime lane ids.
+ - Reject NUL/newline and oversized values.
+ - Treat invalid `laneId` as IPC validation error, not as "no lane".
+
+17. OpenCode mapper drift.
+ - Do not duplicate projection message mapping in the new member source.
+ - Extract generic mapper from `OpenCodeTaskLogStreamSource`.
+ - Keep task marker/window filtering inside the task source.
+
+18. Tool call/result split by trimming.
+ - Truncate parsed messages before chunk build with pair-aware logic.
+ - If a retained tool result has `sourceToolUseID`, retain the matching assistant tool call when possible.
+ - If the matching call cannot fit the hard budget, drop the orphan result.
+
+19. Live refresh noise.
+ - Member stream cannot filter `task-log-change` by member because the event has no `memberName`.
+ - Reload on same-team `task-log-change` only while popup is mounted and debounced.
+ - Skip `tool-activity` reloads to avoid heavy parser churn.
+
+20. Feature IPC ownership drift.
+ - `initializeTeamHandlers()` has many optional dependencies passed positionally.
+ - Adding member stream there would both risk positional service drift and put new feature policy in legacy team IPC.
+ - Register member stream through feature-owned IPC/composition instead. Leave legacy dependency-object cleanup as a separate PR.
+
+21. Browser mode fallback drift.
+ - Browser mode does not support Electron IPC, but the renderer API still must satisfy the feature API.
+ - Return a complete empty `MemberLogStreamResponse` from the feature/browser fallback path.
+ - Add a small test or compile check so shared type changes do not break browser fallback.
+
+22. Historical member not in current config.
+ - `TeamMemberLogsFinder` attribution uses `knownMembers`.
+ - If the requested member is removed from config/meta/inbox, attribution can miss old files.
+ - Add requested member names into the finder attribution set for `findRecentMemberLogFileRefsByMember()`.
+
+23. Mixed-provider segment headers.
+ - Plain `BoardTaskLogSegment` has no provider/session label for renderer.
+ - Use `MemberLogStreamSegment.source` metadata for safe labels.
+ - Do not expose absolute transcript paths in segment metadata or warnings.
+
+24. Path leakage through stable ids.
+ - Segment ids are visible to renderer and can end up in test snapshots.
+ - Use a short hash/fingerprint of `filePath + mtimeMs + sizeBytes`, not the absolute path itself.
+ - Warnings should say counts/provider/session labels, not local filesystem paths.
+
+25. Discovery cache stale after launch/log source changes.
+ - Finder discovery cache can keep old session ids briefly.
+ - Renderer should pass `forceRefresh: true` after same-team `log-source-change`.
+ - Service should pass that through with object-form finder options: `{ mtimeSinceMs, forceRefresh }`.
+ - For OpenCode runtime calls, keep source-local TTL/in-flight protection. `forceRefresh` should bypass completed cache only, not duplicate an active bridge call.
+
+26. Validator placement/type drift.
+ - `src/main/ipc/guards.ts` currently keeps `ValidationResult` local.
+ - Prefer exporting concrete validators such as `validateOptionalRuntimeLaneId()` and `validateOptionalBooleanOption()`.
+ - If validators stay local in `teams.ts`, avoid importing the private `ValidationResult` type.
+
+27. Date object assumptions in renderer.
+ - `groupTransformer` calls `.getTime()` on chunk/step dates.
+ - Existing task stream relies on Electron structured clone preserving dates.
+ - New shared tests should cover Date-shaped chunks and avoid accidentally feeding raw JSON date strings into `MemberExecutionLog`.
+ - If JSON-like chunks are needed in tests or browser mode, add one shared `normalizeEnhancedChunkDates()` helper.
+
+28. Fallback boundary drift.
+ - `MemberLogsTab` is shared by the member popup and task `Execution Sessions`.
+ - Changing `MemberLogsTab` itself can break task-log legacy browsing.
+ - Keep the new-vs-old decision in `MemberDetailDialog` only.
+ - Test renderer gate on/off at dialog level.
+
+29. Hidden stream failure during QA.
+ - If the new stream error silently switches to old logs, failures can ship unnoticed.
+ - Show the error and a clearly labeled fallback.
+ - Background refresh failures can preserve the previous good stream, but initial load failures should be visible.
+
+30. Resolved member lane type drift.
+ - `TeamMemberSnapshot` already has runtime/lane fields: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`.
+ - `ResolvedTeamMember` currently does not declare them, even though `buildResolvedMember()` spreads the snapshot.
+ - Add explicit fields to the shared type instead of using casts in `MemberLogStreamSection`.
+ - Do not add duplicate renderer mapping unless tests prove a real runtime gap.
+
+31. OpenCode bridge call churn.
+ - Live refresh can fire more than once while a popup is open.
+ - Mirror the task OpenCode source pattern with a short TTL cache and `inFlight` join.
+ - Cache both success and null briefly, and keep timeout warnings fail-soft.
+
+32. Board/task sanitization leakage.
+ - Task stream has private helpers that clean board-tool JSON payloads.
+ - Member stream is wider and may legitimately contain JSON output from Bash/API tools.
+ - Reuse only provider-neutral clone/prune/truncate helpers, or rename board-specific cleanup so it cannot be accidentally applied globally.
+
+33. Finder third-arg compatibility drift.
+ - `findRecentMemberLogFileRefsByMember()` already has numeric/null positional callers.
+ - Adding `forceRefresh` as object-only third arg can break runtime advisory or live tests.
+ - Add a compatibility parser that accepts `number`, `null` and `{ mtimeSinceMs, forceRefresh }`.
+ - Member stream should use object form, existing advisory code can keep numeric/null form.
+
+34. Lead transcript bypasses `mtimeSinceMs`.
+ - Current recent-ref finder applies `mtimeSinceMs` to collected candidates, but lead transcript is pushed before that scan.
+ - If selected member is lead and `since` is used as a performance window, old lead logs can be returned unexpectedly.
+ - Apply the same mtime filter to lead transcript stat before pushing the lead ref.
+
+35. Renderer duplicate reload pressure.
+ - `TaskLogStreamSection` ignores stale responses with `requestSeqRef`, but still can start parallel IPC calls.
+ - Member stream requests are heavier because they can parse many files and call OpenCode runtime transcript.
+ - Coalesce active member stream loads in `MemberLogStreamSection`, and join identical active requests in `GetMemberLogStreamUseCase`.
+
+36. Segment render key collision.
+ - Task stream default render key uses `participantKey:firstChunkId` to preserve expanded state when a segment tail grows.
+ - Member stream can have multiple providers/sessions for one participant, so that default can collide.
+ - Keep the task default, but let member stream pass a source-aware `buildSegmentRenderKey`.
+
+37. Live tracking not activated.
+ - `TaskLogStreamSection` subscribes to `onTeamChange`, but `TaskLogsPanel` enables `setTaskLogStreamTracking`.
+ - Member popup has no equivalent parent, so it can miss `task-log-change` and `log-source-change` events if no other UI consumer is active.
+ - Add `setMemberLogStreamTracking()` and a `member_log_stream` tracker consumer.
+ - Enable tracking only while `MemberLogStreamSection` is mounted and disable it on unmount.
+
+38. IPC option typo drift.
+ - Existing `TEAM_GET_DATA` rejects unknown option keys before dispatching.
+ - If `getMemberLogStream` silently accepts unknown keys, typos in `laneId`, `since` or `forceRefresh` can create stale or ambiguous logs while tests still pass.
+ - Use an allow-list: `limitSegments`, `since`, `laneId`, `forceRefresh`.
+ - Return `Unknown getMemberLogStream option: ${key}` before calling the service.
+
+39. Participant/source identity drift.
+ - `BoardTaskLogParticipant` drives actor chips, color lookup and member labels.
+ - If provider/session is encoded as participant identity, one selected member can appear as multiple people and filters become misleading.
+ - Keep `participantKey` actor-based, for example `member:` or existing lead/unknown conventions.
+ - Put provider/session/lane details only in `MemberLogStreamSegment.source`.
+
+40. Since-only reload hides older visible segments.
+ - `since` is useful as a backend performance hint, but a since-filtered response is partial unless the API says otherwise.
+ - Renderer background reload currently replaces state, it does not merge old and new segments.
+ - First PR should do full bounded background reloads, with `forceRefresh` on `log-source-change`.
+ - Use `since` only in tests/service paths where the expected partial semantics are explicit.
+
+41. Parser `retainOnly()` in-flight eviction.
+ - `BoardTaskExactLogStrictParser.parseFiles()` calls `retainOnly()` before parsing.
+ - `BoardTaskActivityParseCache.retainOnly()` deletes `inFlight` entries outside the requested file set.
+ - If task and member streams share a parser, one stream can remove the other's in-flight dedupe and cause duplicate reads or cache churn.
+ - Treat parser instance ownership as per stream source stack. Do not inject the existing task parser into the member stream source stack.
+
+42. Wrong OpenCode mapper source.
+ - `OpenCodeTaskLogStreamSource.toParsedMessage()` preserves content blocks, sanitized display text, `toolUseResult`, `sourceToolUseID`, `sourceToolAssistantUUID`, `isMeta`, subtype and level.
+ - `OpenCodeTaskStallEvidenceSource.toParsedMessage()` is narrower and maps non-string content to an empty array.
+ - Extract the shared mapper from task stream source only; stall monitor migration can be a separate cleanup if needed.
+
+43. Finder metadata overreach.
+ - Adding `messageCount` to `MemberLogFileRef` is useful only when that value is already cheaply known from existing attribution records.
+ - Parsing every candidate file inside the finder just to count messages would double the IO before the strict parser runs.
+ - Keep finder metadata lightweight: `kind`, `sizeBytes`, `mtimeMs` and optional existing `messageCount`.
+
+44. Tracking consumer leaks or semantic drift.
+ - `TeamLogSourceTracker` keeps reference counts by team and consumer name.
+ - Reusing `task_log_stream` from member popup is technically possible but makes task/member lifecycles indistinguishable in tests and diagnostics.
+ - Add `member_log_stream` as a separate consumer and ensure every mount enable has a matching unmount or team-change disable.
+
+45. Generic renderer view grows side effects.
+ - `TaskLogStreamSection` currently owns fetch, debounce, stale-response protection and task copy.
+ - If `ExecutionLogStreamView` imports `api.teams`, feature gates or `MemberLogsTab`, it stops being a reusable render primitive.
+ - Keep the generic view pure: render normalized stream, participants, segment headers and state. Containers own loading, tracking and fallback.
+
+46. Codex partial trace presented as complete.
+ - `CodexNativeTraceReader` is task-keyed and `CodexNativeTraceProjector` keeps only native tool events.
+ - Reusing the task source for member logs would either require a task id or scan all task dirs, and the result still would not be full Codex chat history.
+ - First PR keeps Codex coverage `skipped`. A later partial trace must be explicitly labeled `codex_native_trace_partial`.
+
+47. Runtime lane validation too strict or too loose.
+ - `validateMemberName()` rejects `secondary:opencode:` because of `:`.
+ - Do not lowercase or rewrite `laneId`; orchestrator/session records may expect exact lane ids.
+ - Add an optional lane validator that trims, accepts `primary` and colon-separated runtime lanes, rejects empty strings, NUL/newlines/control characters, path separators and length over 256.
+
+48. DTO inheritance/source union drift.
+ - `BoardTaskLogStreamResponse.source` is task-scoped and does not include member values.
+ - Do not extend `BoardTaskLogStreamResponse` for member logs or broaden its `source` union.
+ - Define standalone `MemberLogStreamResponse` with shared participant/segment primitives, then test that task stream source values stay unchanged.
+
+49. Feature boundary drift.
+ - The easiest regression is to put policy in `src/main/ipc/teams.ts`, preload glue or React containers because those files already exist.
+ - Keep the feature-owned policy in `src/features/member-log-stream/core` and provider IO in feature main adapters.
+ - App-shell files may register, delegate or mount the feature, but they must not know merge order, budgets, source coverage rules or provider-specific parsing details.
+
+## Rollout And Feature Gates
+
+Add separate gates:
+
+- Main: `CLAUDE_TEAM_MEMBER_LOG_STREAM_READ_ENABLED`, default true.
+- Renderer: `VITE_MEMBER_LOG_STREAM_UI_ENABLED`, default true.
+- Put main gate in `src/features/member-log-stream/main` or feature contracts/helpers, following existing truthy/falsy parsing semantics.
+- Put renderer gate in `src/features/member-log-stream/renderer` or a feature renderer helper. Do not read `import.meta.env` outside renderer code.
+
+Fallback behavior:
+
+- If main gate is off, handler returns empty response or unavailable result consistently.
+- If renderer gate is off, `MemberDetailDialog` uses old `MemberLogsTab`.
+- If new stream errors, show error plus old logs fallback.
+- If task log `Execution Sessions` renders old session logs, it stays unchanged. It imports `MemberLogsTab` directly and should not be coupled to member stream rollout.
+
+Rollback:
+
+- Flip renderer gate off to restore old member logs UI.
+- Flip main gate off if parsing causes unexpected load.
+- No data migration needed.
+
+## Testing Plan
+
+Main-process tests:
+
+- Core domain policy tests cover merge order, source coverage status, budget decisions, dedupe keys and DTO-safe source metadata without main/preload/renderer imports.
+- Core application use-case tests inject fake `MemberLogStreamSource` ports, fake clock/logger/cache ports and verify fail-soft source behavior.
+- Core application tests prove adding a new source adapter does not require changing renderer or transport code.
+- `GetMemberLogStreamUseCase` returns empty stream for no files.
+- It builds one or more segments from synthetic Claude JSONL files for selected member.
+- It does not include logs attributed to another member.
+- It skips unreadable/malformed files and records warnings.
+- It respects `limitSegments`.
+- It respects `maxTranscriptFiles`, `maxSegments` and `maxChunks`.
+- It respects `maxSourceMessages` and `maxMessagesPerSegment`.
+- It respects `maxTotalContentChars`, `maxMessageContentChars` and `maxToolResultContentChars`.
+- It sets `truncated: true` and `large_log_window_limited` when budget is hit.
+- It records `message_content_limited` when content budget is hit.
+- It preserves JSON-looking tool output unless content budget requires truncation.
+- It does not apply board/task-specific JSON payload sanitization globally.
+- It records `segment_message_window_limited` when trimming one oversized segment.
+- It preserves tool call/result pairs when trimming, or drops orphan results.
+- It preserves tool ids and source ids when truncating oversized content.
+- It dedupes cumulative subagent refs by member/session before parse.
+- It keeps segment ids stable across repeated calls.
+- It does not expose absolute file paths in segment ids, metadata or warnings.
+- It does not share parser cache with task stream service.
+- It uses a member-owned `BoardTaskExactLogStrictParser` so member parsing cannot call `retainOnly()` on the task stream parser or clear task `inFlight` entries.
+- A task-stream parse in flight stays joined after a member-stream parse starts for a different file set.
+- It handles removed member name without requiring current team membership.
+- It augments finder attribution with requested member names for historical/removed members.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` can return optional `kind` and `sizeBytes` without breaking existing callers.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` does not parse candidate transcript files just to compute `messageCount`.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` keeps legacy numeric/null third-arg behavior.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` supports object options `{ mtimeSinceMs, forceRefresh }`.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` passes `forceRefresh` to `discoverProjectSessions()`.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` applies `mtimeSinceMs` to lead transcript refs.
+- It merges source results in deterministic order.
+- It joins identical active `GetMemberLogStreamUseCase` requests and does not add long-lived response cache.
+- It returns `MemberLogStreamSegment.source` metadata without leaking file paths.
+- Provider source tests cover Claude included, OpenCode skipped, Codex skipped.
+- First-PR Codex skipped adapter does not call `CodexNativeTraceReader.readTaskRuns()` and does not scan trace dirs.
+- `OpenCodeRuntimeProjectionMapper` preserves tool calls, tool results, `sourceToolUseID`, `sourceToolAssistantUUID`, `toolUseResult`, `isMeta` and sanitized text content.
+- `OpenCodeTaskLogStreamSource` and `OpenCodeMemberRuntimeStreamSource` both use `OpenCodeRuntimeProjectionMapper`.
+- Mapper fixture tests cover non-string content blocks so member stream does not regress to the narrower stall-monitor conversion.
+- `OpenCodeMemberRuntimeStreamSource` joins duplicate in-flight bridge calls for the same team/member/lane/limit.
+- `OpenCodeMemberRuntimeStreamSource` honors `forceRefresh` by bypassing completed TTL cache but still joining active in-flight work.
+- `ResolvedTeamMember` exposes runtime/lane fields without renderer casts: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`.
+- `MemberLogStreamSection` passes `laneId` only for OpenCode members whose lane is owned by OpenCode.
+- `MemberLogStreamSection` does not pass stale lane-like fields for non-OpenCode members.
+
+IPC/preload tests:
+
+- Feature IPC tests register/remove feature channels through `registerMemberLogStreamIpc()` and do not rely on `src/main/ipc/teams.ts` owning the handler.
+- Feature preload tests cover `createMemberLogStreamBridge()` and import only feature contracts plus preload-safe helpers.
+- `getMemberLogStream` validates `teamName` and `memberName`.
+- It accepts colon-separated `laneId`, for example `secondary:opencode:alice`.
+- It accepts `primary` as a lane id.
+- It preserves exact lane id casing and punctuation when passing to the service.
+- It rejects `laneId` with NUL/newline/control characters/path separators or length over 256.
+- It clamps `limitSegments` to `1..80`.
+- It rejects invalid `since`.
+- It accepts boolean `forceRefresh` and rejects non-boolean values.
+- It rejects unknown option keys before dispatching to `GetMemberLogStreamUseCase`.
+- It registers and removes feature `MEMBER_LOG_STREAM_GET` channel.
+- It registers and removes feature `MEMBER_LOG_STREAM_SET_TRACKING` channel.
+- It calls the feature facade/use case with normalized options.
+- It maps member stream tracking to `TeamLogSourceTracker` consumer `member_log_stream`.
+- `TeamLogSourceTracker` supports `member_log_stream` as a separate consumer from `task_log_stream`.
+- Multiple member stream mounts for the same team increment/decrement the same team-level consumer count without closing the watcher early.
+- Disabling member stream tracking leaves the watcher alive if task stream or another consumer is still active.
+- It does not add feature-owned services to existing positional `initializeTeamHandlers()` dependencies.
+- Existing task stream IPC test still calls `BoardTaskLogStreamService.getTaskLogStream()` with the correct arguments after adding separate feature IPC registration.
+- Browser-mode `httpClient` returns a complete empty `MemberLogStreamResponse`.
+- Browser-mode `httpClient` exposes a no-op `setMemberLogStreamTracking`.
+
+OpenCode tests:
+
+- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` passes `--lane` when `laneId` is provided.
+- It keeps old behavior when `laneId` is omitted.
+- It accepts popup-specific `timeoutMs` and passes it to `execCli`.
+- Member stream records `opencode_ambiguous_lane` when orchestrator reports multiple records.
+- Member stream records `opencode_runtime_timeout` without failing Claude transcript segments.
+- Member stream includes OpenCode projection only for the selected member.
+
+Renderer tests:
+
+- `renderer/ui` component tests pass props and never mock `@renderer/api` or `@renderer/store`.
+- `renderer/hooks` tests own API calls, tracking, team-change subscription and reload coalescing.
+- Feature renderer public entrypoint exports only supported components/hooks, and app shell does not deep-import `renderer/ui` internals.
+- `MemberLogStreamSection` shows loading, empty, error and populated states.
+- It renders chunks through the same execution-log renderer as task stream.
+- It does not display `Task Log Stream` copy.
+- It keeps old stream during failed background refresh.
+- It shows fallback old logs path when new stream fails.
+- `MemberDetailDialog` renders `MemberLogStreamSection` when renderer gate is on.
+- `MemberDetailDialog` renders old `MemberLogsTab` when renderer gate is off.
+- `MemberDetailDialog` first-load stream failure leaves the Logs tab active and shows explicit old logs fallback.
+- Existing `test/renderer/components/team/members/MemberDetailDialog.test.ts` mocks `MemberLogsTab`; add a mock for `MemberLogStreamSection` instead of depending on the full stream component there.
+- It shows bounded-history note when `truncated` is true.
+- It reloads on same-team `log-source-change` and `task-log-change`.
+- It sends `forceRefresh: true` for `log-source-change` reloads.
+- It does not pass `since` for renderer background replacement reloads in the first PR.
+- It calls the feature tracking bridge on mount and disables it on unmount.
+- It disables member stream tracking for the previous team when `teamName` changes.
+- It coalesces duplicate reloads while a member stream request is active.
+- It does not reload on `tool-activity`.
+- It keeps OpenCode segments with no `Process[]` rendering normal tool/output items.
+- `TaskLogStreamSection` still renders unchanged after extracting shared view.
+- `ExecutionLogStreamView` renders task and member streams from the same segment/chunk shape without task-specific text.
+- `ExecutionLogStreamView` does not import `api.teams`, feature gates, `MemberLogsTab` or store-bound loading hooks.
+- `ExecutionLogStreamView` handles the same Date-shaped chunk objects that `TaskLogStreamSection` handles today.
+- `ExecutionLogStreamView` normalizes chunks without dropping unknown stream fields or member-specific `segment.source`.
+- `ExecutionLogStreamView` preserves task tail-growth expanded state with the existing default render key behavior.
+- `ExecutionLogStreamView` accepts member source-aware `buildSegmentRenderKey` to avoid provider/session key collisions.
+- Shared type tests or compile checks prevent `MemberLogStreamResponse` from extending or mutating task `BoardTaskLogStreamResponse.source`.
+- Member stream participant identity remains actor/member identity; provider/session/lane labels render from `segment.source`.
+- Member stream does not create provider-specific pseudo-participants for Claude/OpenCode/Codex.
+
+Architecture/lint tests:
+
+- `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content` passes.
+- Full `pnpm lint` passes with generic `src/features/*` guard rails.
+- No file outside `src/features/member-log-stream` deep-imports feature internals such as `core/**`, `main/adapters/**`, `preload/**` or `renderer/ui/**`.
+- `core/domain` has no imports from `@main`, `@renderer`, `@preload`, Electron, Fastify or child process modules.
+- `core/application` imports only domain, contracts and ports, not concrete source adapters.
+- `renderer/ui` has no imports from `@renderer/api`, `@renderer/store`, `@main` or Electron.
+
+Regression tests:
+
+- Existing `BoardTaskLogStreamService` tests stay green.
+- Existing `TaskLogStreamSection` tests stay green.
+- Existing `MemberLogsTab` tests stay green because the fallback component remains.
+
+Suggested commands:
+
+```bash
+pnpm vitest test/features/member-log-stream/core/application/GetMemberLogStreamUseCase.test.ts
+pnpm vitest test/features/member-log-stream/main/adapters/input/registerMemberLogStreamIpc.test.ts
+pnpm vitest test/features/member-log-stream/renderer/MemberLogStreamSection.test.tsx
+pnpm vitest test/main/services/team/TeamMemberLogsFinder.test.ts
+pnpm vitest test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
+pnpm vitest test/renderer/components/team/taskLogs/TaskLogStreamSection.test.tsx
+pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content
+pnpm typecheck
+```
+
+Manual QA:
+
+- Open participant popup for a Claude member with existing logs.
+- Confirm UI looks like task stream but title/copy are member-specific.
+- Open a member with no logs.
+- Open a member while task activity/log-source events arrive.
+- Test OpenCode member where one lane is resolvable.
+- Test OpenCode member with ambiguous lane and confirm warning/fallback instead of wrong logs.
+
+## Acceptance Criteria
+
+- Participant popup Logs section uses the new stream format.
+- Selected member stream does not include unrelated teammates.
+- Claude transcript logs render in the same chunk UI as Task Log Stream.
+- OpenCode is included only when runtime session resolution is safe.
+- Codex native limitation is visible in coverage/warnings and not misrepresented.
+- Large histories are capped and marked truncated instead of freezing the popup.
+- Oversized single segments are capped by message budget, not only chunk count.
+- `laneId` is passed and validated separately from member name.
+- Cumulative subagent snapshot duplicates are not shown as repeated turns.
+- Lead transcript refs respect `since`/`mtimeSinceMs` the same way member/subagent refs do.
+- OpenCode projection rendering is shared with task stream through a mapper, not copy-pasted.
+- Shared OpenCode mapper is extracted from task stream source, not from the narrower stall-monitor mapper.
+- Member stream parser ownership is isolated, including parsed cache and in-flight parse dedupe.
+- Member stream reloads coalesce instead of launching parallel expensive IPC requests.
+- Member stream activates log-source tracking while mounted, without depending on Task Logs panel.
+- Member stream segment render keys are source-aware and do not collide across provider/session segments.
+- Repeated OpenCode member refreshes join in-flight bridge calls instead of spawning duplicate `runtime transcript` processes.
+- Old `MemberLogsTab` remains available as fallback.
+- No orchestrator code change is required for variant 2 unless desktop cannot pass existing `--lane`.
+- Feature can be disabled with renderer/main gates.
+- `src/features/member-log-stream` follows `docs/FEATURE_ARCHITECTURE_STANDARD.md`: contracts own DTO/channels, core is side-effect free, source ports live in application, provider IO lives in main adapters, renderer UI is presentational.
+- App shell imports only public feature entrypoints and does not deep-import feature internals.
+- Clean Architecture dependency direction is preserved: core has no framework/runtime imports, adapters depend inward, and concrete provider IO is hidden behind ports.
+- SOLID is visible in code structure: provider adapters are replaceable, use cases depend on abstractions, and UI/transport/provider parsing have separate reasons to change.
+- DRY is visible in shared renderer primitive, shared OpenCode projection mapper and single owner for member stream DTOs.
+- Tests cover the main risky branches above.
+
+## Explicit Non-goals
+
+- Do not build a full provider-wide log audit system in variant 2.
+- Do not add Codex member-wide trace index in variant 2.
+- Do not merge all team agents into one participant popup stream.
+- Do not remove old member logs UI in the first release.
+- Do not silently pick an OpenCode lane when orchestrator says the session is ambiguous.
+
+## Implementation Order
+
+1. Create `src/features/member-log-stream` with contracts, core/application source ports, main composition, preload bridge and renderer public entrypoints.
+2. Add feature-owned member stream DTOs, `MemberLogStreamSegment.source` metadata and `ResolvedTeamMember` runtime/lane fields.
+3. Add feature IPC channel constants, feature preload bridge, browser-mode fallback and optional compatibility methods on `api.teams` if the existing member popup integration needs them.
+4. Add IPC validation for `limitSegments`, `since`, `forceRefresh`, optional runtime `laneId` and unknown option keys in the feature input adapter or shared guard helpers.
+5. Add feature main facade/composition and register IPC handlers without shifting existing positional `initializeTeamHandlers()` dependencies.
+6. Add `MemberLogStreamBudget` and source-port interfaces.
+7. Extend `MemberLogFileRef` with optional `kind`/`sizeBytes`/`messageCount`, add backward-compatible finder options parsing, apply `mtimeSinceMs` to lead refs, augment finder attribution with requested member names and add tests around cumulative subagent refs. Keep metadata cheap: do not parse every candidate file inside the finder just to compute `messageCount`.
+8. Extract provider-neutral `ParsedMessage` hygiene helpers without moving board/task JSON cleanup into member stream.
+9. Add pair-aware message trimming helper for oversized transcript windows.
+10. Add content-size truncation helper that clones affected `ParsedMessage` objects before chunk build.
+11. Add `ClaudeMemberTranscriptStreamSource` using recent member refs, ref dedupe, message/content trimming and dedicated parser.
+12. Extract `OpenCodeRuntimeProjectionMapper` from `OpenCodeTaskLogStreamSource` without moving task-marker logic. Do not extract from `OpenCodeTaskStallEvidenceSource`; its mapper is lossy for stream rendering.
+13. Add `GetMemberLogStreamUseCase` merge/sort/budget/truncation layer, identical active request join, and export/instantiate it in feature main composition.
+14. Add `CodexNativeMemberTraceStreamSource` as skipped coverage adapter.
+15. Add renderer feature `ExecutionLogStreamView` render-only extraction with shape-preserving normalization, source-aware key override and `MemberLogStreamSection` with tracking activation plus reload coalescing.
+16. Wire `MemberDetailDialog` behind renderer feature gate with old fallback, importing only `@features/member-log-stream/renderer`.
+17. Add OpenCode bridge `laneId` and timeout support.
+18. Add `OpenCodeMemberRuntimeStreamSource` with mapper reuse, short TTL cache and in-flight join. Leave stall-monitor mapper migration out of scope unless tests show a shared helper can be adopted without changing stall evidence behavior.
+19. Add coverage/warnings and empty/error states.
+20. Add tests, including feature architecture import-boundary checks if the repo has a matching lint pattern.
+21. Manual QA with Claude and OpenCode teams.
+22. Decide separately whether to add partial Codex native tool trace or start variant 3.
diff --git a/docs/team-management/member-log-stream-v2-research-addendum.md b/docs/team-management/member-log-stream-v2-research-addendum.md
new file mode 100644
index 00000000..f7c58664
--- /dev/null
+++ b/docs/team-management/member-log-stream-v2-research-addendum.md
@@ -0,0 +1,2046 @@
+# Member Log Stream V2 Research Addendum
+
+## Scope
+
+Этот документ углубляет места с самой низкой уверенностью из основного плана:
+
+- OpenCode lane/session resolution;
+- Codex native member-wide feasibility;
+- Claude/member transcript attribution;
+- parser/cache safety;
+- architecture placement;
+- renderer performance budget;
+- oversized message/content budget;
+- IPC validation and abuse limits;
+- IPC registration/composition safety;
+- exact stream DTO/render contract;
+- cumulative subagent snapshot dedupe;
+- OpenCode projection mapper extraction;
+- message/window truncation semantics;
+- member popup live refresh event policy;
+- API shape между renderer, preload и main.
+
+Вывод после code research: вариант 2 остается правильным, но детали нужно сделать строже. Особенно важно не использовать `findMemberLogPaths()` как основной источник и не вызывать OpenCode transcript без `laneId`, если lane уже известен.
+
+## Executive Findings
+
+| Зона | Было | Стало после research | Решение |
+| --- | --- | --- | --- |
+| Claude/member transcript | Уверенность высокая | Еще выше: есть готовый `findRecentMemberLogFileRefsByMember()` с mtime, sessionId и сортировкой | Использовать его, не `findMemberLogPaths()` |
+| OpenCode lane | Средняя уверенность | `TeamMemberSnapshot` уже несет `providerBackendId/selectedFastMode/resolvedFastMode/laneId/laneKind/laneOwnerProviderId`, renderer получает это через spread | Типизировать `ResolvedTeamMember`, передавать `laneId` в `getMemberLogStream` и bridge |
+| Codex native | Низкая уверенность | `readTaskRuns()` физически task-keyed, а projector keeps only native tool events | First PR skipped; partial trace only as separate phase with honest label |
+| Parser cache | Средняя уверенность | `parseFiles()` вызывает `retainOnly()`, а `retainOnly()` чистит и cache, и `inFlight` entries | Держать отдельный parser instance для member stream |
+| UI reuse | Средняя уверенность | `TaskLogStreamSection` содержит task-specific copy и loading logic | Вынести generic view, оставить source-specific containers |
+| Architecture placement | Уверенность высокая | Feature standard прямо подходит: cross-process feature, own policy, transport wiring, more than one adapter, provider roadmap | Делать `src/features/member-log-stream` canonical slice, with thin app-shell integration |
+| Render pressure | Низкая уверенность | `MemberExecutionLog` рендерит все groups и держит их expanded by default | Ограничивать backend response budget |
+| IPC validation | Средняя уверенность | `laneId` содержит `:`, значит `validateMemberName` не подходит | Добавить отдельную optional lane validator |
+| Cumulative subagent logs | Низкая уверенность | `findRecentMemberLogFileRefsByMember()` dedupe только by filePath, а subagent snapshots могут быть cumulative | Добавить/использовать ref metadata и dedupe by member/session/kind перед parse |
+| OpenCode projection mapping | Средняя уверенность | Есть richer mapper в task source и lossy mapper в stall monitor | Вынести generic mapper из task source, не из stall monitor |
+| Finder ref metadata | Средняя уверенность | Current refs не несут `kind`/`sizeBytes`, но full parse ради `messageCount` удвоит IO | Добавлять lightweight metadata и не считать `messageCount` полным parse |
+| Renderer extraction | Средняя уверенность | `TaskLogStreamSection` смешивает generic stream view и task-specific copy/reload | Вынести `ExecutionLogStreamView`, оставить containers source-specific |
+| Budget semantics | Низкая уверенность | `maxChunks` не ограничивает большой AI chunk с сотнями tool calls | Добавить message budget и pair-aware trimming before chunk build |
+| Content budget | Низкая уверенность | One huge tool result can render through `DisplayItemList` even with low message count | Add content-char budgets before chunk build |
+| Live refresh | Средняя уверенность | member stream не знает taskId, а `task-log-change` не несет memberName | Reload on same-team `log-source-change` and `task-log-change`, debounced |
+| IPC composition | Уверенность высокая | `initializeTeamHandlers()` positional deps make legacy team IPC a bad owner for this feature | Register feature IPC through `@features/member-log-stream/main`, do not add service to team handlers |
+| Browser fallback | Средняя уверенность | Browser API must satisfy feature API even without Electron IPC | Вернуть complete empty `MemberLogStreamResponse`, как task stream fallback |
+| Historical members | Средняя уверенность | Finder attribution uses `knownMembers` from config/meta/inbox, not necessarily removed popup member | Add requested names to attribution set inside recent-ref finder |
+| Finder options compatibility | Средняя уверенность | `findRecentMemberLogFileRefsByMember()` уже используется positional `mtimeSinceMs` callers | Add backward-compatible third-arg parser, not object-only signature |
+| Lead transcript mtime window | Новая находка | `mtimeSinceMs` сейчас фильтрует candidates, но lead transcript добавляется до этого filter | Apply mtime window consistently to lead and candidate refs |
+| Segment metadata | Средняя уверенность | `BoardTaskLogSegment` has no provider/session label, so mixed sources become opaque | Use `MemberLogStreamSegment.source` metadata without file paths |
+| Segment render keys | Новая находка | Task stream key uses `participantKey:firstChunkId`, good for tail growth but risky across member sources | Generic view gets caller-provided segment key builder |
+| Renderer reload pressure | Новая находка | Task stream drops stale responses by request seq but still allows parallel IPC calls | Member stream should coalesce in-flight reloads |
+| Live tracking activation | Новая находка | `TaskLogStreamSection` subscribes, but `TaskLogsPanel` enables `TeamLogSourceTracker`; member popup has no equivalent parent | Add member stream tracking activation while popup section is mounted |
+| IPC option strictness | Новая находка | `TEAM_GET_DATA` rejects unknown option keys before dispatch | Reject unknown member-stream option keys too |
+| Participant/source identity | Новая находка | `BoardTaskLogParticipant` is actor identity, while provider/session/lane is source identity | Keep participant actor-based and render provider/session from `segment.source` |
+| Since reload semantics | Новая находка | Renderer state replacement plus since-filtered partial response can hide older visible segments | First PR uses full bounded background reloads, not client-side incremental merge |
+| Chunk date shape | Средняя уверенность | Renderer group transformer expects Date objects, while tests can use JSON-like fixtures | Keep task stream assumption, add shared normalizer only if needed |
+| Parser in-flight ownership | Новая находка | Shared parser can delete another stream's active parse dedupe through `retainOnly()` | Parser ownership is per stream service unless cache API is redesigned |
+| OpenCode mapper source | Новая находка | Stall-monitor mapper drops non-string content blocks and `toolUseResult` | Shared mapper source must be `OpenCodeTaskLogStreamSource` |
+| Tracker activation | Новая находка | `TeamLogSourceTracker` uses team/consumer reference counts and only runs while consumers are active | Add `member_log_stream` consumer, not a member-specific watcher |
+| Runtime lane validation | Новая находка | Existing member validator rejects `:`, but lane ids can be `secondary:opencode:` | Add optional lane validator that preserves exact lane id |
+| Generic renderer purity | Новая находка | `TaskLogStreamSection` mixes fetch/debounce/copy/render helpers | Extract render-only view and keep API/fallback/gates in containers |
+
+## 0. Architecture Boundary Decision
+
+### Repo Standard Tension
+
+`docs/FEATURE_ARCHITECTURE_STANDARD.md` говорит, что full feature slice нужен, когда feature spans process boundaries, имеет business rules, transport bridge, more than one adapter и provider roadmap.
+
+`Member Log Stream V2` подходит под этот стандарт:
+
+- feature spans main/preload/renderer;
+- feature owns merge, dedupe, budget and provider coverage policy;
+- feature needs transport wiring;
+- feature has multiple source adapters: Claude, OpenCode, Codex skipped/partial later;
+- roadmap явно ведет к variant 3/provider extensibility.
+
+Текущая реальность кода все еще важна:
+
+- task stream уже живет в `src/main/services/team/taskLogs/stream`;
+- member popup уже живет в `src/renderer/components/team/members`;
+- team IPC уже централизован в `src/main/ipc/teams.ts`;
+- `api.teams` уже содержит `getMemberLogs`, `getTaskLogStream`, `getLogsForTask`.
+
+Поэтому правильная стратегия не "перетащить весь team logs слой", а создать feature slice для новой member stream capability and keep old surfaces as integration points.
+
+### Options
+
+#### A. Canonical feature slice with thin legacy integration
+
+🎯 8.5 🛡️ 9 🧠 7
+Примерно 1500-2300 LOC.
+
+Плюсы:
+
+- соответствует feature standard;
+- contracts/core/application/source ports становятся clean architecture boundary;
+- provider sources расширяются по OCP;
+- старый task stream and legacy `MemberLogsTab` не мигрируются в этом PR;
+- app shell imports only public feature entrypoints.
+
+Минусы:
+
+- больше файлов и немного больше boilerplate;
+- нужно аккуратно сделать compatibility with existing `MemberDetailDialog` and app API;
+- нужно добавить import-boundary discipline from the start.
+
+#### B. Legacy extension in current team services
+
+🎯 7 🛡️ 6.5 🧠 5
+Примерно 550-850 LOC.
+
+Плюсы:
+
+- минимальный blast radius;
+- ближе к существующему `Task Log Stream`;
+- быстрее внедряется.
+
+Минусы:
+
+- легко получить большой `MemberLogStreamService` с provider-specific ветками;
+- хуже соответствует feature architecture standard;
+- future variant 3 будет сложнее выделять.
+
+#### C. Existing team surface plus source ports
+
+🎯 8 🛡️ 8 🧠 6
+Примерно 1300-2050 LOC, если включить ref metadata dedupe, mapper extraction, renderer shared view, member tracking activation, dialog-level fallback tests, OpenCode in-flight protection, renderer reload coalescing и provider-neutral message hygiene extraction.
+
+Плюсы:
+
+- renderer-facing API is feature-owned, for example `api.memberLogStream`; existing `api.teams` can remain only as a thin compatibility delegate if needed by current popup wiring;
+- implementation остается рядом с existing `taskLogs/stream`;
+- provider/runtime логика делится на отдельные source classes;
+- часть будущей миграции к feature slice будет подготовлена.
+
+Минусы:
+
+- это все еще не full canonical feature slice;
+- нужно дисциплинированно не смешать orchestration, provider IO и UI DTO в одном файле.
+
+### Recommendation
+
+Выбрать A для первого PR.
+
+Правило:
+
+- `src/features/member-log-stream/contracts` owns DTOs, channels, normalize helpers and API fragment types;
+- `core/domain` owns pure policies: merge order, budget decisions, dedupe keys and coverage semantics;
+- `core/application` owns `MemberLogStreamSource` ports and the use case;
+- `main/adapters/output/sources` owns Claude/OpenCode/Codex source adapters;
+- `main/adapters/input/ipc` owns validation and IPC translation;
+- `preload` owns thin bridge;
+- `renderer/ui` owns presentational components only;
+- old `MemberLogsTab`, task stream and task exact logs are not migrated in this PR;
+- if existing `api.teams` is needed for compatibility, it delegates to feature contracts/use case and does not own DTO/policy.
+
+### Exact Standard Compliance
+
+The feature should be reviewable against `docs/FEATURE_ARCHITECTURE_STANDARD.md` without special exceptions:
+
+- `contracts/` owns only DTOs, API fragment types, channel constants and normalize helpers.
+- `core/domain/` owns pure policies: source merge order, source coverage state, dedupe keys, budget decisions and safe source metadata.
+- `core/application/` owns use cases and ports: `MemberLogStreamSource`, clock/logger/cache/tracking ports and response models.
+- `main/composition/` wires concrete adapters and exposes `MemberLogStreamFeatureFacade`.
+- `main/adapters/input/ipc/` owns IPC validation and transport translation.
+- `main/adapters/output/sources/` owns Claude/OpenCode/Codex source adapters.
+- `main/infrastructure/` owns runtime bridge helpers, parser wrappers, TTL/in-flight cache and filesystem details.
+- `preload/` owns only a thin feature bridge.
+- `renderer/hooks/` owns API calls, tracking activation, team-change subscriptions and reload coalescing.
+- `renderer/ui/` owns presentational components only.
+
+Clean Architecture dependency rule:
+
+- domain imports no application/adapters/infrastructure/framework/process code;
+- application imports no main/preload/renderer or concrete IO;
+- adapters import inward to application/domain and outward to infrastructure;
+- renderer UI receives props and view models, never direct API/store/Electron access;
+- app shell imports only public entrypoints.
+
+SOLID and DRY application:
+
+- SRP: `GetMemberLogStreamUseCase` coordinates use case flow only; provider IO stays in source adapters; renderer UI only renders.
+- OCP: adding Codex partial/full later means adding or swapping a source adapter, not rewriting renderer or use case branches.
+- LSP: all sources honor the same `included | partial | skipped` result shape and fail softly for expected absence.
+- ISP: core ports stay narrow. Do not pass `TeamDataService`, Electron events or renderer member snapshots through core.
+- DIP: use cases depend on ports; concrete finder/bridge/parser implementations live outside core.
+- DRY: one DTO owner in feature contracts, one stream render primitive, one OpenCode projection mapper, one provider-neutral message hygiene helper set.
+
+Lint status:
+
+- Generic feature guard rails already exist in `eslint.config.js` for `src/features/*`.
+- Implementation should rely on those first and add member-log-stream-specific guard rails only if generic messages are not strict enough.
+- Targeted lint command: `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content`.
+
+Project standard cross-check:
+
+- Top-level `CLAUDE.md` says new medium/large features default to `src/features/` and must follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`.
+- `docs/FEATURE_ARCHITECTURE_STANDARD.md` requires the full slice when a feature spans process boundaries, owns business policy, has transport wiring, has multiple adapters or has a provider roadmap. Member log stream matches all five.
+- `eslint.config.js` already enforces generic public-entrypoint, core-domain, core-application, preload and renderer-UI guards for `src/features/*`.
+- `src/features/recent-projects` confirms the public entrypoint pattern: `contracts/index.ts`, `main/index.ts`, `preload/index.ts` and `renderer/index.ts` expose only supported surface.
+- `src/features/CLAUDE.md` is referenced by `CLAUDE.md`, but is absent in this worktree, so the binding standard for this plan is the top-level `CLAUDE.md` plus `docs/FEATURE_ARCHITECTURE_STANDARD.md`.
+
+## 0.1 Source-Port Design
+
+Чтобы не нарушить SRP/OCP, `GetMemberLogStreamUseCase` не должен сам знать все детали Claude/OpenCode/Codex.
+
+Нужен internal interface:
+
+```ts
+interface MemberLogStreamSourceInput {
+ teamName: string;
+ memberName: string;
+ laneId?: string;
+ budget: MemberLogStreamBudget;
+ sinceMs?: number | null;
+ forceRefresh?: boolean;
+}
+
+interface MemberLogStreamSourceResult {
+ provider: MemberLogStreamProvider;
+ status: 'included' | 'partial' | 'skipped';
+ segments: MemberLogStreamSegment[];
+ warnings: MemberLogStreamWarning[];
+}
+
+interface MemberLogStreamSource {
+ readonly provider: MemberLogStreamProvider;
+ load(input: MemberLogStreamSourceInput): Promise;
+}
+```
+
+First PR sources:
+
+- `ClaudeMemberTranscriptStreamSource`;
+- `OpenCodeMemberRuntimeStreamSource`;
+- `CodexNativeMemberTraceStreamSource` only as skipped coverage adapter, no heavy trace scan.
+
+`GetMemberLogStreamUseCase` responsibilities:
+
+- normalize already-validated options;
+- call sources fail-soft, preferably with `Promise.allSettled()` plus deterministic merge order;
+- merge source results;
+- enforce global budget;
+- sort final segments;
+- build response metadata and warnings.
+
+It should not:
+
+- parse OpenCode CLI output directly;
+- scan Codex trace directories directly;
+- know renderer copy;
+- know old `MemberLogsTab` fallback details.
+
+## 0.2 Renderer Performance Budget
+
+### Facts
+
+`MemberExecutionLog`:
+
+- transforms all chunks into conversation groups;
+- reverses all groups;
+- renders all groups;
+- keeps everything expanded by default unless user collapses groups;
+- has no virtualization.
+
+Repo already depends on `@tanstack/react-virtual`, and `ActivityTimeline` uses it. But adding virtualization to log stream in first PR would be a separate UI behavior project.
+
+### Decision
+
+Do not add virtualization in first PR.
+
+Instead enforce backend budget:
+
+```ts
+const DEFAULT_MEMBER_LOG_STREAM_BUDGET = {
+ maxTranscriptFiles: 40,
+ maxSegments: 30,
+ maxChunks: 250,
+ maxSourceMessages: 1200,
+ maxMessagesPerSegment: 300,
+ maxTotalContentChars: 800_000,
+ maxMessageContentChars: 80_000,
+ maxToolResultContentChars: 120_000,
+ openCodeMessageLimit: 400,
+ openCodeTimeoutMs: 5_000,
+};
+```
+
+Budget semantics:
+
+- candidate transcript refs are newest-first before capping;
+- source segments are sorted by timestamp before final response;
+- global merge keeps newest useful content if sources exceed budget;
+- message budget is enforced before chunk build when one file/session is too large;
+- chunk budget is enforced after chunk build by dropping oldest whole chunks/segments first;
+- response includes `truncated: true` and warning `large_log_window_limited` when any cap is hit;
+- renderer shows a short "showing recent log stream" note from warnings.
+- content-char budgets are enforced before chunk build so a single huge markdown/tool result cannot freeze the popup after expansion.
+
+Why this is safer:
+
+- prevents popup freezes;
+- avoids new virtualization bugs;
+- keeps v2 focused on correct data/source behavior.
+
+Future:
+
+- add `Load older` only after source cursor semantics exist;
+- add virtualization only if product needs audit-sized member streams in popup.
+
+## 0.2.1 Pair-Aware Truncation
+
+### Facts
+
+`ChunkBuilder` groups all "AI" category messages until the next real user/system/compact boundary. A single AI chunk can contain many assistant messages, tool calls and tool results.
+
+So this is not enough:
+
+```ts
+maxChunks: 250
+```
+
+One chunk can still become a large `displayItems` list in `MemberExecutionLog`.
+
+### Decision
+
+Add message-level budget:
+
+```ts
+maxSourceMessages: 1200;
+maxMessagesPerSegment: 300;
+```
+
+Truncation should happen in this order:
+
+1. Dedupe transcript refs.
+2. Sort candidate refs newest-first and cap files.
+3. Parse selected files.
+4. For each file/session, trim parsed messages before chunk build if it exceeds `maxMessagesPerSegment`.
+5. Build chunks.
+6. Drop oldest whole segments/chunks to satisfy `maxSegments`, `maxChunks`, and `maxSourceMessages`.
+
+Pair-aware rule:
+
+- if retaining a meta user tool-result message with `sourceToolUseID`, also retain the assistant message containing the matching `toolCalls.id`;
+- if retaining the matching assistant would exceed the hard message budget, drop the orphan result instead of rendering an unpaired result;
+- preserve chronological order after expansion;
+- mark response `truncated: true`;
+- add warning `segment_message_window_limited`.
+
+Do not attempt text-level truncation inside `EnhancedChunk` in first PR. It is safer to drop old message windows than to mutate renderer-specific chunk internals.
+
+## 0.2.3 Content-Size Budget
+
+### Facts
+
+`MemberExecutionLog` renders AI groups expanded by default. `DisplayItemList` previews are short, but expanded items can render full markdown/tool result content. So these budgets are not sufficient by themselves:
+
+```ts
+maxChunks: 250;
+maxMessagesPerSegment: 300;
+```
+
+One tool result can still contain hundreds of thousands of characters.
+
+### Decision
+
+Add content budgets before chunk build:
+
+```ts
+maxTotalContentChars: 800_000;
+maxMessageContentChars: 80_000;
+maxToolResultContentChars: 120_000;
+```
+
+Rules:
+
+- apply after message-window trimming and before `BoardTaskExactLogChunkBuilder.buildBundleChunks()`;
+- preserve `uuid`, `sourceToolUseID`, `sourceToolAssistantUUID`, tool call ids and tool result ids;
+- replace oversized text/content fields with a short placeholder that states the content was truncated for popup display;
+- do not mutate file-backed parser cache arrays in place. Clone the affected `ParsedMessage` objects before truncating;
+- set `truncated: true`;
+- add warning `message_content_limited`.
+
+Risk rating:
+
+🎯 8 🛡️ 8 🧠 5
+Approx 120-220 LOC.
+
+This is safer than relying on UI collapse/expand behavior, because the current execution log starts expanded.
+
+## 0.2.4 ParsedMessage Hygiene Boundary
+
+### Facts
+
+`BoardTaskLogStreamService` already has useful private message hygiene helpers:
+
+- `cloneBlock()`;
+- `cloneMessageContent()`;
+- `mergeMessages()`;
+- `pruneEmptyInternalToolResultMessages()`;
+- `retainSyntheticToolUseAssistants()`.
+
+It also has task-specific cleanup:
+
+- `sanitizeJsonLikeToolResultPayloads()`;
+- `sanitizeToolResultContent()`;
+- `sanitizeToolResultPayloadValue()`.
+
+That task cleanup is not fully provider-neutral. It is designed around board/task tool payloads and can replace JSON-like tool result payloads with `''` when it cannot extract a board-tool display string. In member-wide logs, a JSON-looking tool result can be a legitimate Bash/API/tool output. Applying task cleanup wholesale would risk hiding useful member logs.
+
+### Options
+
+#### A. Reuse task sanitization as-is for member stream
+
+🎯 5 🛡️ 4 🧠 3
+Approx 20-80 LOC.
+
+Fast, but unsafe. It can hide useful non-board JSON outputs in a member-wide stream.
+
+#### B. Extract provider-neutral hygiene and add member-specific truncation
+
+🎯 8.5 🛡️ 9 🧠 6
+Approx 180-340 LOC.
+
+Create a small shared main-process helper, for example:
+
+```ts
+src/main/services/team/taskLogs/stream/ParsedMessageStreamHygiene.ts
+```
+
+Provider-neutral exports:
+
+- clone message/content blocks without mutating parser-cache objects;
+- prune empty internal tool-result messages;
+- retain synthetic tool-use assistants by clearing only the synthetic model marker;
+- count/truncate message content by char budget;
+- preserve `uuid`, `toolCalls.id`, `toolResults.toolUseId`, `sourceToolUseID`, `sourceToolAssistantUUID` and `toolUseResult.toolUseId`.
+
+Keep board/task cleanup either private to `BoardTaskLogStreamService` or export it with a name that makes the scope explicit, such as `sanitizeBoardToolResultPayloads()`.
+
+#### C. Skip content hygiene and rely only on message-window limits
+
+🎯 6 🛡️ 5 🧠 2
+Approx 0-60 LOC.
+
+Too weak. One retained tool result can still be huge.
+
+### Decision
+
+Use option B.
+
+Implementation rules:
+
+- do not run board-specific JSON payload cleanup across all member logs;
+- truncate oversized strings/arrays recursively but preserve linking ids;
+- when truncating `toolUseResult.content`, `toolUseResult.message`, `toolUseResult.file.content`, `oldString`, `newString`, or `stderr/stdout`, keep the surrounding object shape;
+- replace text with a compact placeholder that includes original char count and retained char count;
+- clone only changed messages to keep memory reasonable;
+- test with a JSON Bash output to confirm it is truncated if huge but not blanked just because it is JSON.
+
+Risk rating:
+
+🎯 8.5 🛡️ 9 🧠 6
+Approx 180-340 LOC.
+
+## 0.2.2 MemberExecutionLog Process Filtering
+
+### Facts
+
+`MemberExecutionLog` passes `memberName` into `AIExecutionGroup`.
+
+`AIExecutionGroup` filters only `group.processes` by `p.team?.memberName`; it does not filter the raw AI steps or tool executions.
+
+That means:
+
+- OpenCode projection segments with no `Process[]` still render normal tools/output;
+- Claude chunks with team `Process[]` avoid showing another member's subagent panels;
+- synthetic/fake `Process` objects are not required for member stream.
+
+### Decision
+
+Do not synthesize fake `Process` entries for OpenCode member stream.
+
+Render OpenCode through normal tool/output chunks from projected messages. Only use `Process[]` when real process metadata already exists.
+
+Add a renderer regression test that a segment with `actor.memberName` and no `processes` still displays tool/output items.
+
+## 0.3 IPC Validation And Limits
+
+### Facts
+
+Existing validators:
+
+- `validateTeamName` fits `teamName`;
+- `validateMemberName` fits member names;
+- `validateTaskId` is task-only;
+- `laneId` can contain `:`, for example `secondary:opencode:alice`, so `validateMemberName` is wrong for lane.
+
+### Correct IPC Policy
+
+Add a local helper near the handler, or a shared guard if reused:
+
+```ts
+export function validateOptionalRuntimeLaneId(value: unknown) {
+ if (value == null) return { valid: true, value: undefined };
+ if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' };
+ const trimmed = value.trim();
+ if (!trimmed) return { valid: true, value: undefined };
+ if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' };
+ if (/[\0-\x1F\x7F/\\]/.test(trimmed)) {
+ return { valid: false, error: 'laneId contains invalid characters' };
+ }
+ return { valid: true, value: trimmed };
+}
+```
+
+Options policy:
+
+- `limitSegments`: integer, clamp to `1..80`, default 30;
+- `since`: if present, must parse as valid date, otherwise return IPC error;
+- `laneId`: optional, max 256, no control chars or path separators, allow `primary` and colon-separated ids like `secondary:opencode:alice`;
+- `forceRefresh`: optional boolean;
+- unknown option keys rejected.
+
+Do not lowercase or otherwise normalize `laneId`. It should be trimmed only, because the orchestrator/runtime record lookup may treat lane ids as exact identities.
+
+Additional code research: `TEAM_GET_DATA` validates options with an allow-list and rejects unknown keys before dispatching to the service/worker. Member log stream should follow that stricter style because option typos can otherwise disable `laneId`, `since` or `forceRefresh` silently.
+
+Recommended allow-list:
+
+```ts
+const allowed = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']);
+```
+
+Return `Unknown getMemberLogStream option: ${key}` for extra keys.
+
+Security:
+
+- do not expose transcript file paths in renderer response;
+- warnings can mention counts and provider names, not absolute paths;
+- service can log paths to main logger if needed.
+
+## 0.4 Exact Stream DTO And Renderer Contract
+
+### Facts
+
+`BoardTaskLogSegment` is already the right low-level render unit:
+
+```ts
+interface BoardTaskLogSegment {
+ id: string;
+ participantKey: string;
+ actor: BoardTaskLogActor;
+ startTimestamp: string;
+ endTimestamp: string;
+ chunks: EnhancedChunk[];
+}
+```
+
+`MemberExecutionLog` only needs `EnhancedChunk[]` plus optional member visual props. It does not know task ids, provider ids, or source metadata.
+
+Additional type research:
+
+- `BoardTaskLogStreamResponse.source` is a task-only union: transcript, OpenCode task runtime fallback/attribution and Codex task trace fallback values.
+- `BoardTaskLogStreamResponse.runtimeProjection` is also task-specific, with attribution/heuristic/trace counters that are not the right member coverage model.
+- `BoardTaskLogSegment` has no `source` field, so it is safe as the render primitive but not enough as the member DTO by itself.
+- Therefore `MemberLogStreamResponse` should be a standalone shared response type, not `extends BoardTaskLogStreamResponse` and not an `Omit` unless the omitted fields are fully replaced.
+
+Recommended DTO shape:
+
+```ts
+type MemberLogStreamSource =
+ | 'member_transcript'
+ | 'member_mixed_runtime'
+ | 'member_runtime_only'
+ | 'member_empty';
+
+interface MemberLogStreamResponse {
+ participants: BoardTaskLogParticipant[];
+ defaultFilter: 'all' | string;
+ segments: MemberLogStreamSegment[];
+ source: MemberLogStreamSource;
+ coverage: MemberLogStreamCoverage[];
+ warnings: MemberLogStreamWarning[];
+ truncated: boolean;
+ generatedAt: string;
+ metadata: MemberLogStreamMetadata;
+}
+```
+
+This keeps task stream source semantics untouched and avoids having member-only source values leak into task UI copy or task tests.
+
+`TaskLogStreamSection` currently owns too much:
+
+- response normalization through `asEnhancedChunkArray`;
+- participant visual mapping;
+- segment key building;
+- segment headers;
+- participant chips;
+- loading, empty and error copy;
+- live reload behavior;
+- task-specific `describeStreamSource()` text.
+
+### Decision
+
+Extract generic render-only view, not a generic data loader:
+
+```ts
+interface ExecutionLogStreamViewProps {
+ title: string;
+ description: string;
+ stream: TStream | null;
+ loading: boolean;
+ error: string | null;
+ emptyTitle: string;
+ emptyDescription?: string;
+ teamName: string;
+ forceSegmentHeaders?: boolean;
+ boundedHistoryNote?: string | null;
+}
+```
+
+Keep loaders separate:
+
+- `TaskLogStreamSection` loads task stream and keeps task-status reload behavior.
+- `MemberLogStreamSection` loads member stream and passes `laneId`/budget options.
+
+This avoids accidentally bringing task-specific reload and copy into member popup.
+
+## 0.4.1 Member Segment Metadata
+
+### Facts
+
+`BoardTaskLogSegment` is enough for rendering chunks, but it does not identify provider source or safe session/lane labels:
+
+```ts
+interface BoardTaskLogSegment {
+ id: string;
+ participantKey: string;
+ actor: BoardTaskLogActor;
+ startTimestamp: string;
+ endTimestamp: string;
+ chunks: EnhancedChunk[];
+}
+```
+
+For task stream this is acceptable because the whole section is task-scoped and source text is global. For member stream, multiple Claude sessions and OpenCode lane projection can appear in one popup. Without segment metadata, the UI either hides important context or parses `segment.id`, which is brittle.
+
+### Decision
+
+Use a member-specific extension:
+
+```ts
+interface MemberLogStreamSegmentSource {
+ provider: MemberLogStreamProvider;
+ label: string;
+ sessionId?: string;
+ laneId?: string;
+ messageCount?: number;
+ truncated?: boolean;
+}
+
+interface MemberLogStreamSegment extends BoardTaskLogSegment {
+ source: MemberLogStreamSegmentSource;
+}
+```
+
+Then:
+
+- `MemberLogStreamResponse.segments` uses `MemberLogStreamSegment[]`;
+- `MemberLogStreamResponse` stays standalone and does not extend `BoardTaskLogStreamResponse`;
+- `ExecutionLogStreamView` remains generic over base `BoardTaskLogSegment`;
+- `MemberLogStreamSection` can pass a segment label renderer that uses `segment.source`;
+- task stream stays unchanged;
+- segment source metadata never includes absolute file paths.
+- segment ids use provider, normalized team/member, session id and a short hash/fingerprint, not raw absolute paths.
+
+Risk rating:
+
+🎯 8.5 🛡️ 8.5 🧠 4
+Approx 40-90 LOC.
+
+This is safer than overloading `segment.id` or adding provider labels into chunk text.
+
+Path rule:
+
+- hash `filePath + mtimeMs + sizeBytes` if a file fingerprint is needed;
+- never send `filePath` to renderer in `id`, `source`, `warnings` or `metadata`;
+- main logger may record file paths for diagnostics, but response DTO should not.
+
+## 0.4.2 Chunk Date Shape
+
+### Facts
+
+`MemberExecutionLog` calls `transformChunksToConversation()`, and `groupTransformer` uses `Date` methods on chunk and semantic-step timestamps. Existing task stream works because Electron IPC/preload returns structured-clone data and the service tests usually pass Date-shaped chunks.
+
+Additional code research: `src/renderer/api/httpClient.ts` already has an ISO date JSON reviver and comments that Electron IPC preserves `Date` instances via structured clone while HTTP JSON needs rehydration. So the runtime date risk is lower than expected for normal app and browser-mode API paths.
+
+The remaining risk is mostly tests and ad hoc fixtures. Renderer unit tests often use lightweight segment fixtures, and browser fallback has no real chunks. If the new generic view starts accepting JSON-like chunk fixtures with ISO strings without going through the HTTP client's reviver, it can fail in `durationMs = endTime.getTime() - startTime.getTime()`.
+
+### Decision
+
+Do not add broad date rehydration by default in the first implementation. Keep the same runtime assumption as task stream.
+
+But add a clear guardrail:
+
+- `ExecutionLogStreamView` tests should use Date-shaped `EnhancedChunk` fixtures when they expect real rendering;
+- if a JSON-like fixture is needed, add one shared helper such as `normalizeEnhancedChunkDates()` and use it from both task/member stream normalization;
+- do not scatter ad hoc `new Date()` calls across render components.
+
+Updated risk rating:
+
+🎯 9 🛡️ 8.5 🧠 3
+Approx 0-120 LOC depending on whether a normalizer is needed.
+
+This keeps variant 2 aligned with existing task stream behavior without hiding a serialization assumption.
+
+## 0.4.3 Renderer Normalization And Source Headers
+
+### Facts
+
+`TaskLogStreamSection.normalizeResponse()` currently reconstructs only known task-stream fields:
+
+```ts
+return {
+ participants: response.participants,
+ defaultFilter: response.defaultFilter,
+ source: response.source,
+ runtimeProjection: response.runtimeProjection,
+ segments: ...
+};
+```
+
+If this exact function is reused for `MemberLogStreamResponse`, it can accidentally drop member-only fields:
+
+- `coverage`;
+- `warnings`;
+- `truncated`;
+- `generatedAt`;
+- `metadata`;
+- `segment.source`.
+
+`TaskLogStreamSection` also hides segment headers when there is only one participant:
+
+```ts
+participants.length > 1 || selectedParticipantKey !== 'all'
+```
+
+That is correct for task logs, but wrong for member logs. Member stream often has one participant, while the important context is per-segment provider/session/lane source.
+
+Additional UI-model research: `BoardTaskLogParticipant` is actor identity. The renderer uses participant labels for chips, member badges and color lookup. Provider/runtime/session identity is a different axis. If member stream encodes provider/session as participant keys, one selected member can appear as multiple people and the "All" filter becomes misleading.
+
+### Options
+
+#### A. Reuse task view logic as-is
+
+🎯 5 🛡️ 4 🧠 2
+Approx 40-100 LOC.
+
+Low effort, but it can hide provider/session labels and silently drop response metadata.
+
+#### B. Generic view preserves stream shape and accepts a segment-header renderer
+
+🎯 9 🛡️ 9 🧠 5
+Approx 120-240 LOC.
+
+The generic view should:
+
+- normalize chunks with `asEnhancedChunkArray`;
+- preserve the rest of the stream object with object spread, including `coverage`, `warnings`, `metadata`, `truncated`, `generatedAt` and `segment.source`;
+- let callers pass `forceSegmentHeaders`;
+- let callers pass `renderSegmentMarker` or `getSegmentMetaLabel`;
+- keep task copy/source text in `TaskLogStreamSection`;
+- keep member provider/session labels in `MemberLogStreamSection`.
+- not import `api.teams`, feature gates, `MemberLogsTab`, provider sources or task/member loading hooks;
+- receive already loaded `teamMembers` from the container so the pure view can be tested without the global store;
+- keep fallback UI outside the generic view, because fallback policy is different for task vs member.
+
+For member stream:
+
+- `forceSegmentHeaders: true`;
+- label each segment with `segment.source.label`;
+- optionally show safe `sessionId` short prefix or `laneId`, never file path;
+- show bounded-history/coverage warnings outside the repeated segment blocks.
+- keep `participantKey` actor-based, for example `member:`, and do not create `claude:`/`opencode:` pseudo-participants;
+- if source filtering is needed later, add a separate source filter instead of overloading participant chips.
+
+Additional renderer key research: current `TaskLogStreamSection.buildStableSegmentRenderKey()` uses `participantKey:firstChunkId`, not `segment.id`. That preserves expanded/collapsed React state when a live refresh extends the tail of the same segment and changes the segment id. Existing tests cover this behavior.
+
+For member stream, the same default key can collide more easily because different providers/sessions can have one selected participant and similar first chunk ids. Do not change the task default blindly.
+
+Add a caller-provided key strategy:
+
+```ts
+buildSegmentRenderKey?: (segment: TSegment) => string;
+```
+
+Rules:
+
+- task stream default keeps current `participantKey:firstChunkId` behavior;
+- member stream passes a source-aware key such as `${segment.id}:${segment.chunks[0]?.id ?? segment.startTimestamp}`;
+- `segment.id` must already be path-safe and stable across refreshes;
+- tests should cover both task tail-growth state preservation and member source collision avoidance.
+
+#### C. Duplicate task stream UI into member component
+
+🎯 7 🛡️ 6 🧠 4
+Approx 180-320 LOC.
+
+Works quickly, but drift from task stream UI will grow and future variant 3 migration gets harder.
+
+### Decision
+
+Use option B.
+
+Risk rating:
+
+🎯 9 🛡️ 9 🧠 5
+Approx 160-300 LOC.
+
+## 0.4.4 Renderer Request Coalescing
+
+### Facts
+
+`TaskLogStreamSection` uses `requestSeqRef` to ignore stale responses. That prevents stale UI writes, but it does not prevent parallel IPC calls when visibility reload, `log-source-change`, and `task-log-change` happen close together.
+
+For task logs this is acceptable because the source is task-scoped. For member stream, one request can parse many files and may also call OpenCode runtime transcript. Parallel duplicate calls are a bigger performance risk.
+
+### Decision
+
+`MemberLogStreamSection` should coalesce active loads:
+
+- keep one active request for the current `teamName/memberName/laneId/options` key;
+- if a background reload is requested while active, set a pending reload flag instead of starting a second IPC call;
+- when the active request finishes, run at most one pending background reload;
+- if the component unmounts or key changes, ignore old results and clear pending reload state;
+- initial foreground load still shows loading, background reload keeps old stream visible.
+
+This complements backend/source in-flight protection. It is not a replacement for it, because multiple windows or tests can still call IPC directly.
+
+Risk rating:
+
+🎯 8.5 🛡️ 8.5 🧠 5
+Approx 80-160 LOC with tests.
+
+## 0.4.4.1 Since And Full-Response Replacement
+
+### Facts
+
+`getMemberLogStream` options include `since` as a useful backend performance hint. But the renderer state model is full response replacement: `setStream(response)`. A since-filtered response is partial unless the API explicitly says it includes enough prior segments to replace the visible stream.
+
+### Decision
+
+First PR should not do client-side incremental merge.
+
+- Initial load requests a full bounded stream.
+- Background reload requests a full bounded stream too.
+- `log-source-change` background reload adds `forceRefresh: true`, but not `since`.
+- `since` remains validated at IPC and covered in service tests, but UI should not use it for replacement reloads until there is an explicit merge contract.
+- If incremental reload is added later, response metadata must say whether it is `complete` or `partial`, and renderer must merge by source-aware segment id.
+
+Risk rating:
+
+🎯 8.5 🛡️ 9 🧠 3
+Approx 0-60 LOC, mostly tests and avoiding a tempting optimization.
+
+This prevents a subtle UI regression where fresh logs appear, but older visible segments disappear after a background reload.
+
+## 0.4.5 Member Popup Fallback Boundary
+
+### Facts
+
+`MemberDetailDialog` currently renders old logs directly:
+
+```tsx
+
+```
+
+The same old `MemberLogsTab` is also used by task logs through `ExecutionSessionsSection`, where it is labeled as legacy session-centric transcript browsing. That means changing `MemberLogsTab` itself is not a member-popup-only change.
+
+Existing dialog tests live at:
+
+- `test/renderer/components/team/members/MemberDetailDialog.test.ts`
+
+That test file mocks `MemberLogsTab`, so replacing the import without updating the mock can cause noisy test failures that are unrelated to the stream logic.
+
+### Options
+
+#### A. Replace `MemberLogsTab` internals with new stream
+
+🎯 5 🛡️ 5 🧠 4
+Approx 120-260 LOC.
+
+This looks small, but it affects task `Execution Sessions` too. It also removes the clean rollback path.
+
+#### B. Gate at `MemberDetailDialog` and keep `MemberLogsTab` untouched
+
+🎯 9 🛡️ 9 🧠 4
+Approx 80-180 LOC.
+
+This is the safest first implementation:
+
+- renderer feature gate on: render `MemberLogStreamSection`;
+- renderer feature gate off: render old `MemberLogsTab`;
+- initial stream error: show error and explicit old logs fallback;
+- background refresh error: keep last good stream and surface the error state unobtrusively.
+
+Task `Execution Sessions` remains untouched because it still imports `MemberLogsTab` directly.
+
+#### C. Let `MemberLogStreamSection` import and own `MemberLogsTab`
+
+🎯 6 🛡️ 6 🧠 5
+Approx 100-220 LOC.
+
+This hides fallback inside the new component, but makes the boundary muddier. It also makes it easier to accidentally couple legacy UI copy and new stream copy.
+
+### Decision
+
+Use option B.
+
+Implementation guardrails:
+
+- keep fallback decision in `MemberDetailDialog`, not inside the stream renderer;
+- do not change `MemberLogsTab` behavior in the first PR;
+- add a `MemberLogStreamSection` mock in `MemberDetailDialog` tests;
+- test gate-on and gate-off rendering;
+- test that first-load stream failure keeps the Logs tab active and shows explicit old logs fallback;
+- keep `ExecutionSessionsSection` expectations unchanged.
+
+Risk rating:
+
+🎯 9 🛡️ 9 🧠 4
+Approx 80-180 LOC.
+
+This reduces the highest accidental renderer regression risk without changing the backend design.
+
+## 0.5 Cumulative Subagent Snapshot Dedupe
+
+### Facts
+
+`findLogsForTask()` already has this warning in code:
+
+- in-process teammates can produce cumulative JSONL snapshots;
+- the largest file is a superset of smaller files;
+- task flow dedupes subagent snapshots by `sessionId + memberName` and keeps the largest `messageCount`.
+
+But `findRecentMemberLogFileRefsByMember()` currently returns:
+
+```ts
+interface MemberLogFileRef {
+ memberName: string;
+ sessionId: string;
+ filePath: string;
+ mtimeMs: number;
+}
+```
+
+It dedupes only exact `filePath`. That is not enough for member stream, because parsing multiple cumulative snapshots can duplicate the same turn several times.
+
+Another discovered edge case: attribution uses `knownMembers`, built from current config, member meta and inbox names. If the popup is opened for a historical/removed member that is no longer present in those sources, syntax-only IPC validation still will not make attribution recognize that name.
+
+### Options
+
+#### A. Use refs as-is and rely on chunk/message ids
+
+🎯 5 🛡️ 5 🧠 3
+Approx 0-80 LOC.
+
+Low implementation cost, but duplicate stream entries are likely in cumulative snapshot cases.
+
+#### B. Extend `MemberLogFileRef` with optional metadata and dedupe before parse
+
+🎯 8 🛡️ 8 🧠 5
+Approx 120-220 LOC.
+
+Add optional fields without breaking existing callers:
+
+```ts
+interface MemberLogFileRef {
+ memberName: string;
+ sessionId: string;
+ filePath: string;
+ mtimeMs: number;
+ sizeBytes?: number;
+ messageCount?: number;
+ kind?: 'lead_session' | 'member_session' | 'subagent';
+}
+```
+
+Then `ClaudeMemberTranscriptStreamSource` can:
+
+- group subagent refs by `memberName + sessionId`;
+- keep largest `messageCount` when available, otherwise largest `sizeBytes`, tie-break by newest `mtimeMs`;
+- keep root member session refs separately;
+- cap after dedupe, not before.
+
+Do not stream every candidate only to compute `messageCount` in first PR. Use it when a caller already has it; otherwise `sizeBytes` is the cheap proxy for cumulative JSONL snapshots.
+
+Also change `findRecentMemberLogFileRefsByMember()` so it adds requested member names to the attribution set before calling `getCachedSubagentAttribution()` / `getCachedMemberSessionAttribution()`.
+
+Suggested shape:
+
+```ts
+const attributionMembers = new Set(knownMembers);
+for (const key of requestedMembersByKey.keys()) {
+ attributionMembers.add(key);
+}
+```
+
+Use `attributionMembers` only for attribution. Keep the returned `memberName` as the caller's requested casing from `requestedMembersByKey`.
+
+#### C. Parse all refs, then dedupe by message uuid
+
+🎯 6 🛡️ 7 🧠 7
+Approx 180-350 LOC.
+
+More robust when metadata is missing, but expensive and can still miss duplicates if synthetic/tool result uuids differ.
+
+### Recommendation
+
+Use B.
+
+This is the most important correction to the earlier plan. Without it, variant 2 can be correct by attribution but noisy by duplication.
+
+Add the requested-member attribution augmentation to B. It is small, but it is what makes removed-member popup logs actually possible instead of only syntactically allowed.
+
+## 0.6 OpenCode Projection Mapper Boundary
+
+### Facts
+
+`ClaudeMultimodelBridgeService.getOpenCodeTranscript()` returns `transcript.logProjection.messages`.
+
+`OpenCodeTaskLogStreamSource` already has working private mapping from `OpenCodeRuntimeTranscriptLogMessage` to `ParsedMessage`:
+
+- content block conversion;
+- tool call/result conversion;
+- `sourceToolUseID`;
+- `sourceToolAssistantUUID`;
+- `toolUseResult`;
+- sanitized text content.
+
+There is also a separate `toParsedMessage()` in `OpenCodeTaskStallEvidenceSource`, but it is not equivalent. It maps non-string content to `[]` and does not build `toolUseResult`. That is acceptable for stall evidence rows, but not for a user-visible log stream.
+
+But that file also contains task-specific logic:
+
+- task marker matching;
+- task windows;
+- attribution records;
+- foreign team marker filtering;
+- task fallback heuristics.
+
+### Options
+
+#### A. Copy the private mapper into member source
+
+🎯 7 🛡️ 6 🧠 4
+Approx 150-220 LOC.
+
+Fast, but mapper drift is likely.
+
+#### B. Extract generic mapper and make both task/member sources use it
+
+🎯 8.5 🛡️ 8.5 🧠 6
+Approx 180-300 LOC.
+
+Create a small file near stream sources, for example:
+
+```ts
+// src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts
+export function mapOpenCodeProjectionMessagesToParsedMessages(
+ messages: OpenCodeRuntimeTranscriptLogMessage[]
+): ParsedMessage[];
+
+export function countProjectionToolCalls(messages: ParsedMessage[]): ProjectionToolCounts;
+```
+
+Only move generic projection conversion. Do not move task marker/window logic.
+
+Extraction source must be `OpenCodeTaskLogStreamSource`, not `OpenCodeTaskStallEvidenceSource`.
+
+Implementation detail:
+
+- first move the task-source mapper to the shared file without behavior changes;
+- update `OpenCodeTaskLogStreamSource` to call the shared mapper;
+- then let `OpenCodeMemberRuntimeStreamSource` call the same mapper;
+- leave stall monitor as-is unless a separate test-backed cleanup proves identical behavior is intended there.
+
+#### C. Reuse `OpenCodeTaskLogStreamSource`
+
+🎯 3 🛡️ 3 🧠 4
+Approx 80-160 LOC.
+
+Wrong abstraction. It is task-scoped by design.
+
+### Recommendation
+
+Use B. This is a small refactor, but it reduces long-term drift and gives member stream the exact same OpenCode rendering semantics as task stream.
+
+Important: do not use the stall-monitor mapper as the shared base. That would make the implementation look shared while silently degrading member stream content rendering.
+
+## 0.6.1 OpenCode Runtime Call Budget
+
+### Facts
+
+`OpenCodeTaskLogStreamSource` already protects task stream fallback with:
+
+- a small cache keyed by task/window/attribution;
+- an `inFlight` map so concurrent calls join the same bridge request;
+- `CACHE_TTL_MS = 1_500`;
+- transcript limits of `200` for heuristic and `500` for attributed mode.
+
+Member stream will reload on same-team `log-source-change` and `task-log-change`. Even with debounce, those events can arrive close together while a popup is open. Calling `runtime transcript` for every refresh can make the popup feel slow and can add unnecessary process churn.
+
+### Options
+
+#### A. No OpenCode member-source cache
+
+🎯 7 🛡️ 6 🧠 2
+Approx 0-40 LOC.
+
+Simple, but repeated refreshes can spawn repeated bridge calls. Timeout protects the popup from hanging, but not from extra work.
+
+#### B. Add member-source TTL cache and in-flight join
+
+🎯 8.5 🛡️ 8.5 🧠 4
+Approx 80-160 LOC.
+
+Use a cache key like:
+
+```txt
+teamName::memberName::laneId-or-none::limit
+```
+
+Rules:
+
+- default TTL `1_500ms`, matching task OpenCode fallback;
+- `forceRefresh` can bypass completed cache, but should still join an existing in-flight request for the same key;
+- cache both `null` and successful responses briefly, because repeated failures should not spawn repeated CLI calls;
+- do not share this cache with `OpenCodeTaskLogStreamSource`;
+- keep timeout handling inside the bridge/source, not renderer.
+
+#### C. Add a long cache in `ClaudeMultimodelBridgeService`
+
+🎯 5 🛡️ 5 🧠 6
+Approx 120-240 LOC.
+
+This centralizes caching but risks changing behavior for status/stall/task callers that use the same bridge for different freshness expectations.
+
+### Decision
+
+Use option B.
+
+This keeps OpenCode member stream responsive without changing bridge semantics for other callers.
+
+Risk rating:
+
+🎯 8.5 🛡️ 8.5 🧠 4
+Approx 80-160 LOC.
+
+## 0.7 Live Refresh Policy
+
+### Facts
+
+`TeamChangeEvent` has:
+
+- `log-source-change` without task id;
+- `task-log-change` with `taskId`, but no `memberName`;
+- `tool-activity`, which can be frequent and is intended for live tool indicators;
+- no event that means "logs for member X changed".
+
+Task stream can reload only for one `taskId`. Member stream cannot do that safely.
+
+Additional code research: task logs do not rely on `onTeamChange` subscription alone.
+
+- `TaskLogStreamSection` listens to `onTeamChange` and schedules reloads.
+- `TaskLogsPanel` separately calls `api.teams.setTaskLogStreamTracking(teamName, true)` while task log activity tracking is relevant.
+- That IPC maps to `TeamLogSourceTracker.enableTracking(teamName, 'task_log_stream')`.
+- `TeamLogSourceTracker` only watches transcript/log freshness sources while at least one consumer is active.
+- Member popup has no `TaskLogsPanel` parent, so just adding `onTeamChange` in `MemberLogStreamSection` can miss fresh events when no other UI has enabled tracking.
+
+### Decision
+
+For an open member popup:
+
+- enable log-source tracking while the stream section is mounted;
+- reload on same-team `log-source-change`;
+- pass `forceRefresh: true` for `log-source-change`, because `TeamMemberLogsFinder` discovery cache has a 30s TTL;
+- do not pass `since` from renderer for replacement reloads in the first PR;
+- reload on same-team `task-log-change`, because it is still a log-source freshness signal;
+- do not reload on `tool-activity`;
+- debounce at least as strongly as task stream, preferably 500-750ms for member stream;
+- do not clear existing stream on background refresh failure;
+- reload on visibility return only if popup is still open.
+
+Risk rating:
+
+🎯 8 🛡️ 8 🧠 4
+Approx 40-80 LOC.
+
+This may reload for tasks owned by other members, but only while the member popup is open and with debounce. That is safer than missing fresh logs because `task-log-change` lacks member attribution.
+
+### Tracking Activation Options
+
+#### A. Reuse `setTaskLogStreamTracking()` from member popup
+
+🎯 8 🛡️ 7 🧠 2
+Approx 20-50 LOC.
+
+This works because it increments the same `task_log_stream` consumer count. The downside is semantic drift: member UI would call a task-named API.
+
+#### B. Add `setMemberLogStreamTracking()` mapped to a new tracker consumer
+
+🎯 8.5 🛡️ 9 🧠 4
+Approx 90-170 LOC.
+
+Add:
+
+- feature `MEMBER_LOG_STREAM_SET_TRACKING` channel;
+- feature API method or thin compatibility delegate;
+- feature preload bridge and browser no-op fallback;
+- `TeamLogSourceTrackingConsumer | 'member_log_stream'`;
+- IPC handler that calls `TeamLogSourceTracker.enableTracking(teamName, 'member_log_stream')`.
+
+This keeps UI language correct and lets task/member stream lifecycles have separate consumer counts while sharing the same underlying watcher.
+
+Important implementation detail from code:
+
+- `TeamLogSourceTracker` consumer counts are per team and consumer name, not per member.
+- That is correct for member stream because the watcher scope is team/session-level.
+- Do not add one watcher per member popup. Add one `member_log_stream` consumer and rely on reference counts for multiple popups.
+- If the component remounts with a different `teamName`, cleanup must disable the previous team before enabling the next team.
+- Disabling `member_log_stream` must not close the watcher while `task_log_stream`, `change_presence`, `tool_activity` or `stall_monitor` still has a positive count.
+
+#### C. Do not enable tracking from member popup
+
+🎯 5 🛡️ 4 🧠 1
+Approx 0 LOC.
+
+This is unreliable. It only works when another part of the UI has already enabled log-source tracking.
+
+### Tracking Decision
+
+Use B.
+
+It adds a little transport work, but it removes a hidden dependency on `TaskLogsPanel` and avoids calling task-named APIs from member logs.
+
+## 0.8 IPC And Composition Integration
+
+### Facts
+
+`src/main/ipc/teams.ts` currently has many positional dependencies and already owns legacy team APIs. That is exactly why member log stream should not be added there as another owned service.
+
+Current legacy facts still matter:
+
+- `src/main/ipc/teams.ts` owns existing team handlers;
+- task stream still uses `TEAM_GET_TASK_LOG_STREAM`;
+- `src/preload/index.ts` and `src/renderer/api/httpClient.ts` already expose legacy team API fallbacks;
+- `src/main/index.ts` is the composition point where feature facades can be instantiated.
+
+### Options
+
+#### A. Feature-owned IPC and composition
+
+🎯 8.5 🛡️ 9 🧠 4
+Approx 80-180 LOC for transport wiring.
+
+This is the correct first PR choice after adopting the canonical feature slice.
+
+Implementation rule:
+
+```ts
+const memberLogStreamFeature = createMemberLogStreamFeature({
+ logsFinder,
+ logSourceTracker,
+ runtimeBridge,
+ logger,
+});
+
+registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature);
+```
+
+Feature-owned files:
+
+- `src/features/member-log-stream/contracts/channels.ts`;
+- `src/features/member-log-stream/contracts/api.ts`;
+- `src/features/member-log-stream/preload/createMemberLogStreamBridge.ts`;
+- `src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts`;
+- `src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts`.
+
+Test rule:
+
+- feature IPC registration test expects feature channel registration/removal;
+- app-shell integration test imports only `@features/member-log-stream/main`;
+- existing task stream handler tests remain unchanged and prove legacy team IPC was not disturbed.
+
+#### B. Add member stream into `initializeTeamHandlers()`
+
+🎯 5 🛡️ 4 🧠 2
+Approx 30-70 LOC.
+
+This is no longer the recommended path. It is locally cheap, but it violates feature ownership and risks shifting positional dependencies in `initializeTeamHandlers()`.
+
+Do not choose this.
+
+#### C. Convert legacy team initializer to a dependency object
+
+🎯 7 🛡️ 9 🧠 7
+Approx 250-450 LOC.
+
+This is a useful legacy IPC hygiene refactor, but it is separate from member log stream. It should not be bundled unless the PR explicitly pays that cost.
+
+### Required Feature Handler Contract
+
+Add feature-owned channels, for example:
+
+```ts
+export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream';
+export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking';
+```
+
+Add register/remove symmetry in feature input adapter:
+
+```ts
+ipcMain.handle(MEMBER_LOG_STREAM_GET, handleGetMemberLogStream);
+ipcMain.handle(MEMBER_LOG_STREAM_SET_TRACKING, handleSetMemberLogStreamTracking);
+ipcMain.removeHandler(MEMBER_LOG_STREAM_GET);
+ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING);
+```
+
+Add feature API and preload shape:
+
+```ts
+getMemberLogStream(
+ teamName: string,
+ memberName: string,
+ options?: {
+ limitSegments?: number;
+ since?: string;
+ laneId?: string;
+ forceRefresh?: boolean;
+ }
+): Promise
+```
+
+Handler behavior:
+
+- validate `teamName` with `validateTeamName`;
+- validate `memberName` with `validateMemberName`, but do not require current team membership;
+- validate `laneId` with the new runtime-lane validator, not with `validateMemberName`;
+- clamp `limitSegments` before passing options to service;
+- reject invalid `since`;
+- validate `forceRefresh` as boolean when present;
+- reject unknown option keys;
+- call the feature facade/use case with normalized options.
+
+Validator placement note:
+
+- `src/main/ipc/guards.ts` keeps `ValidationResult` local today;
+- safest implementation is to export concrete helpers like `validateOptionalRuntimeLaneId()` and `validateOptionalBooleanOption()` from `guards.ts`;
+- if validators stay local in the feature input adapter, do not import a non-exported `ValidationResult` type.
+
+### Browser Fallback
+
+The renderer API/browser fallback must satisfy the feature API even when Electron IPC is unavailable. If `api.teams` compatibility delegates are kept, they must delegate to the same fallback shape.
+
+Return a complete empty response:
+
+```ts
+{
+ participants: [],
+ defaultFilter: 'all',
+ segments: [],
+ source: 'member_empty',
+ coverage: [],
+ warnings: [],
+ truncated: false,
+ generatedAt: new Date().toISOString(),
+ metadata: {
+ scannedTranscriptFileCount: 0,
+ includedTranscriptFileCount: 0,
+ droppedSegmentCount: 0,
+ droppedChunkCount: 0,
+ droppedMessageCount: 0,
+ },
+}
+```
+
+Do not throw in browser fallback. Throwing would make renderer code need an Electron-only branch even though task stream already uses a safe empty fallback.
+
+### Recommendation
+
+Use option A in the implementation PR.
+
+🛡️ The main bug risk is not the new stream logic here. It is accidentally pulling the feature back into legacy `team` IPC/service ownership and weakening the feature boundary.
+
+## 1. Claude Transcript Attribution
+
+### Facts
+
+`TeamMemberLogsFinder` уже содержит лучший source для member stream:
+
+```ts
+findRecentMemberLogFileRefsByMember(
+ teamName: string,
+ memberNames: readonly string[],
+ mtimeSinceMs?: number | null
+): Promise
+```
+
+Дополнительный compatibility факт: этот method уже используется не только будущим member stream.
+
+- `TeamMemberRuntimeAdvisoryService` вызывает его с третьим positional numeric arg.
+- Existing live tests вызывают его с `null` и numeric `mtimeSinceMs`.
+- `discoverProjectSessions(teamName, { forceRefresh })` уже поддерживает cache bypass.
+- `getLogSourceWatchContext()` и `getLiveLogSourceWatchContext()` уже передают `forceRefresh` в discovery path.
+- Current method calls `discoverProjectSessions(teamName)` without options, so member stream needs object-form options only to add `forceRefresh`.
+- Current lead transcript branch pushes the lead ref after `fs.stat()` but before any `mtimeSinceMs` check.
+- Current refs have only `memberName`, `sessionId`, `filePath` and `mtimeMs`.
+
+Значит для member stream нельзя делать breaking object-only замену третьего аргумента.
+
+Нужен backward-compatible options parser:
+
+```ts
+type FindRecentMemberLogFileRefsOptions =
+ | number
+ | null
+ | {
+ mtimeSinceMs?: number | null;
+ forceRefresh?: boolean;
+ };
+```
+
+Implementation rule:
+
+- numeric third arg остается `mtimeSinceMs`;
+- `null` остается "без mtime window";
+- object third arg включает `mtimeSinceMs` и `forceRefresh`;
+- only object form can bypass discovery cache;
+- existing advisory callers do not need changes.
+
+Он возвращает:
+
+```ts
+{
+ memberName: string;
+ sessionId: string;
+ filePath: string;
+ mtimeMs: number;
+ kind?: 'lead_session' | 'member_session' | 'subagent';
+ sizeBytes?: number;
+ messageCount?: number;
+}
+```
+
+Metadata rule:
+
+- `kind` and `sizeBytes` are cheap and should be added by the finder;
+- `messageCount` is optional and should be filled only when existing attribution/session metadata already knows it;
+- do not parse full JSONL files in `TeamMemberLogsFinder` just to compute `messageCount`, because `ClaudeMemberTranscriptStreamSource` will strict-parse the selected refs afterward.
+
+Почему это лучше, чем `findMemberLogPaths()`:
+
+- уже сортирует refs по `mtimeMs desc`;
+- дедуплицирует `filePath`;
+- возвращает `sessionId`, который нужен для stable segment id;
+- умеет lead transcript;
+- использует ту же attribution precedence, что и старые member logs;
+- принимает `mtimeSinceMs`, что дает cheap performance window.
+
+### Correct Implementation
+
+🎯 9 🛡️ 9 🧠 4
+Примерно 120-220 LOC для Claude-only member stream service.
+
+Service должен:
+
+1. Вызывать `findRecentMemberLogFileRefsByMember(teamName, [memberName], { mtimeSinceMs: sinceMs, forceRefresh })`.
+2. Dedupe cumulative subagent refs before cap, using `kind + memberName + sessionId`, then `messageCount`, `sizeBytes`, `mtimeMs`.
+3. Сразу применять cap по ref count после dedupe: default `maxTranscriptFiles = 40`.
+4. Парсить только capped refs через отдельный `BoardTaskExactLogStrictParser`.
+5. Для каждого ref строить отдельный segment.
+6. Ограничивать total chunks глобальным budget, default `maxChunks = 250`.
+7. Сортировать response segments по `startTimestamp asc`, чтобы renderer мог reverse как task stream.
+8. Возвращать warning `large_log_window_limited`, если refs/chunks больше cap.
+
+### Risks
+
+- Если member был переименован, finder ищет только переданное имя.
+- Если `knownMembers` не содержит historical removed member, attribution может не найти старые logs.
+- Если заменить third arg object-only, можно сломать runtime advisory и live tests, которые уже передают numeric/null.
+- В текущем коде lead transcript добавляется до candidate scan и не проходит `mtimeSinceMs` filter.
+- Если finder начнет считать `messageCount` через full parse, он удвоит IO и сделает popup тяжелее на больших историях.
+- Text mention остается low-confidence fallback. Это уже существующий риск, не новый.
+
+### Mitigation
+
+- Не требовать current config membership в IPC handler.
+- В plan добавить future alias map, но не делать в v2.
+- В coverage явно писать `claude_transcript included/partial`.
+- Расширять third arg finder-а через compatibility parser, а не менять его на object-only.
+- При object/numeric `mtimeSinceMs` применять time window и к lead transcript, и к member/subagent candidates.
+- Если lead transcript старше `mtimeSinceMs`, не возвращать его в recent refs.
+- Добавить tests на numeric, `null` и object options формы.
+- Добавить tests, что `forceRefresh` передается в `discoverProjectSessions()` только через object form.
+- Добавить tests, что optional metadata не ломает existing advisory callers и не требует full parse внутри finder.
+
+## 2. OpenCode Lane Resolution
+
+### Facts
+
+В репо уже есть deterministic lane identity:
+
+```ts
+buildPlannedMemberLaneIdentity({
+ leadProviderId,
+ member,
+})
+```
+
+Для mixed team с non-OpenCode lead и OpenCode member lane становится:
+
+```txt
+secondary:opencode:
+```
+
+`TeamMemberResolver` уже кладет в `TeamMemberSnapshot`:
+
+- `laneId`;
+- `laneKind`;
+- `laneOwnerProviderId`.
+
+Renderer получает resolved member через spread snapshot:
+
+```ts
+return {
+ ...snapshot,
+ status,
+ messageCount,
+ lastActiveAt,
+}
+```
+
+Но `ResolvedTeamMember` type сейчас явно не содержит runtime/lane fields, хотя фактический объект их несет. Это хрупко для новой реализации.
+
+Дополнительный code research:
+
+- `src/shared/types/team.ts` объявляет `TeamMemberSnapshot.laneId/laneKind/laneOwnerProviderId`;
+- `src/shared/types/team.ts` также объявляет `TeamMemberSnapshot.providerBackendId/selectedFastMode/resolvedFastMode`;
+- `src/shared/types/team.ts` не объявляет эти поля на `ResolvedTeamMember`;
+- `src/renderer/store/slices/teamSlice.ts` делает `return { ...snapshot, status, messageCount, lastActiveAt }`, поэтому runtime object уже несет lane fields;
+- renderer code уже обращается к lane fields в некоторых utility paths, но без полноценного `ResolvedTeamMember` contract это легко превращается в касты и drift.
+
+### Correct Implementation
+
+🎯 8.5 🛡️ 9 🧠 4
+Примерно 180-320 LOC.
+
+Нужно сделать так:
+
+1. Расширить `ResolvedTeamMember` type полями:
+ - `providerBackendId?: TeamProviderBackendId`;
+ - `selectedFastMode?: TeamFastMode`;
+ - `resolvedFastMode?: boolean`;
+ - `laneId?: string`;
+ - `laneKind?: 'primary' | 'secondary'`;
+ - `laneOwnerProviderId?: TeamProviderId`.
+ Data mapping almost does not change, because `buildResolvedMember()` already spreads `snapshot`.
+2. Расширить `getMemberLogStream` options:
+
+```ts
+{
+ limitSegments?: number;
+ since?: string;
+ laneId?: string;
+}
+```
+
+3. В `MemberLogStreamSection` передавать `member.laneId`, если:
+ - `member.providerId === 'opencode'`;
+ - `member.laneOwnerProviderId === 'opencode'`;
+ - `member.laneId` непустой.
+ Не передавать `laneId` для non-OpenCode members, даже если в старом snapshot случайно остался lane-like field.
+4. В `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` добавить optional `laneId`.
+5. В тот же params добавить optional `timeoutMs`, чтобы member popup не зависал на provider probe path.
+6. Bridge должен append:
+
+```ts
+if (params.laneId?.trim()) {
+ args.push('--lane', params.laneId.trim());
+}
+```
+
+7. Если `laneId` неизвестен, можно сделать best-effort no-lane call, но только с catch ambiguity error.
+8. Ambiguity превращать в warning `opencode_ambiguous_lane`, не в hard failure.
+9. Timeout/runtime missing превращать в `opencode_runtime_timeout` или `opencode_runtime_unavailable`, не в hard failure.
+10. Add a small member-source TTL cache and in-flight join around OpenCode runtime transcript calls.
+
+### Why This Is Safer
+
+Orchestrator уже защищает от unsafe resolution:
+
+```txt
+Multiple OpenCode session records exist ... pass --lane to select one
+```
+
+Desktop должен уважать это, а не обходить.
+
+### OpenCode Member Stream Semantics
+
+Для member stream нельзя использовать task-specific logic из `OpenCodeTaskLogStreamSource`:
+
+- не применять task marker spans;
+- не применять task work intervals;
+- не фильтровать по task owner;
+- не читать task attribution records как основной source.
+
+Правильный member OpenCode segment:
+
+- получить projection messages за выбранный member/lane;
+- cap по `limit`, default `openCodeMessageLimit = 400`;
+- cap по времени, default `openCodeTimeoutMs = 5_000`, потому что текущий bridge path использует `execCli` timeout and can otherwise delay the popup;
+- build chunks через `BoardTaskExactLogChunkBuilder`;
+- segment id включает `teamName`, `laneId`, `memberName`, `sessionId`;
+- source coverage: `opencode_runtime included`;
+- если projection пустой: `opencode_runtime skipped`.
+
+Implementation detail:
+
+- не использовать `OpenCodeTaskLogStreamSource` напрямую, потому что он task-specific;
+- вынести reusable projection-to-ParsedMessage helpers из `OpenCodeTaskLogStreamSource` в small mapper, если без копипаста не обойтись;
+- покрыть старый task source тестами после extraction, чтобы не сломать task stream.
+
+### Risks
+
+- `ResolvedTeamMember` type drift может скрыть runtime/lane fields от TS.
+- Старые teams могут не иметь lane metadata.
+- OpenCode primary-only teams могут использовать `laneId: primary`.
+- Runtime transcript может быть stale или пустым.
+
+### Mitigation
+
+- API принимает laneId от renderer, но main может recompute fallback lane identity later.
+- Для v2 достаточно renderer laneId + safe no-lane fallback.
+- Не merge multiple lanes.
+- Не показывать OpenCode как complete, если он skipped/partial.
+
+## 3. Codex Native Member-wide Feasibility
+
+### Facts
+
+`CodexNativeTraceReader` хранит traces здесь:
+
+```txt
+.member-work-sync/runtime-hooks/codex-native-traces/
+ processed///*.jsonl
+ incoming///*.jsonl.tmp
+```
+
+Header содержит:
+
+- `teamName`;
+- `taskId`;
+- `ownerName`;
+- `runId`;
+- `cwd`;
+- `startedAt`.
+
+Значит member filtering по `ownerName` технически возможен.
+
+Но текущий API:
+
+```ts
+readTaskRuns({ teamName, taskIds, includeIncoming })
+```
+
+task-first. Он не умеет:
+
+- перечислить все task dirs для team;
+- выбрать последние N runs по ownerName;
+- ограничить scan без taskId;
+- возвращать member-wide coverage.
+
+Current implementation detail:
+
+- It only lists `processed//` and optional `incoming//`.
+- It caps latest candidates after task-dir collection, currently to 10 files.
+- It dedupes by `team/task/runId`, preferring non-partial/newer candidates.
+- That strategy is safe for a known task id, but not enough for member-wide history because there is no bounded owner index.
+
+Еще важнее: `CodexNativeTraceProjector` проектирует только native tool events, а не полный разговор Codex. То есть даже если сделать member-wide reader, это будет "Codex native tool trace", а не полный Codex log stream.
+
+### Options
+
+#### A. Keep Codex skipped in first implementation
+
+🎯 9 🛡️ 9 🧠 2
+Примерно 20-40 LOC для coverage warning.
+
+Плюсы:
+
+- самый надежный первый релиз;
+- нет риска тяжелого scan по trace tree;
+- честное product обещание.
+
+Минусы:
+
+- Codex участники не получат новый native trace в member popup.
+
+#### B. Add partial Codex native tool trace as phase 2
+
+🎯 7 🛡️ 7 🧠 7
+Примерно 250-450 LOC.
+
+Новый метод:
+
+```ts
+readMemberRuns({
+ teamName,
+ ownerName,
+ includeIncoming,
+ limitRuns,
+ maxTaskDirs,
+})
+```
+
+Алгоритм:
+
+1. Resolve trace root.
+2. List `processed/` task dirs and optional `incoming/` task dirs.
+3. Collect jsonl/jsonl.tmp candidates with stat.
+4. Sort by `mtimeMs desc`.
+5. Cap candidates before full parse.
+6. Parse headers/runs.
+7. Filter normalized `ownerName`.
+8. Deduplicate by `team/task/runId`, prefer non-partial and newer mtime.
+9. Project via `CodexNativeTraceProjector`.
+10. Return segment with source label `codex_native_trace_partial`.
+
+Additional required caps:
+
+- `maxTaskDirs` before candidate collection;
+- `maxTraceCandidates` before full parse;
+- `maxTraceRuns` after owner filtering/dedupe;
+- default `includeIncoming: false` unless the member is currently active or the UI explicitly requests live partial files.
+
+Минусы:
+
+- scanning all task dirs может быть тяжелым;
+- trace может быть native-tool-only и выглядеть неполным;
+- incoming partial files требуют аккуратного JSONL handling.
+- scanning by owner without an index can become O(number of tasks), so it should not ride inside the first PR.
+
+#### C. Full Codex member-wide log support
+
+🎯 5 🛡️ 5 🧠 9
+Примерно 700-1200+ LOC.
+
+Нужно строить отдельный index по Codex runtime/session events, не только native tool traces. Это уже вариант 3.
+
+### Recommendation
+
+Для варианта 2:
+
+- first PR: Codex skipped with explicit `codex_member_wide_not_supported`;
+- optional second PR: partial native tool trace with honest UI label;
+- do not present Codex native trace as full logs.
+
+## 4. Parser Cache Safety
+
+### Facts
+
+`BoardTaskExactLogStrictParser.parseFiles()` делает:
+
+```ts
+this.cache.retainOnly(new Set(uniquePaths));
+```
+
+`BoardTaskExactLogsParseCache` delegates to `BoardTaskActivityParseCache`, whose `retainOnly()` removes both:
+
+- parsed cache entries;
+- active `inFlight` parse promises outside the retained file set.
+
+Это значит: если один service instance парсит task files, а другой потом member files на том же parser instance, второй вызов может вычистить не только cache первого, но и его active parse dedupe. В худшем случае это не ломает correctness напрямую, но создает duplicate reads, timing-sensitive cache churn и flaky performance под параллельными reload.
+
+### Correct Implementation
+
+🎯 9 🛡️ 9 🧠 3
+Примерно 20-60 LOC.
+
+Для v2:
+
+- `ClaudeMemberTranscriptStreamSource` получает собственный `BoardTaskExactLogStrictParser`.
+- Не шарить parser instance с `BoardTaskLogStreamService`.
+- Не инжектить task-stream parser в тестах как "удобный shared mock", потому что это маскирует ownership rule.
+- Если понадобится общий parser, сначала redesign cache API: `retainOnly()` должен стать owner-scoped или LRU-based.
+
+Future improvement:
+
+- заменить `retainOnly()` на bounded LRU cache;
+- либо добавить namespace/owner key для task/member cache ownership;
+- тогда task/member services смогут безопасно шарить parser.
+
+Но это не нужно для первого релиза.
+
+## 5. API Shape Refinement
+
+### Previous Draft
+
+Изначально было достаточно:
+
+```ts
+getMemberLogStream(teamName, memberName, options?)
+```
+
+### Updated API
+
+После research лучше зафиксировать:
+
+```ts
+getMemberLogStream(
+ teamName: string,
+ memberName: string,
+ options?: {
+ limitSegments?: number;
+ since?: string;
+ laneId?: string;
+ forceRefresh?: boolean;
+ }
+): Promise
+```
+
+Почему `laneId` в options:
+
+- renderer уже знает выбранного member object;
+- это самый дешевый и точный path для OpenCode secondary lanes;
+- main все равно валидирует strings;
+- если поля нет, main может fallback to no-lane best-effort.
+
+Почему `forceRefresh` в options:
+
+- `discoverProjectSessions()` кэширует discovery на 30 секунд;
+- same-team `log-source-change` означает, что session ids/source context could have changed;
+- renderer can pass `forceRefresh: true` only for that event path;
+- regular task-log-change reloads can keep the cache.
+
+Почему не передавать весь member:
+
+- меньше IPC surface;
+- меньше drift между renderer snapshot и main truth;
+- no need to trust UI for provider selection.
+
+## 6. Correct First Implementation Sequence
+
+Updated safest order:
+
+1. Create `src/features/member-log-stream` with contracts, core/domain, core/application, main composition, preload bridge and renderer public entrypoints.
+2. Add feature-owned `MemberLogStreamResponse`, `MemberLogStreamSegment.source`, response metadata, warnings and `ResolvedTeamMember` runtime/lane fields. Do not extend or broaden `BoardTaskLogStreamResponse.source`.
+3. Add feature IPC channels, feature preload methods, browser fallback and optional compatibility methods on `api.teams` for `getMemberLogStream` plus `setMemberLogStreamTracking`.
+4. Add IPC validation helpers in the feature input adapter or shared guards, including exact-preserving optional `laneId` validation. Register feature handlers append-only without shifting existing `initializeTeamHandlers()` positional dependencies.
+5. Add core source-port interfaces and budget constants.
+6. Extend `MemberLogFileRef` with optional `kind`/`sizeBytes`/`messageCount`, add backward-compatible `findRecentMemberLogFileRefsByMember()` third-arg options parsing, apply `mtimeSinceMs` to lead refs, augment requested members into attribution, keep `messageCount` cheap-only, then add ref dedupe tests.
+7. Add `ClaudeMemberTranscriptStreamSource` using `findRecentMemberLogFileRefsByMember`, ref dedupe and dedicated parser instance owned by member stream.
+8. Extract provider-neutral `ParsedMessage` hygiene helpers and keep board/task JSON cleanup scoped.
+9. Add pair-aware message-window trimming and content-size truncation before chunk build.
+10. Extract `OpenCodeRuntimeProjectionMapper` from `OpenCodeTaskLogStreamSource` without moving task-window logic. Do not use the narrower stall-monitor mapper as the base.
+11. Add `GetMemberLogStreamUseCase` with merge, sort, budget, truncation and identical active request join layer.
+12. Add source-local OpenCode member TTL cache and in-flight join.
+13. Export/instantiate the feature facade in main composition and register/remove the new IPC handler.
+14. Add renderer feature `ExecutionLogStreamView` render-only extraction with shape-preserving normalization and caller-provided segment key strategy.
+15. Add `MemberLogStreamSection` with tracking activation, reload coalescing, actor/source identity separation and without importing old `MemberLogsTab`.
+16. Wire `MemberDetailDialog` gate-on/gate-off boundary with old `MemberLogsTab` fallback, importing only `@features/member-log-stream/renderer`.
+17. Add OpenCode bridge `laneId` and `timeoutMs`.
+18. Add `OpenCodeMemberRuntimeStreamSource` using the shared mapper, while leaving stall-monitor mapper migration as a separate optional cleanup.
+19. Add coverage/warnings and backend truncation semantics.
+20. Keep Codex skipped in first PR.
+21. Optional follow-up PR: `CodexNativeTraceReader.readMemberRuns`.
+
+## 7. Revised Risk Ratings
+
+| Risk | Before | After research | Notes |
+| --- | ---: | ---: | --- |
+| Showing another member's Claude logs | 🛡️ 7 | 🛡️ 8 | Existing finder has strong attribution precedence |
+| OpenCode wrong lane | 🛡️ 6 | 🛡️ 8 | Pass laneId from snapshot, respect orchestrator ambiguity |
+| Codex completeness claim | 🛡️ 5 | 🛡️ 8 | Mark skipped/partial honestly |
+| Codex member-wide scan cost | 🛡️ 4 | 🛡️ 9 | First PR does not scan task-keyed trace tree for member-wide owner history |
+| Parser cache regression | 🛡️ 6 | 🛡️ 9 | Dedicated parser instance avoids parsed cache eviction |
+| Parser in-flight eviction | 🛡️ 5 | 🛡️ 9 | Same ownership rule avoids deleting another stream's active parse dedupe |
+| UI copy drift from task stream | 🛡️ 7 | 🛡️ 8 | Generic view extraction avoids duplicated renderer logic |
+| Popup freeze on large history | 🛡️ 4 | 🛡️ 8 | Backend budget avoids rendering audit-sized logs |
+| Architecture drift | 🛡️ 5 | 🛡️ 9 | Canonical feature slice plus source ports keeps provider logic isolated and follows repo standard |
+| Feature boundary deep imports | 🛡️ 5 | 🛡️ 9 | Generic `src/features/*` lint guard rails plus public-entrypoint imports protect Clean Architecture direction |
+| IPC lane validation | 🛡️ 6 | 🛡️ 8.5 | Dedicated lane validator handles `secondary:opencode:*` safely without rewriting exact ids |
+| Cumulative subagent duplicates | 🛡️ 5 | 🛡️ 8 | Ref metadata plus pre-parse dedupe avoids repeated turns |
+| OpenCode mapper drift | 🛡️ 6 | 🛡️ 8.5 | Shared projection mapper avoids copy-pasted conversion |
+| Wrong OpenCode mapper base | 🛡️ 5 | 🛡️ 9 | Extract from task source, not from the narrower stall-monitor mapper |
+| Finder metadata overreach | 🛡️ 5 | 🛡️ 8 | `kind`/`sizeBytes` stay cheap; no full parse just for `messageCount` |
+| Oversized single segment | 🛡️ 4 | 🛡️ 8 | Message budget catches huge chunks that `maxChunks` cannot |
+| Live refresh misses | 🛡️ 6 | 🛡️ 8 | Same-team `task-log-change` reload covers task freshness signals |
+| IPC dependency shift | 🛡️ 5 | 🛡️ 8 | Append-only service injection avoids breaking positional handler setup |
+| Browser fallback compile drift | 🛡️ 6 | 🛡️ 8 | Full empty response keeps feature API and any compatibility delegate satisfied outside Electron |
+| Removed member attribution | 🛡️ 5 | 🛡️ 8 | Requested member names are added to finder attribution set |
+| Finder options compatibility | 🛡️ 6 | 🛡️ 9 | Backward-compatible third-arg parser keeps advisory and live tests stable |
+| Lead mtime window bypass | 🛡️ 6 | 🛡️ 9 | Apply `mtimeSinceMs` to lead transcript before pushing lead ref |
+| Mixed source context loss | 🛡️ 6 | 🛡️ 8 | Member segment metadata gives UI safe provider/session labels |
+| Segment key collisions | 🛡️ 6 | 🛡️ 8.5 | Generic view supports task default key and member source-aware override |
+| OpenCode popup delay | 🛡️ 5 | 🛡️ 8 | Popup-specific timeout and fail-soft source handling avoid blocking Claude transcript logs |
+| Renderer duplicate reloads | 🛡️ 5 | 🛡️ 8.5 | Member section coalesces active loads and runs at most one pending reload |
+| Live tracking not activated | 🛡️ 4 | 🛡️ 9 | Dedicated `setMemberLogStreamTracking()` activates `TeamLogSourceTracker` while popup stream is mounted |
+| Tracking consumer leak | 🛡️ 5 | 🛡️ 8.5 | Team-level `member_log_stream` consumer uses existing reference counting and cleanup tests |
+| Stale discovery after launch | 🛡️ 5 | 🛡️ 8 | `forceRefresh` on `log-source-change` bypasses 30s finder discovery cache |
+| Huge single tool output | 🛡️ 4 | 🛡️ 8 | Content-char budgets protect renderer beyond message/chunk count |
+| Renderer Date shape drift | 🛡️ 6 | 🛡️ 8.5 | HTTP client already revives ISO dates, remaining risk is mostly test fixtures |
+| DTO source union drift | 🛡️ 5 | 🛡️ 9 | Standalone `MemberLogStreamResponse` avoids mutating task `BoardTaskLogStreamResponse.source` semantics |
+| Member popup fallback regression | 🛡️ 6 | 🛡️ 9 | Gate at `MemberDetailDialog`, leave shared `MemberLogsTab` and task `Execution Sessions` untouched |
+| Resolved member runtime/lane type drift | 🛡️ 6 | 🛡️ 9 | Shared type explicitly declares runtime/lane fields already present at runtime |
+| OpenCode bridge call churn | 🛡️ 5 | 🛡️ 8 | Short source-local TTL and in-flight join mirror task source behavior |
+| Board/task sanitization leakage | 🛡️ 5 | 🛡️ 9 | Extract provider-neutral message hygiene, keep board JSON cleanup scoped to task stream |
+| IPC option typo drift | 🛡️ 5 | 🛡️ 9 | Unknown member-stream options are rejected with an allow-list before service dispatch |
+| Participant/source identity drift | 🛡️ 5 | 🛡️ 9 | Actor participants stay actor-based; provider/session labels come from `segment.source` |
+| Since-only replacement reload | 🛡️ 5 | 🛡️ 9 | Renderer does full bounded reloads until partial-response merge is explicit |
+| Generic view side effects | 🛡️ 5 | 🛡️ 9 | View stays render-only; containers own API calls, gates, tracking and fallback |
+
+## 8. Test Additions From Research
+
+Add these tests beyond the original plan:
+
+- Core domain policy tests run without main/preload/renderer imports.
+- Core application use-case tests use fake source/cache/clock/logger ports and no concrete provider adapters.
+- Feature IPC tests exercise `registerMemberLogStreamIpc()`/remove handler through feature main input adapter.
+- Feature preload tests exercise `createMemberLogStreamBridge()` with contracts-only dependencies.
+- Renderer UI tests pass props/view models and do not mock `@renderer/api` or `@renderer/store`.
+- Targeted lint for `src/features/member-log-stream` passes under generic feature boundary rules.
+- App shell integration imports only `@features/member-log-stream/main`, `@features/member-log-stream/preload`, `@features/member-log-stream/renderer` or `@features/member-log-stream/contracts`.
+- `ClaudeMemberTranscriptStreamSource` uses `findRecentMemberLogFileRefsByMember`, not `findMemberLogPaths`.
+- It dedupes cumulative subagent refs before parsing and caps after dedupe.
+- It augments attribution with requested member names so removed/historical members can still resolve.
+- It enforces global `maxSegments` and `maxChunks`.
+- It enforces `maxSourceMessages` and `maxMessagesPerSegment`.
+- It enforces content-char budgets without mutating parser cache arrays.
+- It trims oversized message windows without orphaning tool results.
+- It truncates oversized content while preserving ids needed for tool linking.
+- It preserves ordinary JSON-looking tool outputs unless the content budget requires truncation.
+- It does not apply board/task JSON cleanup globally to member stream messages.
+- It creates stable segment ids from `sessionId/fileFingerprint/startTimestamp`.
+- It returns `MemberLogStreamSegment.source` labels without absolute file paths.
+- It hashes file fingerprints and does not expose absolute paths in ids/warnings/metadata.
+- It does not share parser cache with task stream.
+- It uses a member-owned parser so member `parseFiles().retainOnly()` cannot clear task-stream `inFlight` parse entries.
+- Task-stream parse in-flight dedupe still works while member stream parses a different file set.
+- First-PR Codex skipped adapter does not call `CodexNativeTraceReader.readTaskRuns()` and does not scan `processed//` directories.
+- `MemberLogFileRef` optional `kind`/`sizeBytes`/`messageCount` remains backward compatible with advisory callers.
+- `MemberLogFileRef.messageCount` is not computed by full parsing inside `TeamMemberLogsFinder`.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` keeps legacy numeric/null third-arg behavior.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` supports object options `{ mtimeSinceMs, forceRefresh }`.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` passes `forceRefresh` through to `discoverProjectSessions()`.
+- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` applies `mtimeSinceMs` to lead transcript refs.
+- `OpenCodeRuntimeProjectionMapper` preserves tool calls, tool results, content blocks, `sourceToolUseID`, `sourceToolAssistantUUID`, `toolUseResult`, `isMeta`, and sanitized text content.
+- `OpenCodeTaskLogStreamSource` and `OpenCodeMemberRuntimeStreamSource` both call `OpenCodeRuntimeProjectionMapper`.
+- Mapper fixtures prove non-string content blocks are not dropped like the stall-monitor mapper would drop them.
+- `ResolvedTeamMember` exposes runtime/lane fields in TS: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`.
+- `MemberLogStreamSection` passes `laneId` for OpenCode member only when `laneOwnerProviderId === 'opencode'`.
+- `MemberLogStreamSection` does not pass stale lane-like fields for non-OpenCode members.
+- `MemberLogStreamSection` enables `setMemberLogStreamTracking(teamName, true)` while mounted and disables it on unmount.
+- `MemberLogStreamSection` disables tracking for the previous team when `teamName` changes.
+- `MemberLogStreamSection` coalesces duplicate background reloads while one request is active.
+- `OpenCodeMemberRuntimeStreamSource` joins duplicate in-flight bridge calls.
+- `OpenCodeMemberRuntimeStreamSource` bypasses completed cache on `forceRefresh` but still joins in-flight work.
+- `MemberDetailDialog` renders `MemberLogStreamSection` when renderer gate is on.
+- `MemberDetailDialog` renders old `MemberLogsTab` when renderer gate is off.
+- `MemberDetailDialog` first-load stream error keeps Logs active and shows explicit old logs fallback.
+- `ExecutionSessionsSection` remains unchanged and still renders legacy `MemberLogsTab`.
+- `MemberLogStreamSection` reloads on same-team `log-source-change` and `task-log-change`, but not `tool-activity`.
+- `MemberLogStreamSection` passes `forceRefresh: true` for `log-source-change` reloads only.
+- `MemberLogStreamSection` does not pass `since` during renderer background replacement reloads in the first PR.
+- `ExecutionLogStreamView` tests cover Date-shaped chunks and document whether JSON-like chunk normalization is supported.
+- `ExecutionLogStreamView` preserves task tail-growth expanded state with task default keys.
+- `ExecutionLogStreamView` supports a member source-aware `buildSegmentRenderKey` override to avoid provider/session collisions.
+- `ExecutionLogStreamView` preserves unknown stream fields and member `segment.source` while normalizing chunks.
+- Shared type tests or compile checks keep `MemberLogStreamResponse` standalone and keep `BoardTaskLogStreamResponse.source` task-only.
+- `MemberLogStreamSection` renders provider/session/lane labels from `segment.source`, not from participant labels.
+- `MemberLogStreamSection` keeps actor participant identity stable for one selected member across multiple providers/sessions.
+- A member stream segment with `actor.memberName` and no `Process[]` still renders tool/output items.
+- IPC rejects invalid `since`, clamps `limitSegments`, and accepts colon-containing lane ids.
+- IPC accepts `primary` as a lane id.
+- IPC preserves exact lane id casing/punctuation when passing options to the service.
+- IPC rejects lane ids with control characters, NUL, newline, `/`, `\` or length over 256.
+- IPC accepts boolean `forceRefresh` and rejects non-boolean values.
+- IPC rejects unknown `getMemberLogStream` option keys before dispatching to the service.
+- IPC validation helpers do not import non-exported `ValidationResult`.
+- IPC registers/removes feature `MEMBER_LOG_STREAM_GET` channel.
+- IPC registers/removes feature `MEMBER_LOG_STREAM_SET_TRACKING` channel.
+- IPC handler calls the feature facade/use case with normalized options.
+- IPC tracking handler validates `teamName` and boolean `enabled`, then maps to `TeamLogSourceTracker` consumer `member_log_stream`.
+- `TeamLogSourceTracker` treats `member_log_stream` as a separate consumer from `task_log_stream`.
+- Multiple member stream mounts for one team keep the watcher alive until all member consumers disable tracking.
+- Disabling `member_log_stream` does not stop tracking while `task_log_stream`, `change_presence`, `tool_activity` or `stall_monitor` is still active.
+- Existing `initializeTeamHandlers()` positional setup remains unchanged for task stream and exact-log services.
+- Existing task stream IPC handler test remains a wiring smoke test proving the feature did not move legacy handler ownership.
+- Browser-mode `httpClient` returns a complete empty member stream response.
+- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` appends `--lane` only when provided.
+- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` honors member-popup `timeoutMs`.
+- OpenCode timeout/runtime missing becomes a warning and does not fail Claude transcript rendering.
+- OpenCode ambiguity error becomes warning, not failed member popup.
+- Codex member stream coverage is `skipped` unless explicit partial trace phase is implemented.
+
+## Final Recommendation
+
+Грамотная реализация варианта 2:
+
+🎯 8.5 🛡️ 8.5 🧠 6
+Примерно 1500-2300 LOC вместе с тестами.
+
+First implementation should be:
+
+- Claude transcript stream complete enough for selected member;
+- OpenCode runtime stream lane-aware and safe;
+- Codex native explicitly skipped or partial-only behind separate follow-up;
+- old member logs fallback kept;
+- fallback decision kept at `MemberDetailDialog`, not inside shared `MemberLogsTab`;
+- task `Execution Sessions` kept unchanged;
+- feature gated;
+- member stream tracking activated while popup Logs stream is mounted;
+- backend budget enforced before renderer;
+- renderer duplicate reloads coalesced while one member stream request is active;
+- provider-neutral message hygiene extracted separately from board/task JSON sanitization;
+- pair-aware message trimming used for oversized single segments;
+- content-size budgets applied before chunk build;
+- cumulative subagent refs deduped before parse;
+- requested member names added to finder attribution set;
+- finder third arg extended through backward-compatible numeric/null/object parser;
+- `mtimeSinceMs` applied to lead transcript refs too;
+- `src/features/member-log-stream` follows canonical feature layout with contracts/core/main/preload/renderer public entrypoints;
+- app shell imports only public feature entrypoints and does not deep-import source adapters or use case internals;
+- member segments include safe provider/session metadata;
+- member stream uses source-aware segment render keys;
+- OpenCode projection conversion extracted into shared mapper;
+- OpenCode runtime transcript calls protected by source-local TTL and in-flight join;
+- feature IPC registration is separate from legacy `initializeTeamHandlers()` service injection;
+- browser fallback returns a complete empty stream response;
+- `log-source-change` reloads use `forceRefresh`;
+- source-port architecture used inside the canonical member-log-stream feature slice;
+- no orchestrator code changes.
+
+Это дает максимальный прирост UX с низким риском неправильных логов.
diff --git a/docs/team-management/member-work-sync-review-obligation-plan.md b/docs/team-management/member-work-sync-review-obligation-plan.md
new file mode 100644
index 00000000..4f6fdcbf
--- /dev/null
+++ b/docs/team-management/member-work-sync-review-obligation-plan.md
@@ -0,0 +1,2078 @@
+# Member Work Sync Review Obligation Plan
+
+**Status:** design proposal, ready for implementation review
+**Scope:** member-work-sync, review lifecycle, member nudges, stuck review pickup
+**Primary repo:** `claude_team`
+**Related controller boundary:** `agent-teams-controller`
+**Recommended option:** Work-sync review pickup obligations
+**Rating:** 🎯 9 🛡️ 9 🧠 8
+**Estimated size:** 850-1250 changed lines with tests for the reliable version. The smaller 350-550 LOC version is only safe for OpenCode-first rollout and does not fully fix the live Anthropic/Alice class.
+
+---
+
+## 1. Summary
+
+The current `member-work-sync` feature already detects review work in the actionable agenda. It correctly detected the live `ember-collective` Alice case as:
+
+```json
+{
+ "state": "needs_sync",
+ "providerId": "anthropic",
+ "agendaItems": [
+ {
+ "kind": "review",
+ "priority": "review_requested",
+ "taskId": "7142f765-76e5-4532-8a37-e228b841a6ed"
+ }
+ ]
+}
+```
+
+But the current system does not yet enforce review pickup:
+
+- Phase 2 nudges are blocked for Anthropic while metrics are not `shadow_ready`.
+- The nudge text is generic and report-oriented.
+- A `member_work_sync_report(still_working)` can suppress more nudges, but it does not prove that the reviewer called `review_start`.
+- `currentReviewCycle.ts` can mix old `review_started` evidence with a newer `review_requested` event, because it does not model a strict review cycle boundary.
+- Production delivery wake is OpenCode-only today, and even that wake is fire-and-forget. For Anthropic/native members, inbox insertion alone is not enough evidence that a live member was prompted.
+- Review-cycle logic is duplicated across work-sync, controller, stall monitor, and renderer timer fallbacks. If these drift, the system can show a timer, skip a nudge, and still leave the task stuck.
+
+The recommended fix is not a separate ping watchdog. The recommended fix is a precise extension of work-sync:
+
+```text
+review_requested -> review_pickup_required obligation
+review_started -> review_in_progress, handled by timers and stall monitor
+review_decision -> obligation gone
+status reset -> obligation gone
+```
+
+Only `review_pickup_required` should be allowed to bypass generic Phase 2 readiness. It should still use all existing anti-spam controls:
+
+- durable outbox idempotency;
+- current agenda revalidation before dispatch;
+- active team check;
+- provider delivery capability check;
+- member busy signal;
+- per-member rate limit;
+- watchdog cooldown;
+- one-shot member nudge keyed by `reviewRequestEventId`, not by the whole agenda fingerprint;
+- lead-facing escalation after ignored correction instead of repeated member spam.
+
+⚠️ Important correction to the earlier plan: do not enable Anthropic review-pickup bypass just because activation says `review_pickup_required`. Enable it only after a provider delivery outcome path is implemented and tested. Otherwise the system can correctly enqueue a nudge that nobody sees promptly.
+
+---
+
+## 2. Decision
+
+Recommended implementation:
+
+**Review pickup obligation inside member-work-sync**
+🎯 9 🛡️ 9 🧠 8, roughly 850-1250 LOC with tests for the full reliable version.
+
+Why this is the best fit:
+
+- It reuses the existing work-sync control plane instead of adding another notification loop.
+- It is level-triggered: every dispatch revalidates the current task state.
+- It already has durable outbox idempotency, and we can add the missing review-cycle idempotency on top.
+- It handles the exact failure class where a reviewer reads a request, answers "duplicate", and never calls `review_start`.
+- It avoids interrupting real work because busy signal and rate limiting already sit in the dispatcher.
+
+Rejected or weaker options:
+
+1. **Standalone review pickup watchdog** - 🎯 6 🛡️ 6 🧠 5, 180-300 LOC
+ Easy to add, but it duplicates work-sync and can spam. It would need to rediscover the same lifecycle, busy, idempotency, and rate-limit rules.
+
+2. **Auto-open `review_start` when review request is delivered/read** - 🎯 6 🛡️ 7 🧠 5, 120-220 LOC
+ It improves timer visibility but can overcount time. Receiving a message is not the same as actually starting review.
+
+3. **Only strengthen review request prompt text** - 🎯 7 🛡️ 5 🧠 2, 20-60 LOC
+ Useful as defense-in-depth, but not reliable. The Alice incident happened because the model reasoned itself out of following the tool protocol.
+
+---
+
+## 3. Live Incident That Motivated This
+
+Task:
+
+```text
+team: ember-collective
+task: #7142f765
+subject: Docs: Workflows (runtime-setup/agent-workflow/code-review/troubleshooting) - EN+RU
+owner: jack
+reviewer: alice
+```
+
+Observed task history:
+
+```text
+08:02:35 review_requested reviewer=alice
+08:02:43 review_started actor=alice
+08:03:25 status_changed completed -> in_progress
+08:03:45 status_changed in_progress -> completed
+08:04:16 review_requested reviewer=alice
+08:04:19 review_approved actor=alice
+08:05:19 status_changed completed -> in_progress
+08:05:24 status_changed in_progress -> completed
+08:05:28 review_requested reviewer=alice
+```
+
+Current state:
+
+```text
+status: completed
+reviewState: review
+latest review cycle: requested only, no review_started
+reviewIntervals: old closed interval only
+```
+
+Alice processed the latest message but replied that it was a duplicate. She did not call:
+
+```text
+review_start
+review_approve
+review_request_changes
+```
+
+Work-sync then evaluated her as `needs_sync`, but skipped the nudge:
+
+```json
+{
+ "event": "nudge_skipped",
+ "reason": "phase2_not_ready",
+ "providerId": "anthropic",
+ "taskRefs": [
+ {
+ "taskId": "7142f765-76e5-4532-8a37-e228b841a6ed",
+ "displayId": "7142f765"
+ }
+ ]
+}
+```
+
+Conclusion:
+
+```text
+work-sync detection works
+review pickup enforcement is missing
+```
+
+---
+
+## 4. Current Architecture Facts
+
+### 4.1 Agenda already includes reviews
+
+`buildActionableWorkAgenda()` produces `kind: "review"` items when a task is in review workflow and the current reviewer resolves to the member.
+
+Current shape:
+
+```ts
+items.push({
+ ...base,
+ kind: 'review',
+ priority: 'review_requested',
+ reason: 'current_cycle_review_assigned',
+ evidence: {
+ status: task.status,
+ owner,
+ reviewer: memberName,
+ reviewState: task.reviewState,
+ historyEventIds: reviewOwner.historyEventIds,
+ },
+});
+```
+
+This is good for basic agenda computation, but insufficient for enforcement because the item does not say whether pickup is still required or review is already started.
+
+### 4.2 Current review cycle resolver is too loose
+
+Current resolver:
+
+```ts
+const latestStarted = [...historyEvents].reverse().find((event) => event.type === 'review_started');
+const latestRequested = [...historyEvents]
+ .reverse()
+ .find((event) => event.type === 'review_requested');
+```
+
+Problem:
+
+```text
+old review_started can be returned together with a newer review_requested
+```
+
+This was visible in the live Alice status. The evidence had both:
+
+```text
+old review_started id
+latest review_requested id
+```
+
+That should not happen for a strict cycle model.
+
+### 4.3 Controller review lifecycle has better boundaries
+
+The controller already treats these as review cycle boundaries:
+
+```js
+if (
+ e.type === 'review_changes_requested' ||
+ e.type === 'review_approved' ||
+ (e.type === 'status_changed' &&
+ (e.to === 'in_progress' || e.to === 'pending' || e.to === 'deleted')) ||
+ e.type === 'task_created'
+) {
+ return null;
+}
+```
+
+Work-sync should match this boundary logic, otherwise renderer/controller/work-sync can disagree about the current cycle.
+
+### 4.4 Work-sync reports are leases, not task transitions
+
+The task protocol explicitly says:
+
+```text
+member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda.
+They do NOT start, complete, approve, or comment on tasks.
+Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment.
+```
+
+This means a review pickup nudge must not treat `still_working` as a successful review pickup. It can only be a bounded lease that prevents immediate repeated nudging.
+
+### 4.5 Existing anti-spam infrastructure is useful
+
+Current `MemberWorkSyncNudgeDispatcher` already revalidates before delivery:
+
+```text
+team active
+status exists
+agenda recomputed
+decision is still needs_sync
+agenda fingerprint still matches
+phase activation allows it
+rate limit allows it
+busy signal allows it
+watchdog cooldown allows it
+```
+
+This is the right place to add review-specific activation. The wrong place is a direct notification inside `review_request`.
+
+### 4.6 Delivery wake is currently provider-asymmetric
+
+Production composition wires `nudgeDeliveryWake` only for OpenCode:
+
+```ts
+nudgeDeliveryWake: {
+ schedule: (input) => {
+ if (input.providerId !== 'opencode') {
+ return;
+ }
+ teamProvisioningService.scheduleOpenCodeMemberInboxDeliveryWake(...);
+ },
+}
+```
+
+The normal teammate inbox relay in `src/main/ipc/teams.ts` is explicitly disabled:
+
+```text
+Teammate inbox relay DISABLED (2026-03-23).
+Codex/Claude teammates read their own inbox files directly via fs.watch.
+Relaying through the lead caused multiple bugs.
+```
+
+This makes the live Alice case more subtle:
+
+```text
+work-sync can detect the stuck Anthropic review
+work-sync can insert an inbox row
+but without a delivery outcome path, the live member may not process it soon
+```
+
+Required conclusion:
+
+```text
+Review pickup bypass needs a delivery-outcome capability gate.
+OpenCode can use the existing wake.
+Anthropic/native needs either a proven fs-watch delivery outcome path or a new narrow delivery path.
+Do not resurrect the old lead relay blindly.
+```
+
+### 4.7 Task impact routing can miss review owners
+
+`MemberWorkSyncTaskImpactResolver` computes `taskWorkflowColumn` from kanban-aware state, but then resolves review owner using raw `task.reviewState`:
+
+```ts
+const taskWorkflowColumn = getTeamTaskWorkflowColumn({
+ ...task,
+ ...(taskKanbanColumn ? { kanbanColumn: taskKanbanColumn } : {}),
+});
+
+const reviewOwner =
+ taskWorkflowColumn === 'review'
+ ? resolveCurrentReviewOwner({
+ reviewState: task.reviewState,
+ kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
+ historyEvents: task.historyEvents,
+ })
+ : null;
+```
+
+If kanban says the task is in review but persisted `task.reviewState` is stale or missing, full agenda recompute can still see the review, while task-impact routing may not wake the reviewer. The plan must fix this by passing the workflow column into the review-cycle resolver:
+
+```ts
+resolveCurrentReviewCycle({
+ reviewState: taskWorkflowColumn,
+ kanbanReviewer,
+ historyEvents,
+});
+```
+
+This is not a cleanup. It is required for reliable triggering.
+
+### 4.8 Review-cycle logic is duplicated and can drift
+
+Current related logic exists in several places:
+
+- `src/features/member-work-sync/core/domain/currentReviewCycle.ts`
+- `agent-teams-controller/src/internal/agenda.js`
+- `src/main/services/team/stallMonitor/reviewerResolution.ts`
+- `src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts`
+- renderer review timer fallback logic
+
+These components do not need identical output, but they must agree on the same lifecycle boundaries:
+
+```text
+review_requested starts or replaces current review request
+review_started only belongs to the current request if it happens after that request
+review_approved closes the cycle
+review_changes_requested closes the cycle
+status_changed -> in_progress/pending/deleted closes the cycle
+task_created resets history
+```
+
+Implementation should add shared fixtures or a shared helper where imports are practical. If controller JS cannot directly import TS shared code, keep the implementations separate but test the same event tables on both sides.
+
+### 4.9 Nudge payload metadata is too thin for robust review intent
+
+Current inbox messages preserve `messageKind`, but there is no structured `intent`, `intentKey`, or `reviewRequestEventId` in `InboxMessage` / `SendMessageRequest`.
+
+That means a review-pickup nudge can only be recognized by generic `member_work_sync_nudge` kind or brittle text matching. This is weak for:
+
+- OpenCode wrapper wording;
+- one-shot per review request;
+- lead escalation after ignored pickup;
+- debugging delivered vs superseded rows.
+
+Reliable implementation should extend the work-sync payload and inbox/sent-message persistence with structured intent metadata:
+
+```ts
+workSyncIntent?: 'agenda_sync' | 'review_pickup';
+workSyncIntentKey?: string; // review-pickup:
+workSyncReviewRequestEventIds?: string[];
+```
+
+This costs more lines, but it avoids depending on prompt text as a machine-readable contract.
+
+### 4.10 Outbox `delivered` currently means inbox row inserted
+
+Current dispatcher order is:
+
+```text
+insert inbox row
+mark outbox delivered
+append nudge_delivered
+schedule delivery wake
+```
+
+That is acceptable for passive sync reminders, but it is too weak for review pickup. If wake scheduling fails after the row is inserted, the outbox is already terminal `delivered`. Then:
+
+- retry will not happen because `delivered` is terminal;
+- rate limit can count a nudge that never reached live input;
+- one-shot marker can incorrectly block future repair;
+- lead escalation may think the member ignored the correction, when the member never saw it.
+
+Reliable review pickup needs a stronger definition:
+
+```text
+inbox_persisted = JSON row exists
+prompt_accepted = live runtime/provider accepted the prompt or direct member wake
+response_proven = runtime saw acceptable proof, such as report or task progress
+delivery unavailable = not delivered, audit and lead-escalate
+```
+
+For review pickup, one-shot should be written at `prompt_accepted`, not at `inbox_persisted`. The review obligation itself is cleared only by task state (`review_start`, `review_approve`, `review_request_changes`) or by a short report lease.
+
+Implementation options:
+
+1. **Three-state review-pickup delivery model** - 🎯 9 🛡️ 9 🧠 8, 180-320 LOC
+ Add explicit `inbox_persisted`, `prompt_accepted`, and `response_proven` metadata/statuses for review pickup. `prompt_accepted` is enough to prevent repeated member nudges; `response_proven` is useful for diagnostics. On prompt failure, keep retryable with `nextAttemptAt`.
+
+2. **Synchronous review-pickup delivery port** - 🎯 9 🛡️ 8 🧠 7, 140-260 LOC
+ Add a dedicated port that persists the inbox row and immediately attempts provider delivery. For OpenCode, call the relay path that returns `lastDelivery` instead of only scheduling the watchdog. Mark prompt accepted only when provider delivery says accepted or response pending after accepted prompt.
+
+3. **Reorder insert -> schedule wake -> mark delivered** - 🎯 6 🛡️ 5 🧠 4, 50-100 LOC
+ Better than current order, but still weak because `scheduleOpenCodeMemberInboxDeliveryWake()` is a fire-and-forget timer. Scheduling the watchdog is not proof that OpenCode accepted the prompt.
+
+4. **Keep current order and audit wake failure** - 🎯 5 🛡️ 4 🧠 2, 10-30 LOC
+ Not reliable. It preserves the exact false-delivered failure mode.
+
+Recommendation: Option 2 for the first implementation if it can return a real provider outcome. If not, use Option 1 and let the watchdog/relay result transition the item from `inbox_persisted` to `prompt_accepted`.
+
+### 4.11 Fire-and-forget wake is not delivery proof
+
+`scheduleOpenCodeMemberInboxDeliveryWake()` currently schedules a watchdog job and returns `void`. It can tell us that a timer was installed, but not whether:
+
+- the runtime was still active when the timer fired;
+- the relay found the message;
+- OpenCode accepted the prompt;
+- the prompt resulted in response proof;
+- the inbox read commit succeeded.
+
+For generic work-sync, this may be acceptable because the next queue/scheduler pass can keep nudging under rate limits. For review pickup, it is not acceptable because one-shot behavior and lead escalation depend on knowing whether the member was actually prompted.
+
+Required plan change:
+
+```text
+nudgeDeliveryWake.schedule remains ok for generic nudges
+review_pickup uses a delivery-outcome path, not fire-and-forget schedule alone
+```
+
+Possible API:
+
+```ts
+export interface MemberWorkSyncReviewPickupDeliveryPort {
+ canDeliver(input: ReviewPickupDeliveryTarget): Promise;
+ deliver(input: ReviewPickupDeliveryRequest): Promise;
+}
+
+export type ReviewPickupDeliveryOutcome =
+ | {
+ ok: true;
+ state: 'prompt_accepted' | 'response_proven';
+ messageId: string;
+ diagnostics?: string[];
+ }
+ | { ok: false; state: 'capability_absent'; reason: string; diagnostics?: string[] }
+ | {
+ ok: false;
+ state: 'retryable_failure';
+ reason: string;
+ nextAttemptAt?: string;
+ diagnostics?: string[];
+ }
+ | { ok: false; state: 'terminal_failure'; reason: string; diagnostics?: string[] };
+```
+
+### 4.12 Persisted status can be stale after restart
+
+`MemberWorkSyncDiagnosticsReader.getStatus()` currently returns stored status immediately when it exists. The renderer hook also calls `getStatus`, not `refreshStatus`, for normal display. That means after app restart the UI can briefly or indefinitely show a persisted status that was evaluated before:
+
+- a new task history event;
+- a crash repair;
+- a startup scan;
+- a provider delivery retry;
+- a report lease expiry.
+
+For generic diagnostics this is tolerable. For review pickup it can hide the fact that no outbox planning has happened yet.
+
+Required behavior:
+
+```text
+stored status should expose staleness
+startup scan should repair/reconcile active members
+UI/debugging should be able to tell persisted snapshot from fresh queue-planned status
+```
+
+Implementation options:
+
+1. **Staleness-aware `getStatus`** - 🎯 8 🛡️ 8 🧠 5, 80-160 LOC
+ If stored status is older than a threshold, source revision changed, report lease expired, or app restart marker is newer, return status with `diagnostics: ['status_snapshot_stale']` and enqueue refresh. Do not plan nudges from plain UI reads.
+
+2. **Renderer calls `refreshStatus` for the panel** - 🎯 7 🛡️ 7 🧠 3, 20-50 LOC
+ Simpler, but it can turn UI visits into side-effectful reconciliation unless carefully separated from outbox planning.
+
+3. **Keep current stored-first behavior** - 🎯 4 🛡️ 4 🧠 1, 0 LOC
+ Not enough for reliable review pickup after crash/restart.
+
+Recommendation: Option 1. Keep `getStatus` cheap and mostly read-only, but make staleness explicit and enqueue a queue reconciliation when needed. Outbox planning still happens in queue reconciliation, not in the read path.
+
+---
+
+## 5. Core Model
+
+Add a review obligation concept to the agenda item evidence.
+
+```ts
+export type MemberWorkSyncReviewObligation = 'review_pickup_required' | 'review_in_progress';
+
+export interface MemberWorkSyncReviewCycleEvidence {
+ reviewCycleId: string;
+ reviewRequestEventId: string;
+ reviewRequestedAt: string;
+ reviewStartedEventId?: string;
+ reviewStartedAt?: string;
+ reviewStartedBy?: string;
+ obligation: MemberWorkSyncReviewObligation;
+ canBypassPhase2: boolean;
+ diagnostics?: string[];
+}
+```
+
+Suggested contract extension:
+
+```ts
+export interface MemberWorkSyncActionableWorkItem {
+ taskId: string;
+ displayId?: string;
+ subject: string;
+ kind: MemberWorkSyncActionableWorkKind;
+ assignee: string;
+ priority: MemberWorkSyncActionableWorkPriority;
+ reason: string;
+ evidence: {
+ status: string;
+ owner?: string;
+ reviewer?: string;
+ reviewState?: string;
+ reviewCycleId?: string;
+ reviewRequestEventId?: string;
+ reviewRequestedAt?: string;
+ reviewStartedEventId?: string;
+ reviewStartedAt?: string;
+ reviewStartedBy?: string;
+ reviewObligation?: MemberWorkSyncReviewObligation;
+ canBypassPhase2?: boolean;
+ reviewDiagnostics?: string[];
+ historyEventIds?: string[];
+ };
+}
+```
+
+The fingerprint must include this evidence. That gives one stable agenda fingerprint per active review request cycle.
+
+For nudge idempotency, add a separate intent key that is stable even if the agenda contains other tasks:
+
+```ts
+export interface MemberWorkSyncReviewPickupIntent {
+ intent: 'review_pickup';
+ intentKey: `review-pickup:${string}`;
+ reviewRequestEventIds: string[];
+}
+```
+
+Do not use only `agendaFingerprint` for one-shot semantics. Agenda fingerprint can change when another task appears, a subject changes, or evidence formatting changes. The review request event id is the real lifecycle identity.
+
+---
+
+## 6. Review Cycle Resolver
+
+Replace the loose owner resolver with a strict cycle resolver.
+
+### 6.1 Desired resolver output
+
+```ts
+export interface CurrentReviewCycle {
+ reviewer: string;
+ reviewCycleId: string;
+ requestEventId: string;
+ requestedAt: string;
+ startedEventId?: string;
+ startedAt?: string;
+ startedBy?: string;
+ diagnostics: string[];
+ canBypassPhase2: boolean;
+ obligation: 'review_pickup_required' | 'review_in_progress';
+ historyEventIds: string[];
+}
+```
+
+### 6.2 Resolver algorithm
+
+Pseudo-code:
+
+```ts
+const REVIEW_CYCLE_BOUNDARY_TYPES = new Set([
+ 'task_created',
+ 'review_approved',
+ 'review_changes_requested',
+]);
+
+function isStatusReset(event: ReviewHistoryEventLike): boolean {
+ return (
+ event.type === 'status_changed' &&
+ (event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')
+ );
+}
+
+function getCurrentReviewCycle(input: {
+ reviewState?: string | null;
+ kanbanReviewer?: string | null;
+ historyEvents?: ReviewHistoryEventLike[];
+}): CurrentReviewCycle | null {
+ if (input.reviewState !== 'review') {
+ return null;
+ }
+
+ const events = (input.historyEvents ?? [])
+ .map((event, index) => ({ event, index }))
+ .sort((a, b) => compareEventsByTimestampThenIndex(a, b));
+
+ let request: ReviewHistoryEventLike | null = null;
+ let requestIndex = -1;
+ let startedByReviewer: ReviewHistoryEventLike | null = null;
+ let ambiguousStarted: ReviewHistoryEventLike | null = null;
+ const diagnostics: string[] = [];
+
+ for (const { event, index } of events) {
+ if (REVIEW_CYCLE_BOUNDARY_TYPES.has(event.type) || isStatusReset(event)) {
+ request = null;
+ requestIndex = -1;
+ startedByReviewer = null;
+ ambiguousStarted = null;
+ diagnostics.length = 0;
+ continue;
+ }
+
+ if (event.type === 'review_requested') {
+ request = event;
+ requestIndex = index;
+ startedByReviewer = null;
+ ambiguousStarted = null;
+ diagnostics.length = 0;
+ continue;
+ }
+
+ if (event.type === 'review_started' && request && index > requestIndex) {
+ const requestedReviewer =
+ normalizeMemberName(request.reviewer) || normalizeMemberName(input.kanbanReviewer);
+ const startedBy = normalizeMemberName(event.actor);
+
+ if (!startedBy) {
+ diagnostics.push('review_started_actor_missing');
+ ambiguousStarted = event;
+ continue;
+ }
+
+ if (requestedReviewer && startedBy !== requestedReviewer) {
+ diagnostics.push('review_started_by_different_member');
+ ambiguousStarted = event;
+ continue;
+ }
+
+ startedByReviewer = event;
+ }
+ }
+
+ if (!request) {
+ return legacyKanbanFallback(input);
+ }
+
+ const reviewer =
+ normalizeMemberName(request.reviewer) ||
+ normalizeMemberName(input.kanbanReviewer) ||
+ normalizeMemberName(startedByReviewer?.actor) ||
+ normalizeMemberName(ambiguousStarted?.actor);
+
+ if (!reviewer) {
+ return null;
+ }
+
+ const effectiveStarted = startedByReviewer ?? ambiguousStarted;
+ const hasStartedEvidence = Boolean(effectiveStarted);
+ const validStartedByReviewer = startedByReviewer;
+
+ return {
+ reviewer,
+ reviewCycleId: request.id ?? `${request.timestamp ?? ''}:${reviewer}`,
+ requestEventId: request.id ?? '',
+ requestedAt: request.timestamp ?? '',
+ ...(effectiveStarted?.id ? { startedEventId: effectiveStarted.id } : {}),
+ ...(effectiveStarted?.timestamp ? { startedAt: effectiveStarted.timestamp } : {}),
+ ...(effectiveStarted?.actor ? { startedBy: effectiveStarted.actor } : {}),
+ diagnostics,
+ canBypassPhase2: Boolean(request.id) && !hasStartedEvidence && diagnostics.length === 0,
+ obligation: hasStartedEvidence ? 'review_in_progress' : 'review_pickup_required',
+ historyEventIds: [request.id, effectiveStarted?.id].filter(Boolean),
+ };
+}
+```
+
+Important behavior:
+
+- A `review_started` before a later `status_changed -> in_progress` must not count.
+- A `review_started` before a later `review_approved` must not count.
+- A newer `review_requested` replaces earlier request evidence even if there was an old `review_started` in the same file.
+- A latest `review_requested` without matching current-cycle `review_started` must be `review_pickup_required`.
+- A legacy kanban reviewer can still create a review item, but should not get Phase 2 bypass unless there is a concrete `reviewRequestEventId`.
+- A `review_started` by a different member is not a normal pickup case. Do not nudge the requested reviewer blindly; surface a diagnostic or lead escalation.
+- A `review_started` with missing actor should not be treated as proof for Phase 2 bypass. It can suppress member spam, but it needs diagnostics because timer attribution may be impossible.
+- If an anomalous `review_started` is followed by a valid current-cycle `review_started` by the requested reviewer, the valid start wins for obligation. Keep diagnostics for observability, but do not keep the task in pickup-required state.
+
+---
+
+## 7. Agenda Item Shape
+
+For a requested-only review:
+
+```json
+{
+ "kind": "review",
+ "priority": "review_requested",
+ "reason": "current_cycle_review_assigned",
+ "evidence": {
+ "status": "completed",
+ "owner": "jack",
+ "reviewer": "alice",
+ "reviewState": "review",
+ "reviewObligation": "review_pickup_required",
+ "canBypassPhase2": true,
+ "reviewCycleId": "420d47fb-be29-40ab-8d2e-c2e4fad63961",
+ "reviewRequestEventId": "420d47fb-be29-40ab-8d2e-c2e4fad63961",
+ "reviewRequestedAt": "2026-05-09T08:05:28.361Z",
+ "historyEventIds": ["420d47fb-be29-40ab-8d2e-c2e4fad63961"]
+ }
+}
+```
+
+For an already started review:
+
+```json
+{
+ "kind": "review",
+ "priority": "review_requested",
+ "reason": "current_cycle_review_assigned",
+ "evidence": {
+ "status": "completed",
+ "owner": "jack",
+ "reviewer": "alice",
+ "reviewState": "review",
+ "reviewObligation": "review_in_progress",
+ "canBypassPhase2": false,
+ "reviewCycleId": "420d47fb-be29-40ab-8d2e-c2e4fad63961",
+ "reviewRequestEventId": "420d47fb-be29-40ab-8d2e-c2e4fad63961",
+ "reviewRequestedAt": "2026-05-09T08:05:28.361Z",
+ "reviewStartedEventId": "abc-start",
+ "reviewStartedAt": "2026-05-09T08:06:10.000Z",
+ "reviewStartedBy": "alice",
+ "historyEventIds": ["420d47fb-be29-40ab-8d2e-c2e4fad63961", "abc-start"]
+ }
+}
+```
+
+---
+
+## 8. Nudge Activation Policy
+
+### 8.1 Current behavior
+
+Current activation allows:
+
+- all providers when `phase2Readiness.state === 'shadow_ready'`;
+- OpenCode targeted candidates during `collecting_shadow_data`;
+- no Anthropic/Codex/Gemini bypass while collecting.
+
+### 8.2 Desired review-specific bypass
+
+Add a narrow condition:
+
+```ts
+function isReviewPickupRequired(status: MemberWorkSyncStatus): boolean {
+ return (
+ status.state === 'needs_sync' &&
+ status.shadow?.wouldNudge === true &&
+ status.agenda.items.length > 0 &&
+ status.agenda.items.every(
+ (item) =>
+ item.kind === 'review' &&
+ item.evidence.reviewObligation === 'review_pickup_required' &&
+ Boolean(item.evidence.reviewRequestEventId) &&
+ item.evidence.canBypassPhase2 === true
+ )
+ );
+}
+```
+
+Then activation can allow planning:
+
+```ts
+if (hasBlockingMetrics(input.metrics)) {
+ return { active: false, reason: 'blocking_metrics' };
+}
+
+if (isReviewPickupRequired(input.status)) {
+ return { active: true, reason: 'review_pickup_required' };
+}
+```
+
+Important: keep `blocking_metrics` before review bypass. If the team has unsafe nudge rates or fingerprint churn, do not bypass.
+
+But activation is not enough. Dispatch must also verify delivery capability:
+
+```ts
+function hasReviewPickupDeliveryCapability(status: MemberWorkSyncStatus): boolean {
+ if (status.providerId === 'opencode') {
+ return true; // existing runtime wake
+ }
+
+ return status.deliveryCapabilities?.memberWorkSyncNudgeWake === true;
+}
+
+if (isReviewPickupRequired(status) && !hasReviewPickupDeliveryCapability(status)) {
+ return { active: false, reason: 'review_pickup_delivery_unavailable' };
+}
+```
+
+Without this gate, Anthropic can pass activation but still only get a passive inbox row. That is not a reliable repair.
+
+Use two different failure classes:
+
+```text
+delivery capability absent = do not create/dispatch member outbox, audit and lead-escalate
+provider delivery temporarily failed = retryable dispatch failure with nextAttemptAt
+fire-and-forget wake scheduled = not enough to mark prompt_accepted
+```
+
+This distinction matters because a provider that has no implementation should not create an infinite retry loop, while an active provider with a transient wake error should retry.
+
+Suggested type change:
+
+```ts
+export type MemberWorkSyncNudgeActivationReason =
+ | 'shadow_ready'
+ | 'opencode_targeted_shadow_collecting'
+ | 'review_pickup_required'
+ | 'review_pickup_delivery_unavailable'
+ | 'status_not_nudgeable'
+ | 'blocking_metrics'
+ | 'phase2_not_ready';
+```
+
+If delivery capability is hard to expose through `MemberWorkSyncStatus`, keep it in dispatcher deps instead:
+
+```ts
+nudgeDeliveryWake.canWake?.({
+ teamName,
+ memberName,
+ providerId,
+ messageKind: 'member_work_sync_nudge',
+ workSyncIntent: 'review_pickup',
+});
+```
+
+The important part is the product invariant, not the exact API shape:
+
+```text
+review pickup bypass may create a prompt only when the app can wake that member path
+```
+
+---
+
+## 9. Nudge Payload
+
+### 9.1 Current payload is too generic
+
+Current text:
+
+```text
+Work sync check: you have current actionable work assigned.
+Required sync action: call member_work_sync_status...
+Then call member_work_sync_report...
+```
+
+For review pickup, this is not enough. It can produce a valid `still_working` lease without `review_start`, which would hide the stuck review for up to 15 minutes.
+
+### 9.2 Review-specific payload
+
+If every item is `review_pickup_required`, build a different payload:
+
+```ts
+function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload {
+ const reviewItems = status.agenda.items.filter(
+ (item) => item.kind === 'review' && item.evidence.reviewObligation === 'review_pickup_required'
+ );
+
+ const taskIds = reviewItems.map((item) => item.taskId);
+ const taskList = reviewItems
+ .map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`)
+ .join('; ');
+
+ return {
+ from: 'system',
+ to: status.memberName,
+ messageKind: 'member_work_sync_nudge',
+ workSyncIntent: 'review_pickup',
+ workSyncIntentKey: buildReviewPickupIntentKey(reviewItems),
+ workSyncReviewRequestEventIds: reviewItems.map((item) => item.evidence.reviewRequestEventId),
+ source: 'member-work-sync',
+ actionMode: 'do',
+ taskRefs: reviewItems.map((item) => ({
+ teamName: status.teamName,
+ taskId: item.taskId,
+ displayId: item.displayId ?? item.taskId.slice(0, 8),
+ })),
+ text: [
+ 'Review pickup check: you have a current review request that is still waiting for review_start.',
+ `Current review agenda: ${taskList}.`,
+ `First call task_get for the task. If it is still in review for member "${status.memberName}", call review_start now.`,
+ `After review_start, either approve with review_approve or request fixes with review_request_changes.`,
+ 'Do not treat this as a duplicate only because you reviewed an earlier cycle. A later review request starts a new review cycle.',
+ `If you are blocked from reviewing, add or request concrete blocker evidence on the task before reporting blocked.`,
+ `member_work_sync_report may be used only to lease the sync state while you continue. It does not start or finish the review.`,
+ taskIds.length
+ ? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
+ : '',
+ `Do not use provider names, runtime names, or team names as memberName; use exactly "${status.memberName}".`,
+ 'Do not reply only with acknowledgement.',
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ };
+}
+```
+
+Required payload contract change:
+
+```ts
+export interface MemberWorkSyncNudgePayload {
+ from: 'system';
+ to: string;
+ messageKind: 'member_work_sync_nudge';
+ workSyncIntent: 'agenda_sync' | 'review_pickup';
+ workSyncIntentKey?: string;
+ workSyncReviewRequestEventIds?: string[];
+ source: 'member-work-sync';
+ actionMode: 'do';
+ text: string;
+ taskRefs: TaskRef[];
+}
+```
+
+The same metadata should survive through:
+
+```text
+MemberWorkSyncNudgePayload
+-> TeamInboxMemberWorkSyncNudgeSink
+-> SendMessageRequest
+-> InboxMessage
+-> TeamInboxWriter
+-> TeamInboxReader
+-> OpenCode delivery ledger / runtime adapter where applicable
+```
+
+Do not use text parsing as the main way to detect review-pickup intent. Text can remain a fallback for legacy rows only.
+
+### 9.3 OpenCode wrapper also needs review-specific wording
+
+OpenCode currently wraps work-sync nudges with:
+
+```text
+Concrete task progress or member_work_sync_report is sufficient response proof.
+```
+
+For review pickup, that wording is wrong. The wrapper should inspect the payload or new payload kind and say:
+
+```text
+This delivered app message is a review pickup work-sync nudge.
+Concrete proof is review_start, review_approve, review_request_changes, or a valid member_work_sync_report lease.
+The lease prevents repeated sync nudges but does not start or finish review.
+```
+
+This matters because OpenCode delivery proof currently treats report proof as enough to mark prompt delivery complete. That is fine for delivery, but not enough to clear the work-sync obligation unless the agenda changes or a lease is still active.
+
+Use structured metadata first:
+
+```ts
+const isReviewPickupNudge =
+ input.messageKind === 'member_work_sync_nudge' && input.workSyncIntent === 'review_pickup';
+```
+
+Text marker fallback is acceptable only for already persisted legacy messages:
+
+```ts
+const isLegacyReviewPickupNudge = input.text.includes('Review pickup check:');
+```
+
+---
+
+## 10. Report Semantics
+
+### 10.1 Keep `still_working` as a lease
+
+Do not reject `still_working` for review pickup by default. If an agent is actually about to review, a short lease is useful. The important constraint is:
+
+```text
+still_working suppresses repeat nudge
+still_working does not satisfy review pickup
+```
+
+Current `decideMemberWorkSyncStatus()` already implements this behavior by returning `still_working` only until `expiresAt`.
+
+### 10.2 Shorter default lease for review pickup
+
+Current default still-working lease is 15 minutes. For review pickup this is too long.
+
+Suggested rule:
+
+```ts
+const DEFAULT_REVIEW_PICKUP_STILL_WORKING_LEASE_MS = 3 * 60 * 1000;
+```
+
+Option A:
+
+- pass agenda into `clampLeaseTtlMs`;
+- if agenda has `review_pickup_required`, default to 3 minutes and max to 10 minutes.
+- 🎯 9 🛡️ 9 🧠 4, roughly 30-70 LOC.
+
+Option B:
+
+- keep report validator unchanged;
+- nudge text explicitly asks for smaller `leaseTtlMs`.
+- 🎯 6 🛡️ 5 🧠 2, roughly 10-25 LOC.
+
+Recommendation: Option A. It is safer because the app controls the lease.
+
+### 10.3 `blocked` must still require board evidence
+
+Current report validator rejects blocked unless agenda has blocker evidence:
+
+```ts
+if (
+ input.request.state === 'blocked' &&
+ !agendaHasBlockedEvidence(input.agenda, input.request.taskIds)
+) {
+ return {
+ ok: false,
+ code: 'blocked_without_evidence',
+ message: 'Blocked report requires current blocker evidence in the task board.',
+ };
+}
+```
+
+For review pickup, this should remain strict. A reviewer saying "blocked" without a task comment or blocker flag is not durable.
+
+Potential future improvement:
+
+```text
+review_blocked_without_comment -> reject with message telling reviewer to add task comment first
+```
+
+### 10.4 Delivered outbox rows are terminal
+
+Current outbox semantics should be treated as:
+
+```text
+pending / claimed / failed_retryable can be retried
+delivered is terminal for that outbox id
+```
+
+That means after the short `still_working` lease expires, we must not rely on reviving the same delivered member nudge. If the same `reviewRequestEventId` is still `review_pickup_required`, the next action should be lead escalation or a new explicit escalation row, not another member poke under a churned agenda fingerprint.
+
+For review pickup, write the one-shot member marker only after the stronger delivery definition is met:
+
+```text
+do not mark one-shot on outbox planned
+do not mark one-shot on inbox inserted alone
+do not mark one-shot on fire-and-forget wake scheduled
+mark one-shot after prompt_accepted or response_proven
+```
+
+If wake fails after inbox insertion, the retry should be able to reuse the same message id and schedule wake again.
+
+---
+
+## 11. One-Shot Member Nudge And Lead Escalation
+
+A key anti-spam requirement:
+
+```text
+Do not keep poking the reviewer forever.
+```
+
+Recommended behavior:
+
+1. First failure after turn-settled:
+ - work-sync computes `needs_sync`;
+ - obligation is `review_pickup_required`;
+ - one member nudge is delivered for that review request cycle.
+
+2. If reviewer calls `review_start`, `review_approve`, or `review_request_changes`:
+ - agenda changes;
+ - outbox item is superseded or no longer matches;
+ - no more pickup nudges.
+
+3. If reviewer reports `still_working`:
+ - status becomes `still_working`;
+ - pending nudge is superseded;
+ - after short lease expires, work-sync can re-enter `needs_sync`.
+
+4. If after one delivered member nudge the same review cycle remains `review_pickup_required`:
+ - do not deliver another member nudge immediately;
+ - create a lead-facing escalation or diagnostic.
+
+Possible escalation payload:
+
+```text
+Review pickup still pending after member correction.
+
+Task #7142f765 is still in review for alice, but no review_start, review_approve, or review_request_changes was recorded after the current review request.
+
+The member already received one review pickup correction for this cycle. Consider reassigning reviewer or sending a direct instruction.
+```
+
+Implementation options:
+
+- Minimal: record `review_pickup_member_nudge_delivered` in audit and rely on existing rate limit - 🎯 5 🛡️ 4 🧠 2, 20-40 LOC. This is not enough for reliable production because fingerprint churn can create repeated member nudges.
+- Better: add outbox terminal reason or sidecar marker keyed by `(team, member, reviewRequestEventId)` - 🎯 8 🛡️ 8 🧠 5, 80-150 LOC.
+- Best: add the sidecar marker plus a lead notification outbox path for ignored review pickup obligations - 🎯 9 🛡️ 9 🧠 7, 160-260 LOC.
+
+Recommended first pass:
+
+```text
+one member nudge per reviewRequestEventId
+lead notification after lease expiry or next turn-settled if obligation persists
+```
+
+This avoids repeated member spam while making the stuck state visible.
+
+Do not make lead escalation optional if the goal is to prevent future silent stuck reviews. Without escalation, the system avoids spam but can still leave the task invisible after the first ignored correction.
+
+---
+
+## 12. Edge Cases
+
+### 12.1 Reviewer already started review in this cycle
+
+History:
+
+```text
+review_requested alice
+review_started alice
+```
+
+Expected:
+
+- obligation is `review_in_progress`;
+- no review pickup bypass;
+- timer can show reviewing;
+- stall monitor handles no-progress-after-start cases.
+
+### 12.2 Reviewer approved without explicit `review_start`
+
+History:
+
+```text
+review_requested alice
+review_approved alice
+```
+
+Expected:
+
+- no review agenda item;
+- any pending pickup nudge is superseded during dispatch revalidation;
+- do not create a pickup nudge after approval.
+
+### 12.3 Reviewer requested changes without explicit `review_start`
+
+History:
+
+```text
+review_requested alice
+review_changes_requested alice
+status_changed completed -> pending
+```
+
+Expected:
+
+- review obligation gone;
+- owner gets needs-fix work item;
+- no pickup nudge.
+
+### 12.4 Task returned to work after review
+
+History:
+
+```text
+review_requested alice
+review_started alice
+status_changed completed -> in_progress
+status_changed in_progress -> completed
+review_requested alice
+```
+
+Expected:
+
+- old `review_started` is not part of current cycle;
+- current cycle is requested-only;
+- pickup obligation exists for latest request only.
+
+This is the exact live Alice shape.
+
+### 12.5 Repeated review request in same cycle
+
+History:
+
+```text
+review_requested alice
+review_requested alice
+```
+
+Expected:
+
+- latest request should become the current request event;
+- older request should not keep the same fingerprint;
+- one nudge per latest request event.
+
+Reason: a new request often means the owner added new context or asked for another pass.
+
+### 12.6 Reviewer changed before pickup
+
+History:
+
+```text
+review_requested alice
+review_requested bob
+```
+
+Expected:
+
+- Alice agenda loses the review item;
+- Bob agenda gets `review_pickup_required`;
+- any Alice pending nudge is superseded because fingerprint no longer matches.
+
+### 12.7 Kanban reviewer exists but no review_requested event
+
+State:
+
+```text
+kanban.tasks[task].column = review
+kanban.tasks[task].reviewer = alice
+history has no review_requested
+```
+
+Expected:
+
+- agenda may include review item for backwards compatibility;
+- do not allow Phase 2 bypass;
+- reason should be legacy or diagnostics should mention missing request event.
+
+Reason: without a concrete request event, idempotency by review cycle is weak.
+
+### 12.8 Self-review
+
+State:
+
+```text
+owner = alice
+reviewer = alice
+```
+
+Current controller agenda treats self-review as lead oversight in some paths. Work-sync should avoid nudging Alice to review her own task unless the system explicitly allows self-review.
+
+Expected:
+
+- no review pickup bypass for self-review;
+- lead-facing issue is safer.
+
+### 12.9 Reviewer inactive or removed
+
+Expected:
+
+- `activeMemberNames` validation prevents valid report;
+- agenda source should not create a member agenda for removed member;
+- task impact resolver should fall back to lead if reviewer missing/invalid;
+- no member nudge.
+
+### 12.10 Team offline or stopping
+
+Expected:
+
+- `isTeamActive` false makes status `inactive`;
+- outbox dispatch supersedes with `team_inactive`;
+- no inbox insertion.
+
+### 12.11 Member busy
+
+Expected:
+
+- active or recent tool activity defers dispatch;
+- retry happens after busy `retryAfterIso`;
+- no interruption during active tool calls.
+
+### 12.12 Existing stall monitor alert
+
+Expected:
+
+- `TeamTaskStallJournalWorkSyncCooldown` prevents duplicate nudge if stall monitor recently alerted for same task;
+- review pickup and stall monitor should not both spam.
+
+Important distinction:
+
+```text
+review_pickup_required = reviewer has not started current review
+started-review stall = reviewer started but stopped progressing
+```
+
+### 12.13 Agent reports `still_working` but does nothing
+
+Expected:
+
+- status becomes `still_working` for short lease;
+- no repeated immediate nudge;
+- after lease expires, obligation returns to `needs_sync`;
+- if member already got one pickup nudge for this reviewRequestEventId, escalate to lead instead of repeatedly nudging the member.
+
+### 12.14 App crash between outbox creation and dispatch
+
+Expected:
+
+- pending outbox item remains durable;
+- dispatch scheduler claims it after restart;
+- revalidation prevents stale delivery.
+
+### 12.15 App crash after inbox write but before mark delivered
+
+Expected:
+
+- `insertIfAbsent` uses stable `messageId`;
+- retry sees existing inbox row and does not duplicate message;
+- outbox can mark delivered on retry.
+
+### 12.16 Agent reads nudge but app crashes before mark read
+
+Expected:
+
+- native inbox behavior may re-relay unread rows;
+- stable `messageId` and relayed ids reduce duplicates during a run;
+- for OpenCode, delivery ledger should handle response proof separately;
+- for native, this is acceptable because nudge is idempotent and review tools are idempotent.
+
+### 12.17 OpenCode-specific delivery proof
+
+OpenCode wrapper currently treats work-sync report as enough delivery proof. For review pickup:
+
+- report is enough proof that the nudge was processed;
+- report is not enough proof that review started;
+- work-sync status remains `still_working` until lease expires unless task state changes.
+
+### 12.18 Non-OpenCode delivery outcome
+
+Production composition currently wires `nudgeDeliveryWake` only for OpenCode:
+
+```ts
+nudgeDeliveryWake: {
+ schedule: (input) => {
+ if (input.providerId !== 'opencode') {
+ return;
+ }
+ teamProvisioningService.scheduleOpenCodeMemberInboxDeliveryWake(...);
+ },
+}
+```
+
+That is fine for generic Phase 2 because OpenCode was the targeted early-delivery candidate. For review pickup bypass, this becomes an implementation risk because the first target provider in the live incident is Anthropic.
+
+Delivery options:
+
+1. **Generic provider delivery outcome using a narrow, tested path** - 🎯 8 🛡️ 8 🧠 7, 180-300 LOC
+ Add a `canDeliver` / `deliver` capability for `member_work_sync_nudge` and route providers explicitly. OpenCode can reuse relay/ledger internals, but not just fire-and-forget scheduling. Native providers use a proven direct member inbox watcher result or `relayInboxFileToLiveRecipient` only if the service test proves it reaches the live member without the old lead-relay loop.
+
+2. **OpenCode-only rollout first** - 🎯 7 🛡️ 8 🧠 3, 40-80 LOC
+ Safe and fast, but it does not fix the live Anthropic/Alice incident. Use only as an incremental rollout, not as the final answer.
+
+3. **Re-enable old `relayMemberInboxMessages` for native** - 🎯 4 🛡️ 4 🧠 5, 60-140 LOC
+ Not recommended. The code comment says this path caused lead misrouting, duplicate messages, and relay loops.
+
+Required behavior:
+
+- work-sync may insert an inbox row for Anthropic;
+- review pickup bypass may dispatch only when delivery capability is available;
+- if capability is missing, do not create a member nudge outbox row; audit `review_pickup_delivery_unavailable` and surface lead diagnostic instead of pretending the member was corrected;
+- if capability exists but provider delivery fails, keep the outbox retryable, because the persisted inbox row alone is not enough for review pickup;
+- if provider delivery is fire-and-forget only, treat it as not capable for review pickup until there is a follow-up result path;
+- do not assume inbox insertion alone is enough.
+
+Implementation gate:
+
+```text
+Before enabling Anthropic review pickup bypass, add a service or live-smoke test proving that member_work_sync_nudge reaches an active Anthropic/native member path.
+```
+
+### 12.19 Native delivery marked read but not semantically processed
+
+Some delivery paths can verify "row written" or "prompt sent" without proving the member called a review tool. That is okay only if the task state remains the source of truth.
+
+Expected:
+
+- delivery proof can mark an outbox item delivered;
+- delivery proof must not clear `review_pickup_required`;
+- only `review_start`, `review_approve`, `review_request_changes`, or a short `member_work_sync_report` lease can change the next status decision;
+- ignored delivered nudges escalate to lead.
+
+### 12.20 Multiple review items in one agenda
+
+If reviewer has multiple pending review pickups:
+
+- one nudge can list all current review pickups;
+- idempotency by full agenda fingerprint still works;
+- one-shot marker should be per `(member, reviewRequestEventId)`, not per whole agenda, otherwise adding a second review can accidentally unlock another nudge for the first.
+
+### 12.21 Fingerprint churn
+
+Changing subject, display id, or evidence can change the fingerprint. The bypass must still respect `blocking_metrics` when churn is high.
+
+Potential mitigation:
+
+- keep review pickup evidence minimal and stable;
+- do not include non-essential timestamps beyond cycle timestamps;
+- keep generatedAt out of fingerprint, as current code already does.
+
+### 12.22 Clock skew or malformed timestamps
+
+Expected:
+
+- event sort should preserve file order as fallback when timestamps are invalid or equal;
+- `reviewCycleId` should prefer event id, not timestamp;
+- malformed timestamps should not create duplicate cycles if event id exists.
+
+### 12.23 `review_started` actor missing or mismatched
+
+History:
+
+```text
+review_requested alice
+review_started actor missing
+```
+
+or:
+
+```text
+review_requested alice
+review_started bob
+```
+
+Expected:
+
+- do not use this as a normal review pickup bypass case;
+- add diagnostics such as `review_started_actor_missing` or `review_started_by_different_member`;
+- avoid repeatedly nudging Alice if evidence suggests the task lifecycle is corrupted;
+- escalate to lead if the review remains stuck.
+
+### 12.24 Kanban/workflow mismatch in task impact routing
+
+State:
+
+```text
+kanban column = review
+task.reviewState missing or stale
+kanban reviewer = alice
+```
+
+Expected:
+
+- full agenda and task-impact resolver both route Alice;
+- resolver input must use kanban-aware `taskWorkflowColumn`, not raw `task.reviewState`;
+- test this explicitly because otherwise the bug only appears in incremental updates.
+
+---
+
+## 13. Implementation Plan
+
+### Phase 0 - Provider delivery capability gate
+
+Files:
+
+```text
+src/features/member-work-sync/core/application/ports.ts
+src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts
+src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts
+src/main/index.ts
+src/main/services/team/TeamProvisioningService.ts
+test/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.test.ts
+test/main/services/team/TeamProvisioningServiceRelay.test.ts
+```
+
+Changes:
+
+- add a way for work-sync to ask whether a review-pickup nudge can wake this provider/member path;
+- keep existing OpenCode wake behavior;
+- add or prove a narrow native delivery outcome path before enabling Anthropic/Codex/Gemini review pickup bypass;
+- audit `review_pickup_delivery_unavailable` when detection works but prompt delivery is not safe;
+- classify capability failures as absent vs temporarily failed;
+- do not re-enable the old lead relay path without new tests covering the bugs listed in `src/main/ipc/teams.ts`.
+
+Recommended API shape:
+
+```ts
+export interface MemberWorkSyncNudgeDeliveryWakePort {
+ canWake?(input: {
+ teamName: string;
+ memberName: string;
+ providerId?: MemberWorkSyncProviderId | null;
+ messageKind: 'member_work_sync_nudge';
+ workSyncIntent?: 'agenda_sync' | 'review_pickup';
+ }): Promise | boolean;
+
+ schedule(input: {
+ teamName: string;
+ memberName: string;
+ messageId: string;
+ providerId?: MemberWorkSyncProviderId | null;
+ reason: 'member_work_sync_nudge_inserted' | 'member_work_sync_nudge_existing';
+ delayMs?: number;
+ workSyncIntent?: 'agenda_sync' | 'review_pickup';
+ }): Promise | void;
+}
+```
+
+Important: this existing wake-style API is not sufficient by itself for review pickup. Either extend it with a delivery outcome callback/result, or introduce a separate `MemberWorkSyncReviewPickupDeliveryPort` as described above.
+
+### Phase 1 - Strict review cycle domain
+
+Files:
+
+```text
+src/features/member-work-sync/core/domain/currentReviewCycle.ts
+test/features/member-work-sync/core/ActionableWorkAgenda.test.ts
+agent-teams-controller/test/controller.test.js
+test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts
+```
+
+Changes:
+
+- add `resolveCurrentReviewCycle`;
+- keep `resolveCurrentReviewOwner` as compatibility wrapper or replace its callers;
+- model boundaries matching controller logic;
+- expose `reviewObligation`, `canBypassPhase2`, and diagnostics;
+- add shared lifecycle fixture tables and run equivalent cases against work-sync, controller agenda, and stall monitor where practical.
+
+Compatibility wrapper:
+
+```ts
+export function resolveCurrentReviewOwner(input: {
+ reviewState?: string | null;
+ kanbanReviewer?: string | null;
+ historyEvents?: ReviewHistoryEventLike[];
+}): CurrentReviewOwner | null {
+ const cycle = resolveCurrentReviewCycle(input);
+ return cycle
+ ? {
+ reviewer: cycle.reviewer,
+ historyEventIds: cycle.historyEventIds,
+ }
+ : null;
+}
+```
+
+Do not ship this phase if repeated `review_requested` still keeps an older `review_started`. That is the exact Alice failure.
+
+### Phase 2 - Agenda evidence and fingerprint
+
+Files:
+
+```text
+src/features/member-work-sync/contracts/types.ts
+src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts
+src/features/member-work-sync/core/domain/AgendaFingerprint.ts
+src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts
+test/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.test.ts
+```
+
+Changes:
+
+- add review evidence fields;
+- include fields in fingerprint through existing evidence copy;
+- add diagnostics for legacy reviewer fallback.
+- fix task-impact routing to pass kanban-aware `taskWorkflowColumn` into the review-cycle resolver.
+
+Example:
+
+```ts
+const reviewCycle = isReviewWorkflow
+ ? resolveCurrentReviewCycle({
+ reviewState: workflowColumn,
+ kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
+ historyEvents: task.historyEvents,
+ })
+ : null;
+
+if (reviewCycle && sameMemberName(reviewCycle.reviewer, memberName)) {
+ items.push({
+ ...base,
+ kind: 'review',
+ priority: 'review_requested',
+ reason: 'current_cycle_review_assigned',
+ evidence: {
+ status: task.status,
+ ...(owner ? { owner } : {}),
+ reviewer: memberName,
+ ...(task.reviewState ? { reviewState: task.reviewState } : {}),
+ reviewObligation: reviewCycle.obligation,
+ reviewCycleId: reviewCycle.reviewCycleId,
+ reviewRequestEventId: reviewCycle.requestEventId,
+ reviewRequestedAt: reviewCycle.requestedAt,
+ ...(reviewCycle.startedEventId ? { reviewStartedEventId: reviewCycle.startedEventId } : {}),
+ ...(reviewCycle.startedAt ? { reviewStartedAt: reviewCycle.startedAt } : {}),
+ ...(reviewCycle.startedBy ? { reviewStartedBy: reviewCycle.startedBy } : {}),
+ canBypassPhase2: reviewCycle.canBypassPhase2,
+ ...(reviewCycle.diagnostics.length > 0 ? { reviewDiagnostics: reviewCycle.diagnostics } : {}),
+ ...(reviewCycle.historyEventIds.length > 0
+ ? { historyEventIds: reviewCycle.historyEventIds }
+ : {}),
+ },
+ });
+}
+```
+
+### Phase 3 - Review-specific activation
+
+Files:
+
+```text
+src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts
+src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts
+test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts
+```
+
+Changes:
+
+- add activation reason `review_pickup_required`;
+- add activation/audit reason `review_pickup_delivery_unavailable`;
+- allow bypass only if all nudgeable items are requested-only review pickups;
+- keep blocking metrics guard first;
+- require delivery capability before planning or dispatching non-OpenCode review pickup nudges;
+- do not create a member outbox row for permanent capability absence.
+
+### Phase 4 - Review-specific payload and metadata
+
+Files:
+
+```text
+src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts
+src/features/member-work-sync/contracts/types.ts
+src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts
+src/shared/types/team.ts
+src/main/services/team/TeamInboxWriter.ts
+src/main/services/team/TeamInboxReader.ts
+test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts
+```
+
+Changes:
+
+- detect review pickup agenda;
+- build review-specific text;
+- keep generic text for normal work-sync agendas;
+- persist `workSyncIntent`, `workSyncIntentKey`, and review request ids through inbox read/write;
+- make payload hash include metadata so payload conflicts are real.
+
+### Phase 5 - Runtime wrapper and delivery outcome update
+
+Files:
+
+```text
+src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts
+src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts
+src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts
+src/main/services/team/TeamProvisioningService.ts
+test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts
+test/main/services/team/TeamProvisioningServiceRelay.test.ts
+```
+
+Changes:
+
+- detect review pickup nudge by payload metadata, with text fallback only for legacy rows;
+- explain `review_start` as required domain action;
+- preserve metadata in OpenCode delivery ledger records;
+- for review-pickup, record `prompt_accepted` or `response_proven` before marking the member outbox terminal delivered;
+- fire-and-forget watchdog scheduling is not enough for terminal delivered;
+- provider delivery failure after inbox insertion must become retryable, not terminal delivered;
+- add native delivery outcome only if a focused test proves it reaches live member input without the disabled lead relay bugs.
+
+### Phase 6 - Short lease and one-shot marker
+
+Files:
+
+```text
+src/features/member-work-sync/core/application/MemberWorkSyncReportValidator.ts
+src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts
+src/features/member-work-sync/main/infrastructure/MemberWorkSyncOutboxStore.ts
+test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts
+```
+
+Changes:
+
+- default `still_working` lease for review pickup to 3 minutes;
+- cap requested review-pickup lease at 10 minutes;
+- add one-shot member nudge marker keyed by `(teamName, memberName, reviewRequestEventId)`;
+- write one-shot marker only after `prompt_accepted` or `response_proven`;
+- never use full agenda fingerprint as the only one-shot key.
+
+```text
+if delivered member nudge exists for same reviewRequestEventId:
+ do not deliver second member nudge
+ plan lead escalation
+```
+
+### Phase 7 - Queue, startup scan, and planning coverage
+
+Files:
+
+```text
+src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts
+src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts
+src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts
+src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts
+src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts
+test/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.test.ts
+test/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.test.ts
+```
+
+Why this phase exists:
+
+```text
+outbox planning happens only during queue reconciliation
+manual status reads do not plan nudges
+startup scan is the recovery path after app restart
+task_changed routing must include reviewer, owner, and lead fallback correctly
+getStatus may return persisted status and must expose staleness
+```
+
+Required changes/tests:
+
+- preserve the existing queue-only planning model, but document it in diagnostics;
+- after app restart, startup scan must enqueue active members and then dispatch due review-pickup nudges;
+- if task impact resolver cannot parse the task id, fallback team-wide must still include the reviewer;
+- if a queue item is dropped because team is inactive, startup/member-spawn/turn-settled must be enough to re-evaluate later;
+- planner should preserve actual activation reason. Do not collapse `review_pickup_delivery_unavailable`, `blocking_metrics`, and `phase2_not_ready` into the same audit code.
+- `getStatus` should not silently return an old pre-restart snapshot as if it were fresh;
+- stale `getStatus` should enqueue queue reconciliation or show explicit diagnostics without directly planning an outbox row from the read path.
+
+### Phase 8 - Lead escalation
+
+Files:
+
+```text
+src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts
+src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts
+src/main/services/team/TeamDataService.ts
+test/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.test.ts
+```
+
+Possible implementation:
+
+- new audit event `review_pickup_escalated`;
+- use existing lead system notification path;
+- only after one member nudge was delivered and the same `reviewRequestEventId` still has pickup obligation after lease expiry or next turn-settled.
+- also escalate when delivery capability is absent, because there is no member prompt path to wait for.
+
+This phase is required for the non-spam reliability goal. Without it, the first ignored correction can still leave the review stuck silently.
+
+---
+
+## 14. Test Plan
+
+### 14.1 Domain tests
+
+Add tests for `resolveCurrentReviewCycle`:
+
+```ts
+it('returns pickup required for latest requested-only review cycle', () => {});
+it('does not reuse review_started before status reset', () => {});
+it('returns in-progress when review_started follows current review_requested', () => {});
+it('returns null after review_approved', () => {});
+it('returns null after review_changes_requested', () => {});
+it('falls back to kanban reviewer without phase bypass evidence', () => {});
+it('uses event id as stable cycle id when timestamps are duplicated', () => {});
+it('uses latest review_requested when two requests exist in one open window', () => {});
+it('does not phase-bypass when review_started actor is missing', () => {});
+it('diagnoses review_started by a different member', () => {});
+it('uses later valid review_started even after earlier malformed started event', () => {});
+```
+
+### 14.2 Agenda tests
+
+Add or update `ActionableWorkAgenda.test.ts`:
+
+```ts
+it('marks requested-only review agenda with review_pickup_required', () => {});
+it('marks started review agenda with review_in_progress', () => {});
+it('keeps old review_started out of reopened review cycle evidence', () => {});
+it('moves review obligation from alice to bob when reviewer changes', () => {});
+it('does not create owner work while task is in review workflow', () => {});
+it('sets canBypassPhase2 only for requested-only cycles with concrete event id', () => {});
+it('carries review diagnostics into evidence without making them nudgeable', () => {});
+```
+
+### 14.2.1 Task impact routing tests
+
+Add tests for `MemberWorkSyncTaskImpactResolver`:
+
+```ts
+it('routes kanban review reviewer when task.reviewState is stale', () => {});
+it('routes lead when review workflow has no resolvable reviewer', () => {});
+it('does not route old reviewer after a newer review_requested assigns another member', () => {});
+```
+
+### 14.3 Activation tests
+
+Add tests:
+
+```ts
+it('activates review pickup nudges while phase2 is collecting', () => {});
+it('does not activate review pickup when blocking metrics are present', () => {});
+it('does not dispatch review pickup when delivery capability is unavailable', () => {});
+it('does not activate started-review nudges through review pickup bypass', () => {});
+it('does not activate legacy kanban-only review through review pickup bypass', () => {});
+it('keeps existing OpenCode targeted behavior', () => {});
+```
+
+### 14.4 Payload tests
+
+Add tests:
+
+```ts
+it('builds review pickup nudge with review_start instructions', () => {});
+it('says member_work_sync_report does not start or finish review', () => {});
+it('keeps generic nudge for work agenda', () => {});
+it('persists workSyncIntent and reviewRequestEventIds through inbox write/read', () => {});
+it('uses structured review pickup metadata in payload hash', () => {});
+```
+
+### 14.5 Use case tests
+
+Add tests in `MemberWorkSyncUseCases.test.ts`:
+
+```ts
+it('plans OpenCode review pickup while phase2 is collecting', async () => {});
+it('skips Anthropic review pickup when no native delivery outcome is configured', async () => {});
+it('plans Anthropic review pickup only when native delivery outcome capability is configured', async () => {});
+it('supersedes review pickup nudge when review_start appears before dispatch', async () => {});
+it('does not dispatch review pickup nudge while member is busy', async () => {});
+it('keeps review pickup retryable when provider delivery fails after inbox insertion', async () => {});
+it('does not mark review pickup delivered only because fire-and-forget wake was scheduled', async () => {});
+it('marks review pickup delivered only after prompt_accepted or response_proven', async () => {});
+it('does not write one-shot marker when only inbox insertion succeeded', async () => {});
+it('does not write one-shot marker when only fire-and-forget wake was scheduled', async () => {});
+it('does not dispatch duplicate member nudge for same reviewRequestEventId after fingerprint churn', async () => {});
+it('escalates to lead when same reviewRequestEventId remains stuck after one delivered member nudge', async () => {});
+it('shortens still_working lease for review pickup agenda', async () => {});
+```
+
+### 14.6 Feature integration tests
+
+Add tests in `createMemberWorkSyncFeature.test.ts`:
+
+```ts
+it('audits delivery unavailable for Anthropic/native when delivery outcome capability is absent', async () => {});
+it('delivers review pickup through real outbox and provider wake when capability is configured', async () => {});
+it('retries provider delivery using the existing inbox row after restart before markDelivered', async () => {});
+it('revalidates and supersedes when the task is approved before dispatch', async () => {});
+it('keeps team inactive review pickup nudges undelivered', async () => {});
+```
+
+### 14.7 OpenCode wrapper tests
+
+Add tests:
+
+```ts
+it('sends review pickup work-sync nudges with review_start oriented instructions', async () => {});
+it('does not say report alone starts or finishes review', async () => {});
+it('detects review pickup by structured workSyncIntent before text fallback', async () => {});
+```
+
+### 14.8 Cross-component lifecycle fixture tests
+
+Use one shared table of history shapes for:
+
+```text
+work-sync currentReviewCycle
+agent-teams-controller agenda reviewer resolution
+stall monitor review window handling
+renderer review timer fallback, if practical
+```
+
+Minimum fixture rows:
+
+```text
+requested only
+requested -> started
+requested -> started -> approved
+requested -> started -> in_progress -> completed -> requested
+requested alice -> requested bob
+requested alice -> started bob
+requested alice -> started missing actor
+duplicate timestamps with stable event order
+```
+
+### 14.9 Queue and restart tests
+
+Add tests:
+
+```ts
+it('plans review pickup during queue reconciliation but not during manual status read', async () => {});
+it('startup scan after app restart plans stuck review pickup before UI relies on stale status', async () => {});
+it('getStatus marks old persisted review pickup status stale after restart', async () => {});
+it('getStatus enqueues refresh for stale persisted status without planning outbox directly', async () => {});
+it('falls back team-wide when task change detail cannot be parsed', async () => {});
+it('preserves review_pickup_delivery_unavailable in planner audit reason', async () => {});
+it('re-enqueues after inactive-team drop once member_spawned or turn_settled arrives', async () => {});
+```
+
+---
+
+## 15. Debugging Flow After Implementation
+
+When a review seems stuck:
+
+```bash
+jq '.status | {state, providerId, diagnostics, shadow, agenda: .agenda.items}' \
+ ~/.claude/teams//members//.member-work-sync/status.json
+```
+
+Expected stuck pickup:
+
+```json
+{
+ "state": "needs_sync",
+ "agenda": [
+ {
+ "kind": "review",
+ "evidence": {
+ "reviewObligation": "review_pickup_required",
+ "reviewRequestEventId": "..."
+ }
+ }
+ ]
+}
+```
+
+Then inspect audit:
+
+```bash
+tail -n 80 ~/.claude/teams//members//.member-work-sync/journal.jsonl
+```
+
+Useful events:
+
+```text
+agenda_loaded
+decision_made
+nudge_planned
+nudge_delivered
+nudge_skipped
+nudge_superseded
+member_busy
+team_inactive
+review_pickup_delivery_unavailable
+review_pickup_wake_failed_retryable
+review_pickup_member_nudge_delivered
+review_pickup_escalated
+```
+
+Expected after successful pickup:
+
+```text
+task history contains review_started after latest review_requested
+work-sync agenda either changes to review_in_progress or remains still_working only while leased
+member card shows reviewing timer from reviewIntervals or current review_started fallback
+```
+
+If status remains stale after app restart, inspect queue diagnostics:
+
+```bash
+jq '.status.shadow, .status.diagnostics' \
+ ~/.claude/teams//members//.member-work-sync/status.json
+```
+
+Expected startup recovery path:
+
+```text
+startup_scan -> queue_reconciled -> nudge_planned -> provider delivery outcome
+```
+
+If the journal shows only manual `reconcile_started` from UI reads, that is not enough. Outbox planning should happen from queue reconciliation or an explicit queue-triggered repair.
+
+---
+
+## 16. Rollout Notes
+
+Recommended rollout:
+
+1. Ship strict cycle resolver, agenda evidence, and task-impact routing fix first with bypass disabled.
+2. Ship structured payload metadata and wrapper tests.
+3. Enable OpenCode review pickup bypass first only through an outcome-returning relay/ledger path.
+4. Enable Anthropic/Codex/Gemini only after delivery capability tests pass for that provider path.
+5. Keep generic Phase 2 behavior unchanged.
+6. Observe audit journals for:
+ - nudge volume;
+ - repeated fingerprints;
+ - `review_pickup_delivery_unavailable`;
+ - `review_pickup_escalated`;
+ - report rejection rate;
+ - stale fingerprint supersedes.
+
+Do not enable broad Anthropic/Codex/Gemini nudges as part of this. The bypass should be limited to:
+
+```text
+review_pickup_required
+```
+
+Anthropic/native rollout gate:
+
+```text
+provider review pickup bypass = disabled unless member_work_sync_nudge wake is proven for that provider path
+```
+
+---
+
+## 17. Acceptance Criteria
+
+The implementation is correct when:
+
+- A latest requested-only review creates `review_pickup_required`.
+- A current-cycle `review_started` changes obligation to `review_in_progress`.
+- Old `review_started` events before status reset do not affect the latest cycle.
+- Repeated `review_requested` uses the latest request and does not inherit old started evidence.
+- Generic work-sync Phase 2 readiness remains unchanged.
+- Requested-only review pickup can nudge OpenCode while phase2 is collecting.
+- Requested-only review pickup can nudge Anthropic/native only when delivery outcome capability is implemented and tested.
+- If native delivery capability is unavailable, the system audits/escalates instead of silently claiming repair.
+- A review-pickup outbox row is not terminal `delivered` until provider outcome is `prompt_accepted` or `response_proven`.
+- Fire-and-forget wake scheduling alone does not write `delivered` or one-shot marker.
+- Provider delivery failure after inbox insertion is retryable and reuses the existing message id.
+- Started reviews do not use pickup bypass.
+- `still_working` leases review pickup briefly but does not clear the obligation forever.
+- Dispatch revalidates and supersedes stale outbox rows.
+- The member is not nudged while busy or team inactive.
+- Repeated reconciles do not duplicate inbox messages for the same `reviewRequestEventId`, even if agenda fingerprint changes.
+- A persisted one-shot marker prevents repeated member nudges for the same review request.
+- One-shot marker is written only after the stronger review-pickup delivery definition is met.
+- Lead escalation is emitted when a delivered review pickup nudge is ignored and the same review request remains stuck.
+- OpenCode delivery text does not imply that report alone starts or finishes review.
+- Structured `workSyncIntent` metadata survives inbox write/read and runtime delivery.
+- Startup scan after restart can plan review pickup even when no new task event arrives.
+- Stored `getStatus` snapshots expose staleness and enqueue reconciliation instead of silently looking fresh.
+- Planner audit preserves exact skip reason, including delivery unavailable vs phase2 not ready.
+
+---
+
+## 18. Final Recommendation
+
+Implement the review pickup path as a narrow obligation in work-sync, not as a separate watchdog.
+
+The safest version is:
+
+```text
+strict current review cycle
++ review_pickup_required agenda evidence
++ task-impact routing uses kanban-aware workflow state
++ provider delivery capability gate
++ delivered means prompt_accepted/response_proven, not inbox inserted or watchdog scheduled
++ review-specific Phase 2 activation bypass
++ structured review-specific nudge metadata and text
++ short still_working lease
++ one member nudge per reviewRequestEventId
++ lead escalation if still stuck
+```
+
+This directly fixes the Alice class of failures only when the provider delivery gate and delivery outcome model are included. Without those, the plan would detect the stuck review but could still fail to prompt the live Anthropic member or falsely mark a scheduled wake as delivered. The full version keeps spam risk low because it stays inside the existing work-sync outbox, revalidation, busy-signal, cooldown, and rate-limit machinery, then escalates to lead instead of repeatedly poking the reviewer.
diff --git a/docs/team-management/opencode-runtime-delivery-advisory-phase-1-2-plan.md b/docs/team-management/opencode-runtime-delivery-advisory-phase-1-2-plan.md
new file mode 100644
index 00000000..318b3afe
--- /dev/null
+++ b/docs/team-management/opencode-runtime-delivery-advisory-phase-1-2-plan.md
@@ -0,0 +1,1363 @@
+# OpenCode Runtime Delivery Advisory Policy - Phase 1.2 Plan
+
+## Summary
+
+Implement a shared user-facing advisory policy for OpenCode prompt delivery records.
+
+The delivery ledger must remain strict. `failed_terminal` still means automatic OpenCode delivery attempts are exhausted for that inbox row. The new policy decides whether that fact should be shown to a human or lead process as an immediate error, deferred while proof can still arrive, surfaced later as a soft warning, or suppressed because a later proof already exists.
+
+Recommended scope: 🎯 9 🛡️ 9 🧠 7 - roughly `420-650` changed lines including tests.
+
+Phase 1.2 intentionally does not change the direct send/composer warning contract yet. That is Phase 1.3. This phase fixes the member card, member snapshot advisory, human notification, and lead notice paths.
+
+## Problem
+
+Observed user-visible failure:
+
+1. OpenCode accepts a prompt.
+2. The app observes no visible assistant response or no sufficient proof in time.
+3. The prompt delivery ledger reaches `failed_terminal` with a generic proof reason such as `empty_assistant_turn`.
+4. Member cards show `OpenCode delivery error`.
+5. A later `runtime_delivery` reply or task progress proof arrives.
+6. The card clears itself.
+
+This is technically consistent with the ledger, but it is wrong UX. A strict ledger fact is being treated as a final user-facing diagnosis too early.
+
+Concrete local evidence showed this shape:
+
+- ledger record: `failed_terminal`, `responseState: "empty_assistant_turn"`, `attempts: 3`, `maxAttempts: 3`
+- later inbox reply: `source: "runtime_delivery"`, same `relayOfMessageId`
+- result: the UI error was temporary and scary, but the participant was not actually unavailable
+
+## Current Hotspots
+
+Member card advisory source:
+
+- `src/main/services/team/TeamDataService.ts`
+- `src/main/services/team/TeamMemberRuntimeAdvisoryService.ts`
+- `src/renderer/utils/memberHelpers.ts`
+
+Notification and lead notice source:
+
+- `src/main/services/team/TeamProvisioningService.ts`
+ - `logOpenCodePromptDeliveryEvent`
+ - `shouldSurfaceOpenCodeRuntimeDeliveryAdvisory`
+ - `shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal`
+ - `fireOpenCodeRuntimeDeliveryErrorNotification`
+ - `notifyLeadAboutOpenCodeRuntimeDeliveryError`
+
+Existing reason helpers:
+
+- `src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts`
+- `src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts`
+
+Existing proof lookup is embedded in:
+
+- `TeamMemberRuntimeAdvisoryService.readVisibleOpenCodeRuntimeDeliveryReplyTimes`
+- `TeamMemberRuntimeAdvisoryService.readTaskProgressProofTimes`
+- `TeamMemberRuntimeAdvisoryService.hasSupersedingProofForOpenCodeDeliveryRecord`
+
+## Core Design
+
+Separate three concepts:
+
+1. **Ledger fact**
+ The durable state of OpenCode prompt delivery. Example: `failed_terminal`.
+
+2. **Proof snapshot**
+ Whether a later visible reply, task progress, or newer success supersedes the failure.
+
+3. **User impact**
+ Whether the app should suppress, defer, warn, or error.
+
+4. **Side-effect eligibility**
+ Whether a surfaced impact should emit a member refresh, desktop notification, or lead notice for this particular event.
+
+This follows SRP:
+
+- ledger store owns durable delivery facts;
+- proof reader owns reading canonical proof sources;
+- policy owns user-facing classification;
+- provisioning service owns event-specific side effects such as notifications and team-change events;
+- renderer only displays the already-classified advisory.
+
+Critical constraint: user impact is not the same thing as notification eligibility. Phase 1.2 must not accidentally broaden notification scope. A hard advisory can be appropriate for a member card while still not producing a desktop notification for event types that never notified before.
+
+## New Module
+
+Add:
+
+```txt
+src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy.ts
+```
+
+Keep this module pure:
+
+- no filesystem reads;
+- no inbox/task readers;
+- no `TeamProvisioningService` import;
+- no `TeamMemberRuntimeAdvisoryService` import;
+- deterministic output from `record`, `proof`, and `nowMs`.
+
+This prevents circular dependencies and keeps the policy easy to unit test.
+
+Recommended exported types:
+
+```ts
+import type { MemberRuntimeAdvisory } from '@shared/types';
+import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
+
+export type OpenCodeRuntimeDeliveryAdvisoryAction = 'suppress' | 'defer' | 'surface';
+
+export type OpenCodeRuntimeDeliveryAdvisorySeverity = 'warning' | 'error';
+
+export interface OpenCodeRuntimeDeliveryProofSnapshot {
+ latestSuccessAt: number | null;
+ visibleReplyAt: number | null;
+ taskProgressAt: number | null;
+}
+
+export interface OpenCodeRuntimeDeliveryAdvisoryDecision {
+ action: OpenCodeRuntimeDeliveryAdvisoryAction;
+ reason:
+ | 'no_reason'
+ | 'responded'
+ | 'newer_success'
+ | 'visible_reply_proof'
+ | 'task_progress_proof'
+ | 'hard_error'
+ | 'proof_pending'
+ | 'proof_missing_confirmed'
+ | 'not_user_visible';
+ severity?: OpenCodeRuntimeDeliveryAdvisorySeverity;
+ reasonCode?: MemberRuntimeAdvisory['reasonCode'];
+ message?: string;
+ observedAt?: string;
+ nextReviewAt?: string;
+}
+
+export interface OpenCodeRuntimeDeliveryAdvisoryPolicyInput {
+ record: OpenCodePromptDeliveryLedgerRecord;
+ proof: OpenCodeRuntimeDeliveryProofSnapshot;
+ nowMs: number;
+ graceMs?: number;
+}
+
+export interface OpenCodeRuntimeDeliveryEventSideEffectDecision {
+ emitMemberAdvisoryRefresh: boolean;
+ scheduleReviewAt?: string;
+ notifyHuman: boolean;
+ notifyLead: boolean;
+}
+```
+
+Recommended default grace:
+
+```ts
+export const OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS = 120_000;
+```
+
+Why `120_000`:
+
+- real observed late proof arrived roughly 40 seconds after `failed_terminal`;
+- current advisory cache TTL is 30 seconds, so less than that can still flicker;
+- 2 minutes is long enough to absorb OpenCode transcript/materialization lag without hiding real stale state for too long.
+
+## Policy Rules
+
+### Suppress
+
+Return `suppress` when:
+
+- selected reason is null;
+- record is `responded`;
+- latest success is newer than the candidate error;
+- visible runtime reply proof is correlated and timestamp-eligible for the original prompt;
+- task progress proof matches `taskRefs`, member actor/author, and original prompt time.
+
+Code sketch:
+
+```ts
+export function decideOpenCodeRuntimeDeliveryAdvisory(
+ input: OpenCodeRuntimeDeliveryAdvisoryPolicyInput
+): OpenCodeRuntimeDeliveryAdvisoryDecision {
+ const recordTimeMs = getOpenCodeRuntimeDeliveryRecordTimeMs(input.record);
+ const reason = selectOpenCodeRuntimeDeliveryReason(input.record);
+
+ if (!reason) {
+ return suppress('no_reason');
+ }
+ if (input.record.status === 'responded') {
+ return suppress('responded');
+ }
+ if (input.proof.latestSuccessAt != null && input.proof.latestSuccessAt > recordTimeMs) {
+ return suppress('newer_success');
+ }
+ if (input.proof.visibleReplyAt != null && input.proof.visibleReplyAt > recordTimeMs) {
+ return suppress('visible_reply_proof');
+ }
+ if (input.proof.taskProgressAt != null && input.proof.taskProgressAt > recordTimeMs) {
+ return suppress('task_progress_proof');
+ }
+
+ // Continue with hard/generic classification.
+}
+```
+
+### Surface immediate error
+
+Return `surface/error` immediately for hard errors.
+
+Hard errors include:
+
+- auth and login problems;
+- quota/credits/capacity;
+- provider or bridge unavailable;
+- permission blocked when action is required;
+- payload mismatch;
+- attachment payload unavailable or unsupported;
+- project/runtime identity unavailable;
+- terminal session/runtime errors with specific non-generic diagnostics.
+
+For non-terminal records, keep current intent but narrow the scary path:
+
+- non-terminal `session_error`, `tool_error`, `permission_blocked`, or `reconcile_failed` with action-required/hard reason can surface immediately;
+- non-terminal generic retryable states should not create a member card error while retries or observe-later work can still run;
+- non-terminal generic states should be handled by the delivery watchdog/direct-send pending UX, not by the member card.
+
+Use existing `selectOpenCodeRuntimeDeliveryReason()` and `isActionRequiredOpenCodeRuntimeDeliveryReason()` first. Add a policy-local hard token set for app/runtime errors that are not provider API text.
+
+Example hard token set:
+
+```ts
+const HARD_RUNTIME_DELIVERY_REASON_TOKENS = [
+ 'auth_unavailable',
+ 'authentication_failed',
+ 'invalid api key',
+ 'insufficient credits',
+ 'quota exceeded',
+ 'key limit exceeded',
+ 'opencode_prompt_delivery_payload_mismatch',
+ 'opencode_inbox_attachment_payload_unavailable',
+ 'opencode_inbox_attachment_payload_read_failed',
+ 'opencode_attachment_delivery_prepare_failed',
+ 'opencode_runtime_message_bridge_unavailable',
+ 'opencode_project_path_unavailable',
+];
+```
+
+Do not classify generic proof states as hard only because the ledger is terminal.
+
+Do not notify for recipient-shape or removed-member cases by default:
+
+- `recipient_is_not_opencode`
+- `recipient_removed`
+- removed member filtered out by config/meta
+
+Those are routing/config facts, not evidence that a live OpenCode participant is broken. They may be useful diagnostics in logs, but they should not produce the scary OpenCode runtime delivery notification path.
+
+### Defer generic proof failures
+
+Return `defer` when:
+
+- record is `failed_terminal`;
+- reason is generic proof missing;
+- no proof supersedes it;
+- `nowMs < failedAt + graceMs`.
+
+Generic proof states include:
+
+- `empty_assistant_turn`
+- `prompt_delivered_no_assistant_message`
+- `visible_reply_still_required`
+- `visible_reply_ack_only_still_requires_answer`
+- `plain_text_ack_only_still_requires_answer`
+- `visible_reply_destination_not_found_yet`
+- `visible_reply_missing_relayOfMessageId`
+- `visible_reply_missing_relayofmessageid`
+- `visible_reply_missing_task_refs`
+- `visible_reply_missing_task_refs_after_merge`
+- `visible_reply_task_refs_merge_failed`
+- `non_visible_tool_without_task_progress`
+
+Do not match only raw ledger tokens. `selectOpenCodeRuntimeDeliveryReason()` often returns readable fallback copy, for example `OpenCode returned an empty assistant turn.`. Add a helper that recognizes both raw and formatted reasons:
+
+```ts
+export function isOpenCodeRuntimeDeliveryProofOnlyReason(input: {
+ record: OpenCodePromptDeliveryLedgerRecord;
+ selectedReason: string;
+}): boolean {
+ const candidates = [
+ input.record.responseState,
+ input.record.lastReason,
+ ...input.record.diagnostics,
+ input.selectedReason,
+ ]
+ .map((value) => value?.trim().toLowerCase())
+ .filter((value): value is string => Boolean(value));
+
+ return candidates.some((value) =>
+ OPEN_CODE_PROOF_ONLY_REASON_TOKENS.some((token) => value.includes(token))
+ );
+}
+
+// Keep these lower-case because candidates are normalized with toLowerCase().
+const OPEN_CODE_PROOF_ONLY_REASON_TOKENS = [
+ 'empty_assistant_turn',
+ 'opencode returned an empty assistant turn',
+ 'prompt_delivered_no_assistant_message',
+ 'opencode accepted the prompt, but no assistant turn was recorded',
+ 'visible_reply_still_required',
+ 'opencode responded, but did not create a visible message_send reply',
+ 'visible_reply_ack_only_still_requires_answer',
+ 'plain_text_ack_only_still_requires_answer',
+ 'visible_reply_destination_not_found_yet',
+ 'visible_reply_missing_relayofmessageid',
+ 'without the required relayofmessageid correlation',
+ 'visible_reply_missing_task_refs',
+ 'visible_reply_missing_task_refs_after_merge',
+ 'visible_reply_task_refs_merge_failed',
+ 'opencode created a reply without the required taskrefs metadata',
+ 'non_visible_tool_without_task_progress',
+ 'opencode used tools, but did not create a visible reply or task progress proof',
+];
+```
+
+This helper is the reason `empty_assistant_turn` becomes `protocol_proof_missing` after grace instead of falling through to `backend_error`.
+
+Code sketch:
+
+```ts
+if (isGenericOpenCodeRuntimeDeliveryProofFailure(input.record, reason)) {
+ const terminalAt = getOpenCodeRuntimeDeliveryTerminalTimeMs(input.record);
+ const nextReviewMs = terminalAt + (input.graceMs ?? OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS);
+ if (input.nowMs < nextReviewMs) {
+ return {
+ action: 'defer',
+ reason: 'proof_pending',
+ observedAt: new Date(terminalAt).toISOString(),
+ nextReviewAt: new Date(nextReviewMs).toISOString(),
+ };
+ }
+
+ return {
+ action: 'surface',
+ reason: 'proof_missing_confirmed',
+ severity: 'warning',
+ reasonCode: 'protocol_proof_missing',
+ message: reason,
+ observedAt: new Date(terminalAt).toISOString(),
+ };
+}
+```
+
+### Surface confirmed soft warning
+
+After grace expires with no proof, return `surface/warning` with:
+
+- `reasonCode: 'protocol_proof_missing'`
+
+Rationale:
+
+- the member card and hover/detail surfaces can show a warning;
+- human desktop notification is too noisy for a proof-only problem;
+- lead notice can disturb the team and cause unnecessary human-facing messages;
+- task-stall monitoring should handle real work inactivity.
+
+If a product decision later wants lead notice for confirmed proof gaps, add a new soft notice path with different copy. Do not reuse `Treat @member as unavailable`.
+
+### Surface immediate hard error
+
+For hard errors:
+
+```ts
+return {
+ action: 'surface',
+ reason: 'hard_error',
+ severity: 'error',
+ reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
+ message: reason,
+ observedAt: new Date(recordTimeMs).toISOString(),
+};
+```
+
+Notification eligibility is decided later from the event and the impact.
+
+## Proof Reader Extraction
+
+Extract the proof lookup out of `TeamMemberRuntimeAdvisoryService` into a reusable helper:
+
+```txt
+src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofReader.ts
+```
+
+Also extract pure matching helpers from `TeamProvisioningService` before building the reader:
+
+```txt
+src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofMatching.ts
+```
+
+Move or wrap the non-mutating parts of:
+
+- `isOpenCodeRecoveredVisibleReplyCandidate`
+- `isOpenCodeVisibleReplyTimestampEligible`
+- `getOpenCodeVisibleReplyInboxCandidates`
+- `openCodeTaskRefsIncludeAll`
+- `normalizeOpenCodeTaskRefsForComparison`
+- `openCodeTaskRefKey`
+
+Then update `TeamProvisioningService` to call those shared helpers. Do this before changing advisory behavior so existing delivery recovery tests prove the extraction did not change semantics.
+
+Recommended public surface:
+
+```ts
+export interface OpenCodeRuntimeDeliveryProofReaderInput {
+ teamName: string;
+ activeMemberKeys: ReadonlySet;
+ recordsByMember: ReadonlyMap;
+}
+
+export interface OpenCodeRuntimeDeliveryProofIndex {
+ // Raw batched reads, not final proof decisions.
+ inboxMessagesByInbox: ReadonlyMap;
+ taskProgressTimes: ReadonlyMap;
+ configuredLeadName: string | null;
+}
+
+export interface OpenCodeRuntimeDeliveryRecordProofSnapshot
+ extends OpenCodeRuntimeDeliveryProofSnapshot {
+ recordId: string;
+ visibleReplyCorrelation: OpenCodeDeliveryVisibleReplyCorrelation | null;
+ visibleReplyMessageId: string | null;
+ visibleReplyInbox: string | null;
+ proofDiagnostics: string[];
+}
+
+export class OpenCodeRuntimeDeliveryProofReader {
+ async readProofIndex(
+ input: OpenCodeRuntimeDeliveryProofReaderInput
+ ): Promise {
+ // Batch-read candidate inboxes and tasks once per team snapshot/status read.
+ }
+
+ getProofSnapshot(input: {
+ memberName: string;
+ record: OpenCodePromptDeliveryLedgerRecord;
+ latestSuccessAt: number | null;
+ proofIndex: OpenCodeRuntimeDeliveryProofIndex;
+ }): OpenCodeRuntimeDeliveryRecordProofSnapshot {
+ // Evaluate record-specific visible reply, task progress, and ledger proof.
+ }
+}
+```
+
+Do not reduce visible proof to only `Map`. That shape cannot represent recovery by observed message id, recovery by `taskRefs`, lead-recipient fallback candidates, or existing `plain_assistant_text` ledger proof. The reader should batch I/O, but proof decisions must stay record-specific.
+
+Use dependency injection/ports so this helper does not import `TeamProvisioningService`:
+
+```ts
+export interface OpenCodeRuntimeDeliveryProofReaderPorts {
+ inboxReader: Pick;
+ taskReader: Pick;
+ configReader: { readConfig(teamName: string): Promise };
+}
+```
+
+This keeps dependencies one-way:
+
+```txt
+TeamProvisioningService -> ProofMatching / ProofReader
+TeamMemberRuntimeAdvisoryService -> ProofReader
+ProofReader -> ports/readers
+```
+
+Do not let `ProofReader` import `TeamProvisioningService`; that would create the wrong ownership boundary and make tests brittle.
+
+Keep proof strict:
+
+- no time-window heuristic message matching;
+- no summary matching;
+- no passive user reply summary;
+- no cross-lane proof;
+- no proof from another member;
+- no proof from a task without matching `taskRefs`.
+
+The existing `relayOfMessageId` and task progress checks are the right base shape, but do not only move the current `TeamMemberRuntimeAdvisoryService.readVisibleOpenCodeRuntimeDeliveryReplyTimes()` as-is. That code is narrower than the delivery recovery logic in `TeamProvisioningService`.
+
+The proof reader must mirror the read-only parts of these current provisioning paths:
+
+- `findOpenCodeVisibleReplyByRelayOfMessageId`
+- `findOpenCodeVisibleReplyByObservedMessageId`
+- `findOpenCodeVisibleReplyByTaskRefs`
+- `isOpenCodeRecoveredVisibleReplyCandidate`
+- `isOpenCodeVisibleReplyTimestampEligible`
+- `openCodeTaskRefsIncludeAll`
+
+It should recognize all existing visible proof correlations:
+
+- `relayOfMessageId`
+- `direct_child_message_send`
+- `plain_assistant_text`
+
+Do not call mutating repair/materialization helpers from snapshot advisory reads:
+
+- no `correlateRuntimeDeliveryReply`
+- no taskRef merge writes
+- no plain-text visible reply materialization
+- no ledger mutation from `getMemberAdvisories()`
+
+If proof needs mutation to become durable, that belongs to the delivery/observe path. The advisory proof reader is read-only and should only suppress when proof is already visible in inbox/task/ledger state.
+
+Timestamp rule:
+
+- visible reply proof should use the existing `isOpenCodeVisibleReplyTimestampEligible` semantics, not `reply.timestamp > failedAt`;
+- task progress proof should compare against the original prompt/inbox time, not terminal `failedAt`, because the terminal row can be written after the teammate already produced task progress and the app observed it late;
+- use a small skew tolerance for visible replies, matching the existing `message.timestamp + 5s >= inboxTimestamp` behavior;
+- never accept proof older than the prompt/inbox time unless it is explicitly correlated by the ledger's existing `visibleReplyMessageId`.
+
+This prevents false warnings when a reply/progress existed for the prompt but the terminal failure row was written slightly later.
+
+Performance constraints:
+
+- `TeamDataService` gives member runtime advisory loading only `250ms` per snapshot.
+- The proof reader must support batched member reads, as the current service does.
+- Do not call the proof reader from the delivery hot path.
+- For single-record status reads in Phase 1.3, wrap proof reads with a small budget and fall back to fact-only impact if the budget is exceeded.
+
+## Integration - Member Advisory
+
+Current `buildOpenCodeDeliveryAdvisoryFromRecords()` should become policy-driven.
+
+Pseudo-flow:
+
+```ts
+private buildOpenCodeDeliveryAdvisoryFromRecords(
+ memberName: string,
+ records: readonly OpenCodePromptDeliveryLedgerRecord[],
+ now: number,
+ proofIndex: OpenCodeRuntimeDeliveryProofIndex
+): MemberRuntimeAdvisory | null {
+ const ordered = orderRecords(records);
+ const latestSuccessAt = getLatestSuccessAt(ordered);
+ const latestCandidate = findLatestPotentialError(ordered, now);
+ if (!latestCandidate) return null;
+
+ const proof = this.proofReader.getProofSnapshot({
+ memberName,
+ record: latestCandidate,
+ latestSuccessAt,
+ proofIndex,
+ });
+
+ const decision = decideOpenCodeRuntimeDeliveryAdvisory({
+ record: latestCandidate,
+ proof,
+ nowMs: now,
+ });
+
+ if (decision.action !== 'surface') {
+ return null;
+ }
+
+ return {
+ kind: 'api_error',
+ observedAt: decision.observedAt ?? new Date(now).toISOString(),
+ reasonCode: decision.reasonCode,
+ message: decision.message,
+ };
+}
+```
+
+Important: `defer` returns `null` for member card. The card should not show a temporary "checking" badge from Phase 1.2, because this is a teammate card, not a direct send composer.
+
+## Integration - Notifications And Lead Notice
+
+Replace the current boolean logic in `TeamProvisioningService` with two decisions:
+
+1. a cheap delivery-event impact decision that does not scan inboxes/tasks;
+2. a side-effect decision that preserves current notification scope.
+
+Current risky behavior:
+
+```ts
+const shouldNotifyTerminalFailure =
+ event === 'opencode_prompt_delivery_terminal_failure' && record.status === 'failed_terminal';
+
+if (shouldNotifyTerminalFailure || shouldNotifyActionRequiredRetry) {
+ void this.fireOpenCodeRuntimeDeliveryErrorNotification(record);
+ return;
+}
+```
+
+Do not call the full proof reader from this hot path. `logOpenCodePromptDeliveryEvent()` can run inside relay/watchdog delivery flow, and proof reads may scan inboxes and tasks. The hot path only needs to know:
+
+- no selected reason -> no side effects;
+- hard/action-required reason -> keep existing immediate notification behavior where the event is already notification-eligible;
+- generic proof failure -> schedule delayed proof recheck and do not notify immediately.
+
+Recommended cheap classifier:
+
+```ts
+private classifyOpenCodeRuntimeDeliveryEventImpact(
+ event: string,
+ record: OpenCodePromptDeliveryLedgerRecord
+): OpenCodeRuntimeDeliveryAdvisoryDecision {
+ const reason = selectOpenCodeRuntimeDeliveryReason(record);
+ if (!reason) {
+ return { action: 'suppress', reason: 'no_reason' };
+ }
+
+ const recordTimeMs = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
+ if (isHardOpenCodeRuntimeDeliveryReason(record, reason)) {
+ return {
+ action: 'surface',
+ reason: 'hard_error',
+ severity: 'error',
+ reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
+ message: reason,
+ observedAt: new Date(recordTimeMs).toISOString(),
+ };
+ }
+
+ if (record.status === 'failed_terminal' && isGenericOpenCodeRuntimeDeliveryProofFailure(record, reason)) {
+ const terminalAt = getOpenCodeRuntimeDeliveryTerminalTimeMs(record);
+ return {
+ action: 'defer',
+ reason: 'proof_pending',
+ observedAt: new Date(terminalAt).toISOString(),
+ nextReviewAt: new Date(terminalAt + OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS).toISOString(),
+ };
+ }
+
+ if (isGenericOpenCodeRuntimeDeliveryProofFailure(record, reason)) {
+ return { action: 'suppress', reason: 'not_user_visible' };
+ }
+
+ if (record.status !== 'failed_terminal') {
+ return { action: 'suppress', reason: 'not_user_visible' };
+ }
+
+ // Unknown terminal non-generic failures remain visible, but keep this branch narrow
+ // and covered by tests so proof-only states cannot fall through here.
+ return {
+ action: 'surface',
+ reason: 'hard_error',
+ severity: 'error',
+ reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
+ message: reason,
+ observedAt: new Date(recordTimeMs).toISOString(),
+ };
+}
+```
+
+Recommended event side-effect policy:
+
+```ts
+private decideOpenCodeRuntimeDeliveryEventSideEffects(input: {
+ event: string;
+ record: OpenCodePromptDeliveryLedgerRecord;
+ impact: OpenCodeRuntimeDeliveryAdvisoryDecision;
+}): OpenCodeRuntimeDeliveryEventSideEffectDecision {
+ if (input.impact.action === 'suppress') {
+ return {
+ emitMemberAdvisoryRefresh: false,
+ notifyHuman: false,
+ notifyLead: false,
+ };
+ }
+
+ if (input.impact.action === 'defer') {
+ return {
+ emitMemberAdvisoryRefresh: true,
+ scheduleReviewAt: input.impact.nextReviewAt,
+ notifyHuman: false,
+ notifyLead: false,
+ };
+ }
+
+ const terminalFailureEvent =
+ input.event === 'opencode_prompt_delivery_terminal_failure' &&
+ input.record.status === 'failed_terminal';
+ const actionRequiredBeforeTerminal =
+ input.record.status !== 'failed_terminal' &&
+ isActionRequiredOpenCodeRuntimeDeliveryReason(input.impact.message);
+
+ return {
+ emitMemberAdvisoryRefresh: true,
+ notifyHuman: input.impact.severity === 'error' && (terminalFailureEvent || actionRequiredBeforeTerminal),
+ notifyLead: input.impact.severity === 'error' && (terminalFailureEvent || actionRequiredBeforeTerminal),
+ };
+}
+```
+
+This preserves the current notification blast radius. For example, an attachment-payload terminal record can still surface as an advisory/direct-send diagnostic without suddenly generating a new OS notification path unless the event was already notification-eligible.
+
+Important: deferred generic proof failures still emit `member-advisory` refresh. That refresh is needed to clear stale cached hard advisories or cached warnings immediately when the newest record is now `defer/null`. It is a cache/UI refresh only:
+
+- no desktop notification;
+- no lead notice;
+- do not mark the deferred record as surfaced in the notification/advisory dedupe map;
+- do not let this refresh block the delayed post-grace `surface/warning` refresh.
+
+Prefer separate emit helpers:
+
+```ts
+private emitOpenCodeRuntimeDeliveryAdvisoryRefreshEvent(record: OpenCodePromptDeliveryLedgerRecord): void {
+ // Invalidates card/snapshot caches. Optional short refresh dedupe is ok.
+}
+
+private emitOpenCodeRuntimeDeliveryAdvisorySurfaceEvent(record: OpenCodePromptDeliveryLedgerRecord): void {
+ // Uses existing surface dedupe and can represent a visible advisory.
+}
+```
+
+Do not reuse `emitOpenCodeRuntimeDeliveryAdvisorySurfaceEvent()` for `defer`, because the existing dedupe key includes record id/reason and can suppress the later post-grace warning refresh.
+
+New handler:
+
+```ts
+private async handleOpenCodeRuntimeDeliveryAdvisorySideEffects(
+ event: string,
+ record: OpenCodePromptDeliveryLedgerRecord
+): Promise {
+ const impact = this.classifyOpenCodeRuntimeDeliveryEventImpact(event, record);
+ const effects = this.decideOpenCodeRuntimeDeliveryEventSideEffects({ event, record, impact });
+
+ if (effects.scheduleReviewAt) {
+ this.scheduleOpenCodeRuntimeDeliveryAdvisoryReview(record, effects.scheduleReviewAt);
+ }
+ if (effects.emitMemberAdvisoryRefresh) {
+ if (impact.action === 'defer') {
+ this.emitOpenCodeRuntimeDeliveryAdvisoryRefreshEvent(record);
+ } else {
+ this.emitOpenCodeRuntimeDeliveryAdvisorySurfaceEvent(record);
+ }
+ }
+ if (effects.notifyHuman || effects.notifyLead) {
+ await this.fireOpenCodeRuntimeDeliveryNotificationFromDecision(record, impact, effects);
+ }
+}
+```
+
+`logOpenCodePromptDeliveryEvent()` should call this asynchronously after logging.
+
+Do not block the delivery path on notification writes:
+
+```ts
+void this.handleOpenCodeRuntimeDeliveryAdvisorySideEffects(event, record).catch((error) => {
+ logger.warn(`[${record.teamName}] Failed to handle OpenCode runtime delivery advisory: ${getErrorMessage(error)}`);
+});
+```
+
+### Delayed Review Timer
+
+Add a narrow timer map:
+
+```ts
+private readonly openCodeRuntimeDeliveryAdvisoryReviewTimers = new Map>();
+```
+
+Timer key:
+
+```ts
+private getOpenCodeRuntimeDeliveryAdvisoryReviewKey(record: OpenCodePromptDeliveryLedgerRecord): string {
+ return `${record.teamName}::${record.laneId}::${record.memberName}::${record.id}`;
+}
+```
+
+Scheduler:
+
+```ts
+private scheduleOpenCodeRuntimeDeliveryAdvisoryReview(
+ record: OpenCodePromptDeliveryLedgerRecord,
+ nextReviewAt: string | undefined
+): void {
+ const reviewAtMs = Date.parse(nextReviewAt ?? '');
+ if (!Number.isFinite(reviewAtMs)) return;
+
+ const delayMs = Math.max(500, Math.min(reviewAtMs - Date.now(), 180_000));
+ const key = this.getOpenCodeRuntimeDeliveryAdvisoryReviewKey(record);
+ const existing = this.openCodeRuntimeDeliveryAdvisoryReviewTimers.get(key);
+ if (existing) clearTimeout(existing);
+
+ const timer = setTimeout(() => {
+ this.openCodeRuntimeDeliveryAdvisoryReviewTimers.delete(key);
+ void this.recheckOpenCodeRuntimeDeliveryAdvisory(record).catch((error) => {
+ logger.warn(`[${record.teamName}] Delayed OpenCode advisory recheck failed: ${getErrorMessage(error)}`);
+ });
+ }, delayMs);
+
+ timer.unref?.();
+ this.openCodeRuntimeDeliveryAdvisoryReviewTimers.set(key, timer);
+}
+```
+
+Delayed recheck must re-read current ledger/proof before emitting anything:
+
+```ts
+private async recheckOpenCodeRuntimeDeliveryAdvisory(
+ original: OpenCodePromptDeliveryLedgerRecord
+): Promise {
+ const ledger = this.createOpenCodePromptDeliveryLedger(original.teamName, original.laneId);
+ const current = (await ledger.list()).find((record) => record.id === original.id);
+ if (!current) return;
+
+ const decision = await this.decideOpenCodeRuntimeDeliveryAdvisoryForRecord(current);
+ if (decision.action === 'defer') {
+ this.scheduleOpenCodeRuntimeDeliveryAdvisoryReview(current, decision.nextReviewAt);
+ return;
+ }
+ if (decision.action === 'suppress') {
+ this.emitOpenCodeRuntimeDeliveryAdvisoryRefreshEvent(current);
+ return;
+ }
+
+ this.emitOpenCodeRuntimeDeliveryAdvisorySurfaceEvent(current);
+ // Delayed generic proof warnings do not notify in Phase 1.2.
+ // Delayed review is allowed to refresh cards, not to retro-fire desktop
+ // notifications or lead notices.
+}
+```
+
+Delayed review should emit `member-advisory` only. If a re-read somehow discovers a hard diagnostic that was not present in the original generic event, surface it in the snapshot/card and log it, but do not synthesize a new desktop notification or lead notice from the timer. Notification eligibility remains tied to live delivery events.
+
+This timer is an optimization for visible teams. Correctness must not depend on it:
+
+- if the app restarts, `TeamMemberRuntimeAdvisoryService` recomputes by wall clock;
+- if the timer never fires, the next team snapshot still surfaces the confirmed warning;
+- hard errors still notify immediately.
+
+### Delayed Review Timer Lifecycle
+
+The review timer must follow the same cleanup discipline as `openCodePromptDeliveryWatchdogTimers`.
+
+Add a helper:
+
+```ts
+private clearOpenCodeRuntimeDeliveryAdvisoryReviewTimers(teamName?: string): void {
+ for (const [key, timer] of this.openCodeRuntimeDeliveryAdvisoryReviewTimers) {
+ if (teamName && !key.startsWith(`${teamName}::`)) continue;
+ clearTimeout(timer);
+ this.openCodeRuntimeDeliveryAdvisoryReviewTimers.delete(key);
+ }
+}
+```
+
+Call it from every path that currently clears per-team prompt delivery watchdog timers:
+
+- `cleanupRun(teamName, ...)` and any team stop/cancel cleanup path;
+- permanent team deletion cleanup;
+- service shutdown/dispose cleanup;
+- launch failure cleanup when a partially started team is abandoned.
+
+The timer callback must also revalidate current ownership before emitting:
+
+```ts
+if (!this.teamRunStates.has(original.teamName)) return;
+if (!this.isOpenCodeLaneStillCurrent(original.teamName, original.laneId, original.memberName)) return;
+```
+
+`isOpenCodeLaneStillCurrent()` must compare more than member name and lane id. Include `record.runId` when it is present, because a lane id can be reused across a respawn while the old ledger record still exists on disk.
+
+Use existing state/config readers for the actual implementation. The point is to prevent a delayed proof-missing advisory from firing after:
+
+- the team was stopped;
+- the lane was respawned with a new process;
+- the member was removed or renamed;
+- a launch failed and cleaned up the run.
+
+Do not persist these timers. After app restart, snapshot reads can show a warning if it is still true, but startup must not retro-fire desktop notifications or lead notices for old generic proof gaps. Live event side effects stay event-driven.
+
+## Notification Copy
+
+Hard error human notification:
+
+```txt
+Team : @ hit an OpenCode runtime delivery error while handling #.
+```
+
+Hard error lead notice:
+
+```txt
+System notice: OpenCode teammate @ hit a runtime delivery error while handling #. Reason: . Treat @ as unavailable for that work until retry or restart succeeds. Do not message the human user solely because of this notice unless user action is required.
+```
+
+Generic proof warning:
+
+- no human notification in Phase 1.2;
+- no lead notice in Phase 1.2;
+- member card warning only after grace.
+
+If soft lead notice is added later, use different copy:
+
+```txt
+System notice: OpenCode delivery proof is still missing for @ while handling #. Do not assume the teammate is unavailable. Check task progress or wait for a correlated reply before escalating.
+```
+
+Do not use this copy in Phase 1.2 unless there is a product decision to notify lead for proof gaps.
+
+## Reason Code Mapping
+
+Move or share `classifyRetryReason()` from `TeamMemberRuntimeAdvisoryService`.
+
+Recommended name:
+
+```ts
+export function classifyOpenCodeRuntimeDeliveryReasonCode(
+ message: string | undefined
+): MemberRuntimeAdvisory['reasonCode'];
+```
+
+`MemberRuntimeAdvisory['reasonCode']` already includes `protocol_proof_missing`; do not add a duplicate local enum or renderer-only string. The implementation work is to route generic proof failures to that existing reason code after grace.
+
+Do not let fallback `OpenCode returned an empty assistant turn.` classify as `backend_error`. It should classify as `protocol_proof_missing` after grace.
+
+## Edge Cases
+
+### Hard diagnostic mixed with generic state
+
+Example:
+
+```json
+{
+ "status": "failed_terminal",
+ "responseState": "empty_assistant_turn",
+ "diagnostics": [
+ "Latest assistant message msg_1 failed with APIError - Insufficient credits.",
+ "empty_assistant_turn"
+ ]
+}
+```
+
+Expected:
+
+- immediate `surface/error`;
+- `reasonCode: "quota_exhausted"`;
+- human notification yes;
+- lead notice yes;
+- no grace.
+
+### Late visible reply after terminal
+
+Expected:
+
+- before grace expires: `defer`;
+- after reply appears: `suppress`;
+- member card never shows error;
+- delayed recheck emits `member-advisory` refresh to clear stale cached values if needed.
+
+### Visible reply recovered by observed message id or task refs
+
+Expected:
+
+- suppress when an existing visible reply is recovered by `visibleReplyMessageId`;
+- suppress when an existing visible reply is recovered by matching `taskRefs` and semantic sufficiency;
+- support `direct_child_message_send` correlation;
+- do not require `source: "runtime_delivery"` when the current recovery logic accepts a missing source;
+- do not mutate inbox messages from the advisory snapshot path.
+
+### Plain text reply already materialized
+
+Expected:
+
+- if the ledger already has `visibleReplyCorrelation: "plain_assistant_text"` and a visible reply id/inbox, suppress;
+- if plain text could be materialized but has not been materialized yet, do not write from the advisory path;
+- direct-send/status path may still use the delivery observer to materialize later.
+
+### Late task progress after terminal
+
+Expected:
+
+- suppress only if task id matches record `taskRefs`;
+- author/actor matches member;
+- progress timestamp is after the original prompt/inbox time;
+- no cross-task suppression.
+
+### Newer success after older failure
+
+Expected:
+
+- suppress older failure if newer terminal success exists for same member/lane;
+- this prevents a historical failed row from dominating a recovered member card.
+
+### No proof after grace
+
+Expected:
+
+- `surface/warning`;
+- `reasonCode: "protocol_proof_missing"`;
+- label should render as `OpenCode proof missing`;
+- no desktop notification;
+- no hard lead notice.
+
+### Removed member or stopped lane
+
+Expected:
+
+- removed members are already filtered by caller;
+- stopped lane should not be scanned for fresh advisories;
+- do not revive old stopped-lane errors on active team cards.
+
+### Payload mismatch and attachment payload unavailable
+
+Expected:
+
+- immediate hard error;
+- not delayed as generic proof;
+- these are app/runtime data consistency problems, not late-proof problems.
+
+### Permission blocked
+
+Expected:
+
+- if action required, immediate hard warning/error path;
+- do not retry automatically while blocked;
+- do not classify as proof pending.
+
+### Cache behavior
+
+Current advisory cache TTL is 30 seconds.
+
+Required behavior:
+
+- deferred generic proof returns `null`;
+- defer event emits `member-advisory` refresh immediately to clear stale cached hard/warning advisories;
+- cache may store null for up to TTL after that refresh;
+- delayed `member-advisory` event at grace expiry invalidates cache;
+- runtime reply event already invalidates cache through `emitRuntimeDeliveryReplyAdvisoryRefresh`.
+
+### Worker cache invalidation
+
+This is critical.
+
+`team:getData` normally prefers `team-data-worker`. The worker owns a separate `TeamDataService` instance, and that instance owns a separate `TeamMemberRuntimeAdvisoryService` with its own 30 second member/batch advisory cache. Invalidating only the main-thread advisory service is not enough.
+
+Add a worker request:
+
+```ts
+export interface InvalidateMemberRuntimeAdvisoryPayload {
+ teamName: string;
+ memberName?: string;
+}
+
+export type TeamDataWorkerRequest =
+ | { id: string; op: 'invalidateMemberRuntimeAdvisory'; payload: InvalidateMemberRuntimeAdvisoryPayload }
+ // existing variants
+```
+
+Worker handling:
+
+```ts
+case 'invalidateMemberRuntimeAdvisory': {
+ if (msg.payload.memberName) {
+ teamDataService.invalidateMemberRuntimeAdvisory(msg.payload.teamName, msg.payload.memberName);
+ } else {
+ teamDataService.invalidateTeamRuntimeAdvisories(msg.payload.teamName);
+ }
+ respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
+ break;
+}
+```
+
+Add public invalidators:
+
+```ts
+// TeamDataService
+invalidateMemberRuntimeAdvisory(teamName: string, memberName: string): void {
+ this.memberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
+}
+
+invalidateTeamRuntimeAdvisories(teamName: string): void {
+ this.memberRuntimeAdvisoryService.invalidateTeamAdvisories(teamName);
+}
+
+// TeamMemberRuntimeAdvisoryService
+invalidateTeamAdvisories(teamName: string): void {
+ // clear member cache entries, batch cache, in-flight batch requests, and bump generation
+}
+```
+
+Then update the existing main invalidator wiring:
+
+```ts
+teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
+ teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
+ getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName, memberName);
+});
+```
+
+Also update the `member-advisory` team-change forwarding path to invalidate the worker cache when the event was not emitted through `TeamProvisioningService` in the current process.
+
+Without this, the renderer can receive a `member-advisory` refresh event, call `team:getData`, hit the worker, and still see a stale cached `null` or stale advisory for up to 30 seconds.
+
+`TeamDataWorkerClient.invalidateMemberRuntimeAdvisory()` must also clear in-flight `getTeamData` requests for the team:
+
+```ts
+invalidateMemberRuntimeAdvisory(teamName: string, memberName?: string): void {
+ if (!SAFE_NAME_RE.test(teamName)) return;
+ this.clearTeamDataInFlightForTeam(teamName);
+ this.postBestEffort('invalidateMemberRuntimeAdvisory', { teamName, memberName });
+}
+```
+
+`postBestEffort()` currently returns immediately when the worker has not been created. That is acceptable for advisory invalidation because an uncreated worker has no worker-side advisory cache yet. The main-thread invalidation still must happen first, and `clearTeamDataInFlightForTeam(teamName)` still matters because main may be holding a worker-backed promise that was started before the invalidation event.
+
+Also update `summarizeWorkerRequest()` so diagnostics and logs do not show the new operation as an unknown worker request:
+
+```ts
+case 'invalidateMemberRuntimeAdvisory':
+ return {
+ teamName: request.payload.teamName,
+ memberName: request.payload.memberName,
+ };
+```
+
+When `forwardTeamChange` handles config/meta changes, clear runtime advisories in both main and worker services. Team config changes can change the member roster, provider, model, cwd, lane metadata, or removed status, and a cached advisory from the previous shape should not survive a config invalidation.
+
+Main thread:
+
+```ts
+if (
+ event.type === 'config' &&
+ (event.detail === 'config.json' ||
+ event.detail === 'team.meta.json' ||
+ event.detail === 'members.meta.json')
+) {
+ teamMemberRuntimeAdvisoryService.invalidateTeamAdvisories(event.teamName);
+ getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName);
+}
+```
+
+Do not call both `teamMemberRuntimeAdvisoryService.invalidateTeamAdvisories()` and `teamDataService.invalidateTeamRuntimeAdvisories()` in the current main wiring if they point at the same service instance. Pick one path to avoid double generation bumps in tests. The public `TeamDataService` invalidator still exists for worker and future encapsulated callers.
+
+Worker:
+
+```ts
+case 'invalidateTeamConfig': {
+ teamConfigReader.invalidateTeam(msg.payload.teamName);
+ teamDataService.invalidateMessageFeed(msg.payload.teamName);
+ teamDataService.invalidateTeamRuntimeAdvisories(msg.payload.teamName);
+ respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
+ break;
+}
+```
+
+For `member-advisory` events in `forwardTeamChange`, invalidate before sending the renderer event. The renderer often responds to `member-advisory` by immediately calling `team:getData`; if the event is sent first, the refresh can race and read stale worker cache.
+
+`TeamChangeEvent` currently has `type`, `teamName`, and optional string `detail`, but no structured `memberName`. Do not parse `memberName` from colon-delimited `detail`; member names are not a stable serialization format. In this forwarding path, clear the whole team's advisory cache:
+
+```ts
+if (event.type === 'member-advisory') {
+ teamMemberRuntimeAdvisoryService.invalidateTeamAdvisories(event.teamName);
+ getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName);
+}
+
+safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
+httpServer?.broadcast('team-change', event);
+```
+
+Keep precise member invalidation in call sites that already have a real `memberName`, such as `setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => ...)`.
+
+Do not widen `TeamProvisioningService.setMemberRuntimeAdvisoryInvalidator()` to accept an optional member name just to support team-wide invalidation. That callback is currently a precise member-level contract. Team-wide invalidation belongs in the main `forwardTeamChange` wiring and in `TeamDataService`/worker invalidators.
+
+This does not cancel a worker request that is already running and already awaited by a renderer refresh. Keep the existing `member-advisory` safety refresh that calls `refreshTeamData(teamName)` without dedup. That second fresh read is the guard that overwrites any stale in-flight snapshot that resolves after the advisory event.
+
+### Dedupe behavior
+
+Existing event dedupe key includes record id and reason key. With policy:
+
+- do not mark deferred records as "sent";
+- otherwise delayed surface could be deduped away;
+- dedupe notification/advisory "surface" events only after `surface`;
+- deferred `member-advisory` refreshes may use a separate short refresh dedupe key, but must not share the surface dedupe key.
+
+## Implementation Steps
+
+1. Add `OpenCodeRuntimeDeliveryAdvisoryPolicy.ts`.
+2. Export generic/hard classification helpers from diagnostics or add policy-local helpers.
+3. Move reason-code classification into a shared helper.
+4. Add `OpenCodeRuntimeDeliveryProofReader.ts` and extract read-only proof matching from both `TeamMemberRuntimeAdvisoryService` and the non-mutating parts of `TeamProvisioningService` visible-reply recovery.
+5. Update `TeamMemberRuntimeAdvisoryService` to use proof reader plus policy.
+6. Add advisory invalidation methods to `TeamDataService`, `TeamMemberRuntimeAdvisoryService`, `TeamDataWorkerClient`, and `team-data-worker`.
+7. Update `TeamProvisioningService.logOpenCodePromptDeliveryEvent()` to use cheap event impact classification and narrow side-effect policy.
+8. Add delayed recheck timers in `TeamProvisioningService`.
+9. Keep renderer mostly unchanged, except tests may need copy expectations if `protocol_proof_missing` becomes the normal reason code after grace.
+10. Add tests.
+
+## Tests
+
+### New policy unit tests
+
+File:
+
+```txt
+test/main/services/team/OpenCodeRuntimeDeliveryAdvisoryPolicy.test.ts
+test/main/services/team/opencode/OpenCodeRuntimeDeliveryProofMatching.test.ts
+test/main/services/team/opencode/OpenCodeRuntimeDeliveryProofReader.test.ts
+```
+
+Cases:
+
+```ts
+it('defers recent terminal empty assistant proof failures', () => {});
+it('surfaces old terminal empty assistant proof failures as protocol warnings', () => {});
+it('classifies formatted empty assistant fallback text as protocol proof missing', () => {});
+it('surfaces quota diagnostics immediately even when responseState is empty_assistant_turn', () => {});
+it('does not surface non-terminal generic proof states as hard errors', () => {});
+it('does not let proof-only formatted reasons fall through to unknown hard error', () => {});
+it('suppresses when a visible runtime reply is correlated and timestamp-eligible for the prompt', () => {});
+it('suppresses when a visible reply is recovered by observed message id', () => {});
+it('suppresses when a visible reply is recovered by taskRefs and semantic sufficiency', () => {});
+it('suppresses when the ledger already has plain_assistant_text visible reply proof', () => {});
+it('does not mutate inboxes or ledgers while reading advisory proof snapshots', () => {});
+it('uses prompt inbox time rather than failedAt for late proof timestamp eligibility', () => {});
+it('suppresses when task progress proof is newer than the original prompt inbox time', () => {});
+it('suppresses when a newer terminal success exists', () => {});
+it('treats payload mismatch as immediate hard error', () => {});
+```
+
+Add extraction-preservation tests around existing delivery recovery if they are not already covered:
+
+```ts
+it('keeps visible reply recovery by observed message id behavior unchanged after helper extraction', async () => {});
+it('keeps visible reply recovery by taskRefs behavior unchanged after helper extraction', async () => {});
+it('keeps lead-recipient user fallback candidate behavior unchanged after helper extraction', async () => {});
+```
+
+### Member advisory service tests
+
+Update:
+
+```txt
+test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts
+```
+
+Add/adjust:
+
+```ts
+it('does not show a member advisory for recent generic OpenCode proof failure', async () => {});
+it('shows protocol proof missing after generic proof failure grace expires', async () => {});
+it('keeps hard OpenCode quota advisory immediate inside the grace window', async () => {});
+it('does not cache deferred null past a member-advisory recheck event', async () => {});
+it('invalidates team batch advisory cache when a single member advisory is invalidated', async () => {});
+```
+
+Existing test `classifies terminal OpenCode protocol proof failures as warnings` must be changed to use an old `failedAt`, not `new Date()`.
+
+### Provisioning notification tests
+
+Update:
+
+```txt
+test/main/services/team/TeamProvisioningService.test.ts
+test/main/services/team/TeamProvisioningServiceRelay.test.ts
+```
+
+Add:
+
+```ts
+it('does not fire OpenCode runtime notification for recent terminal empty assistant turn', async () => {});
+it('does not notify lead for recent terminal generic proof failure', async () => {});
+it('emits member-advisory refresh for deferred generic proof failure to clear stale card cache', async () => {});
+it('does not mark deferred member-advisory refresh as surfaced for delayed-warning dedupe', async () => {});
+it('fires hard OpenCode runtime notification for insufficient credits immediately', async () => {});
+it('does not use hard unavailable lead copy for protocol proof missing', async () => {});
+it('schedules a member advisory recheck for deferred generic proof failure', async () => {});
+it('clears delayed advisory recheck timers when the team run is cleaned up', async () => {});
+it('does not emit a delayed advisory for a removed member or replaced lane', async () => {});
+it('does not fire desktop or lead notification from delayed advisory recheck', async () => {});
+it('does not broaden notification scope for attachment payload terminal records', async () => {});
+```
+
+Use fake timers for delayed recheck:
+
+```ts
+vi.useFakeTimers();
+// terminal generic record at t0
+// assert no notification
+await vi.advanceTimersByTimeAsync(120_000);
+// assert member-advisory event emitted, notification still not emitted
+```
+
+### Renderer tests
+
+Most renderer tests should remain unchanged for cards if they already treat `protocol_proof_missing` as warning.
+
+Expected affected tests:
+
+- `test/renderer/utils/memberHelpers.test.ts`
+- `test/renderer/components/team/members/MemberCard.test.tsx`
+- `test/renderer/components/team/members/MemberHoverCard.test.tsx`
+- `test/renderer/components/team/members/MemberDetailHeader.test.tsx`
+
+No Phase 1.2 change should be required for:
+
+- `test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts`
+- `test/renderer/store/teamSlice.test.ts`
+- `OpenCodeDeliveryWarning.test.tsx`
+
+Those belong to Phase 1.3.
+
+### Worker invalidation tests
+
+Update or add:
+
+```txt
+test/main/services/team/TeamDataWorkerClient.test.ts
+test/main/workers/team-data-worker.test.ts
+```
+
+Cases:
+
+```ts
+it('posts invalidateMemberRuntimeAdvisory to the worker', async () => {});
+it('clears worker-side member runtime advisory cache on invalidateMemberRuntimeAdvisory', async () => {});
+it('clears main-thread advisory cache during config/team.meta/members.meta forwarding', async () => {});
+it('clears worker-side advisory cache during invalidateTeamConfig', async () => {});
+it('invalidates worker advisory cache before forwarding member-advisory events to the renderer', async () => {});
+it('summarizes invalidateMemberRuntimeAdvisory worker requests in diagnostics', async () => {});
+```
+
+If there is no worker harness for this path, cover the public invalidator on `TeamDataService` and `TeamMemberRuntimeAdvisoryService`, then add an integration test around `forwardTeamChange` or the provisioning invalidator wiring.
+
+## Verification
+
+Focused:
+
+```bash
+pnpm vitest run test/main/services/team/OpenCodeRuntimeDeliveryAdvisoryPolicy.test.ts
+pnpm vitest run test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts
+pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts --testNamePattern "OpenCode runtime"
+pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts --testNamePattern "OpenCode"
+```
+
+Broader:
+
+```bash
+pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts
+pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts
+pnpm vitest run test/main/services/team/TeamDataService.test.ts
+pnpm vitest run test/renderer/utils/memberHelpers.test.ts
+pnpm typecheck --pretty false
+git diff --check
+```
+
+## Rollout Notes
+
+- Do not change `failed_terminal` semantics.
+- Do not mark failed OpenCode inbox rows read.
+- Do not synthesize teammate replies.
+- Do not treat generic proof failure as participant unavailable.
+- Do not add heuristic proof matching by time or summary.
+- Keep the policy pure and unit tested.
+- Keep notification side effects outside the policy.
+
+## Acceptance Criteria
+
+- A recent `failed_terminal / empty_assistant_turn` record no longer creates a member card error.
+- The same record with `Insufficient credits` still creates an immediate error.
+- A late `runtime_delivery` reply suppresses the advisory.
+- A late same-task progress event suppresses the advisory.
+- A generic proof failure older than grace appears as warning `protocol_proof_missing`, not backend error.
+- Generic proof failure does not create a desktop notification or hard lead notice.
+- Hard OpenCode runtime errors still notify as before.
diff --git a/docs/team-management/opencode-runtime-delivery-status-ux-phase-1-3-plan.md b/docs/team-management/opencode-runtime-delivery-status-ux-phase-1-3-plan.md
new file mode 100644
index 00000000..45230e39
--- /dev/null
+++ b/docs/team-management/opencode-runtime-delivery-status-ux-phase-1-3-plan.md
@@ -0,0 +1,1159 @@
+# OpenCode Runtime Delivery User-Visible Status - Phase 1.3 Plan
+
+## Summary
+
+Extend the Phase 1.2 advisory policy into the direct send/composer runtime delivery status path.
+
+Phase 1.2 fixes member cards, snapshots, notifications, and lead notices. Phase 1.3 makes the direct user send UX use the same user-impact classification, so a temporary generic proof gap does not show as `OpenCode runtime delivery failed` while the app is still inside the late-proof window.
+
+Recommended scope: 🎯 8 🛡️ 8 🧠 8 - roughly `650-900` changed lines including tests.
+
+## Why This Is Separate
+
+The direct send path has an existing public-ish shared type:
+
+```ts
+SendMessageResult.runtimeDelivery
+OpenCodeRuntimeDeliveryStatus
+```
+
+Renderer code currently maps:
+
+```ts
+runtimeDelivery.delivered === false -> failed warning
+runtimeDelivery.responsePending === true -> pending warning
+```
+
+Changing `delivered` semantics directly would be risky because it is already used by store actions and tests. Phase 1.3 should add a user-visible impact field while preserving the old ledger fact fields for compatibility.
+
+## Current Direct Send Paths
+
+There are two runtime status entry points:
+
+1. Immediate send result path:
+
+```txt
+src/main/ipc/teams.ts
+handleSendMessage()
+ -> provisioning.relayOpenCodeMemberInboxMessages()
+ -> result.runtimeDelivery = relay.lastDelivery
+```
+
+2. Later polling path:
+
+```txt
+src/main/services/team/TeamProvisioningService.ts
+getOpenCodeRuntimeDeliveryStatus()
+ -> toOpenCodeRuntimeDeliveryStatus(record)
+```
+
+Renderer consumers:
+
+```txt
+src/renderer/store/slices/teamSlice.ts
+src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts
+src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx
+src/renderer/components/team/messages/MessageComposer.tsx
+src/renderer/components/team/dialogs/SendMessageDialog.tsx
+```
+
+Important: both backend paths must use the same user-impact contract. If only polling is fixed, the initial composer warning can still flash the old failure text.
+
+## Shared Type Extension
+
+Extend `SendMessageResult.runtimeDelivery` in `src/shared/types/team.ts`.
+
+Recommended additive fields:
+
+```ts
+export type OpenCodeRuntimeDeliveryUserVisibleState =
+ | 'none'
+ | 'checking'
+ | 'warning'
+ | 'error';
+
+export interface OpenCodeRuntimeDeliveryUserVisibleImpact {
+ state: OpenCodeRuntimeDeliveryUserVisibleState;
+ reasonCode?: MemberRuntimeAdvisory['reasonCode'];
+ message?: string;
+ observedAt?: string;
+ nextReviewAt?: string;
+}
+```
+
+Inside `SendMessageResult.runtimeDelivery`:
+
+```ts
+userVisibleImpact?: OpenCodeRuntimeDeliveryUserVisibleImpact;
+```
+
+Why a nested object:
+
+- avoids overloading `delivered`;
+- keeps old fields readable for debugging;
+- allows renderer to prefer the new impact but fall back to old behavior;
+- makes Phase 1.3 backwards compatible with older IPC payloads during development.
+
+## State Semantics
+
+### `none`
+
+No user warning should be shown.
+
+Examples:
+
+- successful visible reply proof;
+- newer success suppressed older failure;
+- late visible reply or task progress proof exists.
+
+### `checking`
+
+The ledger may already say terminal, but the user-facing policy is still in the grace window for generic proof.
+
+Examples:
+
+- recent `failed_terminal / empty_assistant_turn`;
+- recent `failed_terminal / prompt_delivered_no_assistant_message`;
+- recent `failed_terminal / visible_reply_still_required`;
+- UI timeout pending;
+- queued behind older OpenCode delivery.
+
+Renderer copy should be non-scary:
+
+```txt
+OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.
+```
+
+### `warning`
+
+Grace expired and proof is still missing, but this is not a hard provider/runtime error.
+
+Examples:
+
+- old `failed_terminal / empty_assistant_turn` with no late proof;
+- old `failed_terminal / non_visible_tool_without_task_progress` with no same-task progress.
+
+Renderer copy:
+
+```txt
+OpenCode reply could not be verified. Message was saved to inbox, but the app did not find a correlated reply or progress proof.
+```
+
+If there is a specific message:
+
+```txt
+OpenCode reply could not be verified. Message was saved to inbox, but the app did not find a correlated reply or progress proof. Detail: OpenCode returned an empty assistant turn.
+```
+
+### `error`
+
+Hard delivery error. This is the old scary warning path and remains valid.
+
+Examples:
+
+- insufficient credits;
+- invalid API key;
+- runtime bridge unavailable;
+- payload mismatch;
+- attachment payload unavailable;
+- recipient unavailable/removed for the direct send recovery path.
+
+Recipient unavailable/removed should not create a member-card runtime advisory or notification by itself. For direct send UX it can still be an `error` impact so the draft is preserved and the user can choose another recipient.
+
+Renderer copy:
+
+```txt
+OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.
+```
+
+## Backend Mapping
+
+Add a converter from Phase 1.2 decision to shared `userVisibleImpact`.
+
+There are two backend mapping modes:
+
+1. **Full status mapping** for explicit status reads and polling. This may read proof sources with a small budget.
+2. **Immediate result mapping** for `sendMessage` IPC. This must stay cheap and should not scan tasks/inboxes before returning the send result.
+
+Example:
+
+```ts
+function toOpenCodeRuntimeDeliveryUserVisibleImpact(
+ decision: OpenCodeRuntimeDeliveryAdvisoryDecision
+): OpenCodeRuntimeDeliveryUserVisibleImpact {
+ if (decision.action === 'suppress') {
+ return { state: 'none' };
+ }
+ if (decision.action === 'defer') {
+ return {
+ state: 'checking',
+ observedAt: decision.observedAt,
+ nextReviewAt: decision.nextReviewAt,
+ };
+ }
+ return {
+ state: decision.severity === 'error' ? 'error' : 'warning',
+ reasonCode: decision.reasonCode,
+ message: decision.message,
+ observedAt: decision.observedAt,
+ };
+}
+```
+
+Special immediate statuses that may not have a ledger record:
+
+```ts
+function getImmediateOpenCodeRuntimeDeliveryImpact(input: {
+ delivered?: boolean;
+ responsePending?: boolean;
+ reason?: string;
+ diagnostics?: string[];
+ queuedBehindMessageId?: string;
+}): OpenCodeRuntimeDeliveryUserVisibleImpact {
+ const observedAt = new Date().toISOString();
+ if (input.responsePending === true) {
+ return { state: 'checking', observedAt };
+ }
+ if (input.queuedBehindMessageId) {
+ return { state: 'checking', observedAt };
+ }
+ if (input.reason === 'opencode_runtime_delivery_ui_timeout_pending') {
+ return { state: 'checking', observedAt };
+ }
+ if (input.reason === 'opencode_delivery_response_pending') {
+ return { state: 'checking', observedAt };
+ }
+ if (
+ input.reason === 'opencode_runtime_not_active' &&
+ (input.diagnostics ?? []).some((line) =>
+ line.toLowerCase().includes('will be retried after runtime check-in')
+ )
+ ) {
+ return { state: 'checking', observedAt };
+ }
+ if (input.delivered === false) {
+ return { state: 'error', message: input.reason, observedAt };
+ }
+ return { state: 'none' };
+}
+```
+
+Do not use the immediate fallback when a full ledger status read is available. Ledger plus policy is authoritative there.
+
+For immediate `sendMessage` responses, use cheap ledger-fact classification first and leave full proof reconciliation to the later status poll or member-advisory refresh.
+
+## Service Changes
+
+### `TeamProvisioningService.getOpenCodeRuntimeDeliveryStatus`
+
+Current:
+
+```ts
+if (record) {
+ return this.toOpenCodeRuntimeDeliveryStatus(record);
+}
+```
+
+Recommended:
+
+```ts
+if (record) {
+ return await this.toOpenCodeRuntimeDeliveryStatus(record);
+}
+```
+
+Make `toOpenCodeRuntimeDeliveryStatus` async, or pass in a precomputed impact.
+
+Example:
+
+```ts
+private async toOpenCodeRuntimeDeliveryStatus(
+ record: OpenCodePromptDeliveryLedgerRecord
+): Promise {
+ const base = this.toOpenCodeRuntimeDeliveryStatusFacts(record);
+ const decision = await this.decideOpenCodeRuntimeDeliveryAdvisoryForRecord(record, {
+ proofReadBudgetMs: 750,
+ });
+ return {
+ ...base,
+ userVisibleImpact: toOpenCodeRuntimeDeliveryUserVisibleImpact(decision),
+ };
+}
+```
+
+If proof reading exceeds the budget, fall back conservatively:
+
+- hard/action-required reason -> `error`;
+- recent generic terminal proof gap -> `checking`;
+- old generic terminal proof gap -> `warning`;
+- responded or newer success visible in the ledger -> `none`.
+
+Keep fact semantics unchanged:
+
+```ts
+const failed = record.status === 'failed_terminal';
+return {
+ delivered: !failed,
+ responsePending: !failed && !responded,
+ ledgerStatus: record.status,
+ responseState: record.responseState,
+ reason: record.lastReason ?? undefined,
+ diagnostics: record.diagnostics,
+};
+```
+
+The renderer should no longer interpret these fact fields directly when `userVisibleImpact` exists.
+
+### Immediate `sendMessage` IPC result
+
+In `src/main/ipc/teams.ts`, after relay:
+
+```ts
+result.runtimeDelivery = {
+ providerId: 'opencode',
+ attempted: true,
+ delivered: delivery.delivered,
+ responsePending: delivery.responsePending,
+ acceptanceUnknown: delivery.acceptanceUnknown,
+ responseState: delivery.responseState,
+ ledgerStatus: delivery.ledgerStatus,
+ visibleReplyMessageId: delivery.visibleReplyMessageId,
+ visibleReplyCorrelation: delivery.visibleReplyCorrelation,
+ reason: delivery.reason,
+ diagnostics: delivery.diagnostics,
+};
+```
+
+This path should ask provisioning to decorate the delivery with impact, but it should not run the full proof reader. The user is waiting for the send call to return, and the relay path may already have done substantial I/O.
+
+Important timestamp caveat: `OpenCodeMemberInboxDelivery` does not currently carry `failedAt` or `updatedAt`. If immediate classification needs exact grace-age, either:
+
+- read the single ledger record by `ledgerRecordId` and `laneId` without scanning inboxes/tasks; or
+- conservatively classify generic terminal proof failures as `checking` and let `getOpenCodeRuntimeDeliveryStatus()` correct the state on the next poll.
+
+Prefer the conservative fallback unless a single-record ledger read is already cheap in the call site.
+
+Recommended helper:
+
+```ts
+async getOpenCodeRuntimeDeliveryImpactForResult(input: {
+ teamName: string;
+ delivery: OpenCodeMemberInboxDelivery;
+}): Promise
+```
+
+Implementation:
+
+```ts
+async getOpenCodeRuntimeDeliveryImpactForResult(input: {
+ teamName: string;
+ delivery: OpenCodeMemberInboxDelivery;
+}): Promise {
+ if (input.delivery.responsePending === true) {
+ return { state: 'checking' };
+ }
+
+ const factImpact = getImmediateOpenCodeRuntimeDeliveryImpact({
+ delivered: input.delivery.delivered,
+ responsePending: input.delivery.responsePending,
+ reason: input.delivery.reason,
+ });
+
+ if (factImpact.state !== 'error') {
+ return factImpact;
+ }
+
+ // If the immediate delivery result carries generic terminal proof failure facts,
+ // report checking during the grace window rather than hard failure.
+ const deliveryRecordFacts = {
+ ledgerStatus: input.delivery.ledgerStatus,
+ responseState: input.delivery.responseState,
+ reason: input.delivery.reason,
+ diagnostics: input.delivery.diagnostics ?? [],
+ };
+ // Without record timestamps, generic terminal proof gaps should become checking,
+ // not warning. The next explicit status poll can use the full record time.
+ return classifyOpenCodeRuntimeDeliveryFactsForImmediateUx(deliveryRecordFacts);
+}
+```
+
+If a caller needs exact suppression because a late proof already exists, it should call `getOpenCodeRuntimeDeliveryStatus()` after the send result. The immediate result can temporarily say `checking`; it should not temporarily say hard failed for generic proof gaps.
+
+Then IPC:
+
+```ts
+const userVisibleImpact = await provisioning.getOpenCodeRuntimeDeliveryImpactForResult({
+ teamName: tn,
+ delivery,
+});
+
+result.runtimeDelivery = {
+ ...oldFacts,
+ userVisibleImpact,
+};
+```
+
+## Renderer Diagnostics
+
+Update:
+
+```txt
+src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts
+```
+
+Preferred logic:
+
+```ts
+export function buildOpenCodeRuntimeDeliveryDiagnostics(
+ result: SendMessageResult
+): OpenCodeRuntimeDeliveryDiagnostics {
+ const runtimeDelivery = result.runtimeDelivery;
+ if (runtimeDelivery?.attempted !== true) {
+ return { warning: null, debugDetails: null };
+ }
+
+ const impact = runtimeDelivery.userVisibleImpact;
+ if (impact) {
+ return buildDiagnosticsFromUserVisibleImpact(result, impact);
+ }
+
+ return buildLegacyDiagnostics(result);
+}
+```
+
+Impact mapping:
+
+```ts
+function buildDiagnosticsFromUserVisibleImpact(
+ result: SendMessageResult,
+ impact: OpenCodeRuntimeDeliveryUserVisibleImpact
+): OpenCodeRuntimeDeliveryDiagnostics {
+ if (impact.state === 'none') {
+ return { warning: null, debugDetails: null };
+ }
+
+ if (impact.state === 'checking') {
+ return {
+ warning: CHECKING_WARNING,
+ debugDetails: buildDebugDetails(result),
+ };
+ }
+
+ if (impact.state === 'warning') {
+ const detail = formatOpenCodeRuntimeDeliveryFailureReason(impact.message);
+ return {
+ warning: detail ? `${PROOF_WARNING} Detail: ${detail}` : PROOF_WARNING,
+ debugDetails: buildDebugDetails(result),
+ };
+ }
+
+ const detail = formatOpenCodeRuntimeDeliveryFailureReason(
+ impact.message ?? result.runtimeDelivery?.reason
+ );
+ return {
+ warning: detail ? `${FAILED_WARNING} Reason: ${detail}` : FAILED_WARNING,
+ debugDetails: buildDebugDetails(result),
+ };
+}
+```
+
+Recommended copy constants:
+
+```ts
+const CHECKING_WARNING =
+ 'OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.';
+
+const PROOF_WARNING =
+ 'OpenCode reply could not be verified. Message was saved to inbox, but the app did not find a correlated reply or progress proof.';
+```
+
+Keep the old fallback for safety:
+
+```ts
+function buildLegacyDiagnostics(result: SendMessageResult): OpenCodeRuntimeDeliveryDiagnostics {
+ // Current logic.
+}
+```
+
+## Renderer Success/Failure Semantics
+
+Update renderer code that currently treats `delivered === false` as a terminal UX failure.
+
+Current examples:
+
+```ts
+// teamSlice.ts
+const runtimeDeliveryFailed =
+ result.runtimeDelivery?.attempted === true && result.runtimeDelivery.delivered === false;
+
+// SendMessageDialog.tsx
+if (
+ result?.runtimeDelivery?.attempted === true &&
+ result.runtimeDelivery.delivered === false
+) {
+ return;
+}
+```
+
+After Phase 1.3, `delivered === false` is a ledger fact, not a UX decision. Use `userVisibleImpact` first:
+
+```ts
+function isOpenCodeRuntimeDeliveryHardUxFailure(
+ runtimeDelivery: SendMessageResult['runtimeDelivery'] | undefined
+): boolean {
+ if (runtimeDelivery?.attempted !== true) {
+ return false;
+ }
+ if (runtimeDelivery.userVisibleImpact) {
+ return runtimeDelivery.userVisibleImpact.state === 'error';
+ }
+ return runtimeDelivery.delivered === false;
+}
+```
+
+Use this helper for:
+
+- `lastSendMessageResult` decision in `teamSlice.ts`;
+- draft-clearing decision in `SendMessageDialog.tsx`;
+- pending-send restore/finalize decision in `MessageComposer.tsx`;
+- any test that currently uses `delivered === false` as "do not clear draft".
+
+Expected UX:
+
+- `checking` clears the draft like a saved send, because the message is persisted and still being observed;
+- `warning` also should not invite blind duplicate resend;
+- `error` preserves the draft for user recovery, preserving the existing hard-failure behavior;
+- legacy payloads without `userVisibleImpact` keep the old `delivered === false` behavior.
+
+`MessageComposer.tsx` currently computes pending-send failure from:
+
+```ts
+const failed = sendError !== null || sendDebugDetails?.delivered === false;
+```
+
+That must become user-visible-impact aware. Otherwise a terminal generic proof gap with `userVisibleState: "checking"` and `delivered: false` will restore the draft even though the message was saved and is still being observed.
+
+Recommended helper:
+
+```ts
+function isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails(
+ debugDetails: OpenCodeRuntimeDeliveryDebugDetails | null | undefined
+): boolean {
+ if (!debugDetails) return false;
+ if (debugDetails.userVisibleState) {
+ return debugDetails.userVisibleState === 'error';
+ }
+ return debugDetails.delivered === false;
+}
+```
+
+Use it only for the optimistic draft restore path. If a delayed status poll later changes from `checking` to `error`, do not resurrect an old draft automatically; by then the saved user message is already in the conversation and restoring a stale composer draft would look like a duplicate-send prompt.
+
+Pending-reply clearing needs a different helper.
+
+Current paths:
+
+- `src/renderer/components/team/messages/MessagesPanel.tsx`
+- `src/renderer/components/team/TeamDetailView.tsx`
+
+They currently clear `pendingRepliesByMember` when `runtimeDelivery.delivered === false`. After Phase 1.3:
+
+```ts
+function shouldClearPendingReplyForOpenCodeRuntimeDelivery(
+ runtimeDelivery: SendMessageResult['runtimeDelivery'] | undefined
+): boolean {
+ if (runtimeDelivery?.attempted !== true) {
+ return false;
+ }
+ if (runtimeDelivery.userVisibleImpact) {
+ return (
+ runtimeDelivery.userVisibleImpact.state === 'warning' ||
+ runtimeDelivery.userVisibleImpact.state === 'error'
+ );
+ }
+ return runtimeDelivery.delivered === false;
+}
+```
+
+Expected pending behavior:
+
+- `checking` keeps pending reply, because a real reply can still arrive;
+- `none` clears only through the existing visible-reply reconciliation;
+- `warning` clears pending reply because the live reply could not be verified after grace;
+- `error` clears pending reply because live delivery failed;
+- legacy payloads keep current `delivered === false` clearing behavior.
+
+## Debug Details
+
+Extend renderer debug details to include impact:
+
+```ts
+export interface OpenCodeRuntimeDeliveryDebugDetails {
+ messageId: string;
+ statusMessageId: string | null;
+ ledgerRecordId: string | null;
+ laneId: string | null;
+ queuedBehindMessageId: string | null;
+ providerId: string;
+ delivered: boolean | null;
+ responsePending: boolean | null;
+ responseState: string | null;
+ ledgerStatus: string | null;
+ acceptanceUnknown: boolean | null;
+ reason: string | null;
+ diagnostics: string[];
+ userVisibleState: string | null;
+ userVisibleReasonCode: string | null;
+ userVisibleMessage: string | null;
+ userVisibleObservedAt: string | null;
+ userVisibleNextReviewAt: string | null;
+}
+```
+
+This keeps support/debugging transparent for visible warning/checking/error states. For `userVisibleImpact.state === 'none'`, return `debugDetails: null` so the store clears stale hidden runtime diagnostics.
+
+Update `formatOpenCodeRuntimeDeliveryDebugDetails()` as well as the debug details builder. The expandable JSON/details view should include the user-visible impact fields when a warning/checking/error is visible, otherwise support logs will show `ledgerStatus: failed_terminal` without the reason the UI chose not to render it as a hard failure.
+
+Also update the expanded details grid in `OpenCodeDeliveryWarning.tsx` to render:
+
+- `statusMessageId`;
+- `ledgerRecordId`;
+- `laneId`;
+- `queuedBehindMessageId`;
+- `userVisibleState`;
+- `userVisibleReasonCode`;
+- `userVisibleMessage`;
+- `userVisibleObservedAt`;
+- `userVisibleNextReviewAt`.
+
+Do not attach debug details for `state: "none"`:
+
+```ts
+if (runtimeDelivery.userVisibleImpact?.state === 'none') {
+ return { warning: null, debugDetails: null };
+}
+```
+
+This matters for stale UI cleanup. A hidden `none` state with non-null debug details can keep `OpenCodeDeliveryWarning` mounted, keep polling dependencies alive, or leave a collapsed "delivery details" affordance with no user-facing warning.
+
+`messageId` should remain the original user-sent inbox row id because `clearSendMessageRuntimeDiagnostics(messageId)` and visible-reply reconciliation use it. `statusMessageId` is the id to poll:
+
+```ts
+const statusMessageId =
+ runtimeDelivery.queuedBehindMessageId?.trim() ||
+ result.messageId;
+```
+
+Do not replace `debugDetails.messageId` with `queuedBehindMessageId`; that breaks clearing the warning for the original send row. Use `statusMessageId` only for `getOpenCodeRuntimeDeliveryStatus()`.
+
+## Warning Delay Logic
+
+Update:
+
+```txt
+src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx
+```
+
+Current delay logic only delays when:
+
+```ts
+debugDetails?.responsePending === true && debugDetails.delivered !== false
+```
+
+That will not work for Phase 1.3 because a generic terminal ledger fact can be:
+
+```ts
+delivered: false
+ledgerStatus: 'failed_terminal'
+userVisibleState: 'checking'
+```
+
+Change the delay condition to prefer user-visible state:
+
+```ts
+const delayPendingWarning =
+ debugDetails?.userVisibleState === 'checking' ||
+ (debugDetails?.userVisibleState == null &&
+ debugDetails?.responsePending === true &&
+ debugDetails.delivered !== false);
+```
+
+This keeps the existing legacy behavior and prevents a terminal-generic proof gap from flashing even as a non-scary checking warning.
+
+## Status Polling Logic
+
+Update:
+
+```txt
+src/renderer/components/team/messages/MessagesPanel.tsx
+```
+
+Current polling starts only when:
+
+```ts
+debugDetails?.responsePending === true
+```
+
+That is insufficient after Phase 1.3. A terminal generic proof gap should have:
+
+```ts
+responsePending: false
+ledgerStatus: 'failed_terminal'
+userVisibleState: 'checking'
+```
+
+Change the polling gate:
+
+```ts
+const messageId = debugDetails?.messageId;
+const statusMessageId = debugDetails?.statusMessageId || messageId;
+const shouldPollRuntimeDeliveryStatus =
+ debugDetails?.responsePending === true ||
+ debugDetails?.userVisibleState === 'checking';
+
+if (!messageId || !statusMessageId || sendMessageRuntimeReplyVisible || !shouldPollRuntimeDeliveryStatus) {
+ return;
+}
+```
+
+Update the effect dependencies to include:
+
+- `sendMessageDebugDetails?.statusMessageId`;
+- `sendMessageDebugDetails?.userVisibleState`;
+- `sendMessageDebugDetails?.userVisibleNextReviewAt`.
+
+Without this, the composer can show a checking warning forever after the immediate send result, because the follow-up `getOpenCodeRuntimeDeliveryStatus()` call never runs.
+
+When calling `refreshSendMessageRuntimeDeliveryStatus`, pass both ids or add a small wrapper:
+
+```ts
+void refreshSendMessageRuntimeDeliveryStatus(teamName, {
+ messageId,
+ statusMessageId,
+});
+```
+
+The store should still update/clear only if `state.sendMessageDebugDetails?.messageId === messageId`. The IPC status lookup uses `statusMessageId`.
+
+If `statusMessageId !== messageId`, treat the returned status as blocker status, not as the final status of the original send:
+
+- `checking` keeps the original send in checking;
+- `none` means the blocker cleared, so schedule or attempt a follow-up status read for the original `messageId`;
+- `warning` or `error` on the blocker should not be copied as the new message's warning/error;
+- if the original `messageId` still has no ledger record, keep checking until the stale-check window expires.
+
+This prevents a hard failure from an older active delivery row being shown as the failure for a newly queued message.
+
+Stop polling when impact becomes terminal from a UX perspective:
+
+- `none` clears warning/debug details;
+- `warning` stops polling because the proof grace window has expired;
+- `error` stops polling because delivery is a hard failure;
+- legacy `responsePending: false` keeps current behavior.
+
+Do not keep polling a `warning` forever waiting for a late reply. Late runtime replies already reach the renderer through message-feed/member-advisory refresh paths; status polling should only cover the short "checking" window.
+
+## Polling And Refresh Behavior
+
+Current store behavior:
+
+- send action stores warning/debug details from immediate `runtimeDelivery`;
+- `refreshSendMessageRuntimeDeliveryStatus()` polls `getOpenCodeRuntimeDeliveryStatus`;
+- `OpenCodeDeliveryWarning` delays pending warning display.
+
+Phase 1.3 expected behavior:
+
+1. Direct send returns terminal generic proof inside grace:
+ - ledger facts: `delivered: false`, `ledgerStatus: "failed_terminal"`
+ - impact: `{ state: "checking", nextReviewAt }`
+ - renderer warning: checking copy, not failed copy.
+
+2. Poll before grace expires:
+ - still checking.
+
+3. Late reply arrives:
+ - impact becomes `none`;
+ - warning clears.
+
+4. Grace expires without proof:
+ - impact becomes `warning`;
+ - copy changes to proof warning.
+
+The polling caller must not rely only on `responsePending`.
+
+If `nextReviewAt` is present, add one extra status refresh just after that time in addition to the existing short poll cadence. The current fixed delays are `[15s, 45s, 90s]`; if the backend grace is around 120s, those fixed timers can all fire before the proof window closes and leave `checking` visible forever.
+
+Clamp the extra delay to a sane range. Do not schedule an arbitrary long timer from IPC data:
+
+```ts
+const nextReviewDelayMs = Number.isFinite(Date.parse(nextReviewAt ?? ''))
+ ? Math.max(1_000, Math.min(Date.parse(nextReviewAt!) - Date.now() + 500, 180_000))
+ : null;
+```
+
+Schedule it only when `userVisibleState === 'checking'`. De-dupe it against existing fixed delays if it is within roughly 500ms of one of them.
+
+This is an optimization only. Correctness still comes from explicit status calls and from team/message refresh events, but this timer prevents the common "checking never transitions to proof warning" case.
+
+Handle status failures inside `refreshSendMessageRuntimeDeliveryStatus()`:
+
+```ts
+try {
+ const status = await unwrapIpc(...);
+ if (!status) {
+ maybeClearStaleCheckingDiagnostics(normalizedMessageId);
+ return;
+ }
+ // existing diagnostic update
+} catch (error) {
+ logger.debug('OpenCode runtime delivery status refresh failed', error);
+ maybeClearStaleCheckingDiagnostics(normalizedMessageId);
+}
+```
+
+`maybeClearStaleCheckingDiagnostics()` should only clear non-terminal `checking` after the backend review window is already stale, for example `now > userVisibleNextReviewAt + 60s`. If `userVisibleNextReviewAt` is absent, fall back to a conservative max checking age from `userVisibleObservedAt`, for example three minutes. It must not convert a transient status miss into a hard delivery error.
+
+## Edge Cases
+
+### Backward-compatible IPC
+
+If `userVisibleImpact` is absent:
+
+- use current legacy behavior;
+- do not crash browser mode or tests with older fixtures.
+
+### Immediate hard error without ledger
+
+Example:
+
+```json
+{
+ "attempted": true,
+ "delivered": false,
+ "reason": "opencode_runtime_message_bridge_unavailable"
+}
+```
+
+Expected:
+
+- impact fallback `error`;
+- old failed warning still appears.
+
+### Runtime not active but bootstrap still checking in
+
+Example:
+
+```json
+{
+ "attempted": true,
+ "delivered": false,
+ "reason": "opencode_runtime_not_active",
+ "diagnostics": [
+ "OpenCode runtime bootstrap is not confirmed for jack. Message was saved and will be retried after runtime check-in."
+ ]
+}
+```
+
+Expected:
+
+- impact `checking`;
+- draft clears like a saved send;
+- pending reply remains;
+- no hard failed copy.
+
+If `opencode_runtime_not_active` has no retry/check-in diagnostic and the lane is stopped/deleted, keep `error` for direct-send recovery.
+
+### UI timeout pending
+
+Example:
+
+```json
+{
+ "attempted": true,
+ "delivered": true,
+ "responsePending": true,
+ "reason": "opencode_runtime_delivery_ui_timeout_pending"
+}
+```
+
+Expected:
+
+- impact `checking`;
+- pending/checking warning;
+- no failed copy.
+
+### Terminal generic proof inside grace
+
+Example:
+
+```json
+{
+ "attempted": true,
+ "delivered": false,
+ "responsePending": false,
+ "responseState": "empty_assistant_turn",
+ "ledgerStatus": "failed_terminal",
+ "reason": "empty_assistant_turn",
+ "userVisibleImpact": {
+ "state": "checking",
+ "nextReviewAt": "2026-05-09T07:54:30.998Z"
+ }
+}
+```
+
+Expected:
+
+- renderer shows checking, not failed;
+- debug details still show `ledgerStatus: "failed_terminal"`.
+
+### Terminal generic proof after grace
+
+Expected:
+
+- impact `warning`;
+- renderer shows proof warning;
+- not hard failed warning.
+
+### Hard diagnostic mixed with generic state
+
+Expected:
+
+- impact `error`;
+- renderer shows failed warning with provider/auth/quota reason;
+- no checking state.
+
+### Late proof between immediate send and polling
+
+Expected:
+
+- immediate result may show checking;
+- next poll returns `none`;
+- warning clears.
+
+### Terminal facts with none impact
+
+Example:
+
+```json
+{
+ "attempted": true,
+ "delivered": false,
+ "ledgerStatus": "failed_terminal",
+ "userVisibleImpact": {
+ "state": "none"
+ }
+}
+```
+
+Expected:
+
+- renderer clears warning;
+- renderer clears debug details;
+- draft remains cleared because the send was saved;
+- pending reply clears only through existing visible-reply reconciliation, not through a hard-failure path.
+
+### Message queued behind older active delivery
+
+Expected:
+
+- impact `checking`;
+- copy should not say failed;
+- debug reason can include older message id.
+- `debugDetails.messageId` remains the newly sent user message id;
+- `debugDetails.statusMessageId` uses `queuedBehindMessageId` while the older active delivery is blocking;
+- status polling must not clear the original send warning only because the queued-behind active record is not the same message id.
+
+### Acceptance unknown
+
+Expected:
+
+- impact `checking` while observe-first watchdog can still recover;
+- if later hard failure, impact `error`;
+- if later generic terminal and inside grace, still `checking`.
+
+### Status request fails during checking
+
+Expected:
+
+- keep the current checking state for one retry window;
+- do not convert a transient IPC/status error into `OpenCode delivery error`;
+- surface hard failure only when backend status returns `error` or legacy hard facts without `userVisibleImpact`.
+- if the backend status remains unavailable past `userVisibleNextReviewAt + 60s`, clear the checking diagnostic instead of leaving a permanent warning;
+- if `userVisibleNextReviewAt` is missing, use a conservative max age from `userVisibleObservedAt`.
+
+### Warning after grace with late reply later
+
+Expected:
+
+- proof warning can appear after grace;
+- a later visible correlated reply still clears advisory through the normal message/member refresh path;
+- no desktop notification or lead notice is retro-fired for the previous proof warning.
+
+## Tests
+
+### Shared type tests
+
+No runtime test required for type-only additions, but compile must pass:
+
+```bash
+pnpm typecheck --pretty false
+```
+
+### Backend status tests
+
+Update:
+
+```txt
+test/main/services/team/TeamProvisioningService.test.ts
+test/main/services/team/TeamProvisioningServiceRelay.test.ts
+```
+
+Add:
+
+```ts
+it('decorates getOpenCodeRuntimeDeliveryStatus with checking impact for recent generic terminal proof failure', async () => {});
+it('decorates getOpenCodeRuntimeDeliveryStatus with warning impact after proof grace expires', async () => {});
+it('decorates getOpenCodeRuntimeDeliveryStatus with error impact for quota diagnostics', async () => {});
+it('decorates immediate sendMessage runtimeDelivery with checking impact for generic terminal proof failure', async () => {});
+it('decorates immediate runtime_not_active bootstrap check-in retry as checking', async () => {});
+it('decorates stopped runtime_not_active without retry diagnostic as error', async () => {});
+it('returns none impact when late visible runtime reply supersedes terminal proof failure', async () => {});
+it('returns none impact when late visible reply is recovered by observed message id or taskRefs', async () => {});
+it('returns none impact when ledger already has plain_assistant_text visible reply proof', async () => {});
+```
+
+### Renderer diagnostics tests
+
+Update:
+
+```txt
+test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts
+```
+
+Existing tests that expect failed copy for terminal generic states should split:
+
+```ts
+it('shows checking copy for terminal empty assistant turn while impact is checking', () => {});
+it('shows proof warning for terminal empty assistant turn when impact is warning', () => {});
+it('keeps legacy failed copy when impact is absent', () => {});
+it('shows hard failed copy when impact is error', () => {});
+it('returns null warning and null debug details when impact is none', () => {});
+it('formats user-visible impact fields in debug details for checking and warning states', () => {});
+it('preserves original messageId and stores statusMessageId for queued-behind delivery', () => {});
+```
+
+### Store tests
+
+Update:
+
+```txt
+test/renderer/store/teamSlice.test.ts
+```
+
+Cases:
+
+```ts
+it('updates pending OpenCode diagnostics to checking when terminal generic proof is still in grace', async () => {});
+it('updates checking OpenCode diagnostics to proof warning after grace impact is warning', async () => {});
+it('clears OpenCode diagnostics when status impact becomes none', async () => {});
+it('does not retain hidden debug details when impact becomes none', async () => {});
+it('keeps failed warning for hard OpenCode runtime error impact', async () => {});
+it('keeps polling while userVisibleState is checking even when responsePending is false', async () => {});
+it('polls statusMessageId while updating diagnostics for the original messageId', async () => {});
+it('does not copy a queued-behind blocker hard error onto the newly sent message', async () => {});
+it('rechecks the original messageId after queued-behind blocker status becomes none', async () => {});
+it('schedules an extra status refresh at userVisibleNextReviewAt for checking impact', async () => {});
+it('clears stale checking diagnostics after the review window is stale and status stays unavailable', async () => {});
+it('does not treat checking impact as lastSendMessageResult failure when delivered is false', async () => {});
+it('keeps pending reply for checking impact even when delivered is false', async () => {});
+it('clears pending reply for warning and error impacts', async () => {});
+it('stops status polling when checking becomes warning', async () => {});
+it('does not convert a transient status request failure into a hard delivery error', async () => {});
+```
+
+### Component tests
+
+Update:
+
+```txt
+test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx
+test/renderer/components/team/messages/MessagesPanel.test.tsx
+test/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx
+test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
+```
+
+Expected:
+
+- checking warning respects the delay even when ledger facts say `delivered: false`;
+- proof warning appears immediately once impact is `warning`;
+- hard error appears immediately;
+- debug details include both ledger fact and impact.
+
+Add a focused component case:
+
+```ts
+it('delays checking impact even when ledger facts are terminal failed', async () => {});
+```
+
+Dialog draft cases:
+
+```ts
+it('does not restore the composer draft for checking impact even when delivered is false', async () => {});
+it('clears the send dialog draft for checking impact even when delivered is false', async () => {});
+it('preserves the send dialog draft for hard error impact', async () => {});
+it('keeps legacy delivered-false draft preservation when userVisibleImpact is absent', async () => {});
+```
+
+## Verification
+
+Focused:
+
+```bash
+pnpm vitest run test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts
+pnpm vitest run test/renderer/store/teamSlice.test.ts --testNamePattern "OpenCode"
+pnpm vitest run test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx
+pnpm vitest run test/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx
+pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts --testNamePattern "OpenCode runtime"
+pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts --testNamePattern "OpenCode"
+```
+
+Broader:
+
+```bash
+pnpm vitest run test/renderer/components/team/messages/MessagesPanel.test.tsx
+pnpm vitest run test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
+pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts
+pnpm vitest run test/main/services/team/TeamProvisioningServiceRelay.test.ts
+pnpm typecheck --pretty false
+git diff --check
+```
+
+Manual smoke:
+
+1. Send a direct message to an OpenCode teammate.
+2. Force or simulate a recent `failed_terminal / empty_assistant_turn`.
+3. Confirm composer/dialog shows checking copy.
+4. Add a correlated runtime reply.
+5. Confirm warning clears after refresh.
+6. Simulate old proof missing record.
+7. Confirm proof warning copy, not hard failed copy.
+8. Simulate quota/auth diagnostic.
+9. Confirm hard failed copy.
+
+## Rollout Notes
+
+- Do not remove old `delivered`, `responsePending`, `ledgerStatus`, or `responseState` fields.
+- Renderer must prefer `userVisibleImpact` when present and fall back to legacy fields when absent.
+- Do not hide debug details.
+- Do not make `failed_terminal` mean success.
+- Do not mark failed inbox rows read.
+- Do not delay hard provider/runtime failures.
+
+## Acceptance Criteria
+
+- Direct send no longer flashes `OpenCode runtime delivery failed` for recent generic proof gaps.
+- The same status still exposes `failed_terminal` in debug details.
+- Hard errors still show failed copy.
+- Confirmed proof gaps after grace show a warning, not hard error.
+- Late proof clears the warning.
+- Existing legacy payloads without `userVisibleImpact` still render with old behavior.
diff --git a/docs/team-management/process-backend-bootstrap-transport-hardening-plan.md b/docs/team-management/process-backend-bootstrap-transport-hardening-plan.md
new file mode 100644
index 00000000..4f206caf
--- /dev/null
+++ b/docs/team-management/process-backend-bootstrap-transport-hardening-plan.md
@@ -0,0 +1,3630 @@
+# Process backend bootstrap transport hardening plan
+
+## Summary
+
+Goal: make native process teammate launch stable, observable, and honestly represented in UI for Claude and Codex process teammates, without widening readiness semantics and without regressing OpenCode bridge behavior.
+
+Chosen approach: **durable process-backend bootstrap transport state machine + bounded event reads + service-layer projection enrichment + runner timeout diagnostics**.
+
+🎯 9.6 🛡️ 9.4 🧠 7.1
+Estimated change size: `560-860` LOC across two repos.
+
+Repos:
+
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+- `/Users/belief/dev/projects/claude/claude_team`
+
+Target failures:
+
+```text
+Teammate process atlas@signal-ops did not submit bootstrap prompt: timed out waiting for bootstrap_submitted
+Last stderr: Warning: no stdin data received in 3s, proceeding without it.
+```
+
+Target misleading UI states:
+
+```json
+{
+ "launchState": "failed_to_start",
+ "livenessKind": "stale_metadata",
+ "pid": 65704,
+ "runtimeDiagnostic": "persisted runtime pid is not alive",
+ "diagnostics": [
+ "Teammate process atlas@signal-ops did not submit bootstrap prompt: timed out waiting for bootstrap_submitted ..."
+ ]
+}
+```
+
+The root problem is not a single model/provider bug. It is missing transport-state evidence plus fallback projection:
+
+```text
+process spawned
+-> runtime started
+-> mailbox/bootstrap prompt path partially ran
+-> parent waited only for bootstrap_submitted
+-> no durable event explained where delivery stopped
+-> runner/projection later guessed generic timeout, never spawned, or stale pid
+```
+
+## What success means
+
+This phase does **not** make readiness easier. It makes failures clearer.
+
+Readiness remains:
+
+```text
+confirmed_alive = durable bootstrap_confirmed proof only
+```
+
+Transport stages remain diagnostics:
+
+```text
+process_spawned != ready
+runtime_ready != ready
+inbox_poller_ready != ready
+bootstrap_submitted != ready
+```
+
+## Deep code research findings
+
+### 1. Existing parent wait loses causality
+
+`/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts` waits for one event type and primarily matches by pid:
+
+```ts
+const match = events.find(
+ event => event.type === params.type && matchesPid(event),
+)
+```
+
+This cannot distinguish:
+
+- prompt row never observed;
+- prompt observed but submit deferred;
+- submit attempted but rejected;
+- submit accepted without UUID;
+- runtime failed;
+- process exited;
+- stale event from a previous launch matched same pid.
+
+### 2. Existing event reader reads full files
+
+`readTeammateRuntimeEvents()` uses whole-file `readFile`. New wait loops must not use unbounded reads.
+
+Keep the old helper unchanged for compatibility. Add a new helper:
+
+```ts
+readRecentTeammateRuntimeEvents(eventsPath, { maxBytes: 256 * 1024 })
+```
+
+Use it in new outcome waiters and timeout diagnostics.
+
+### 3. `useInboxPoller` submit rejection is currently retryable by behavior
+
+Current code:
+
+```ts
+if (!submitted.accepted) {
+ logForDebugging('[InboxPoller] Submission rejected, keeping messages queued')
+ return
+}
+```
+
+This keeps the message queued. Therefore a submit rejection is not automatically terminal. The plan must record it as diagnostic, but final failure happens only on:
+
+- later explicit `failed`;
+- `exited`;
+- accepted-without-uuid;
+- non-retryable rejection if the submit layer can prove it;
+- timeout.
+
+### 4. Runtime event `runId` is ambiguous
+
+Writers use `runId` differently:
+
+- startup sentinel/runtime-ready path uses session id;
+- `main.tsx` `cli_started` uses session id;
+- native app-managed context uses bootstrap request run id;
+- `mcp/client.ts` `member_briefing` proof uses session id;
+- `useInboxPoller` uses bootstrap run id only when native app-managed injection exists, otherwise session id.
+
+Therefore new launch-scoped events need a new optional `bootstrapRunId`. Do not redefine legacy `runId` globally.
+
+### 5. `bootstrapMessageId` and mailbox id must stay separate
+
+Use:
+
+- `bootstrapMirrorId`: original mailbox/bootstrap row id;
+- `bootstrapMessageId`: submitted session user message UUID from `onSubmitTeammateMessage`.
+
+Do not overload one field for both.
+
+### 6. Startup ready sentinel is not readiness proof
+
+`/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/startupReadySentinel.ts` writes a temp `startup-ready.json`. The hook also writes `runtime_ready`.
+
+This proves the child reached runtime init with hooks attached. It is not bootstrap completion and not teammate availability. Keep it separate from bootstrap confirmation.
+
+### 7. Stdin must stay open
+
+Do not change process backend spawn to:
+
+```ts
+stdio: ['ignore', 'pipe', 'pipe']
+```
+
+There is explicit process backend e2e coverage that stdin remains open because some wrappers treat stdin EOF as shutdown.
+
+### 8. The 3 second stdin warning is startup probing
+
+```text
+Warning: no stdin data received in 3s, proceeding without it.
+```
+
+comes from non-TTY stdin peek. Text-mode headless teammate startup should skip this peek. Stream-json behavior should remain unchanged.
+
+### 9. `TeamRuntimeLivenessResolver` already separates liveness from readiness
+
+`/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamRuntimeLivenessResolver.ts` already treats:
+
+- OpenCode process with unconfirmed bootstrap as `runtime_process_candidate`;
+- confirmed bootstrap as strong evidence;
+- stale pid as liveness diagnostic;
+- `sanitizeProcessCommandForDiagnostics()` as command redaction helper.
+
+The new transport evidence reader must align with this model. It must not mark process liveness as readiness.
+
+Process backend transport evidence should not modify `TeamRuntimeLivenessResolver` semantics. Liveness resolver answers "is there a verified/live runtime process or persisted runtime marker?". Transport enrichment answers "what happened during bootstrap submission?". Keep these separate and merge only in `TeamProvisioningService` status assembly.
+
+Desktop command parsing rule:
+
+- desktop may use `TeamRuntimeLivenessResolver` command matching only for liveness diagnostics;
+- process termination identity belongs to orchestrator `killProcessBackendRuntime(...)`, not desktop;
+- sanitized command text from desktop must never be used as proof of bootstrap confirmation or as a kill authorization token.
+
+### 10. Failure reasons are persisted and emitted to users
+
+`TeamBootstrapStateStore.markMemberResult()` persists `failureReason` in `bootstrap-state.json`. `TeamBootstrapProgressEmitter` emits `failedMembers` in structured output.
+
+Therefore every new failure detail must be:
+
+- bounded in length;
+- redacted;
+- stable enough to persist;
+- not raw argv/env/stderr.
+
+### 11. `TeamLaunchStateEvaluator` must stay pure
+
+Do not add JSONL reads to `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamLaunchStateEvaluator.ts`. Evidence IO belongs in `TeamProvisioningService` and a dedicated evidence reader.
+
+### 12. Bootstrap proof validation must not be reused for transport evidence
+
+`BootstrapProofValidation.ts` is confirmation-only. It validates durable `bootstrap_confirmed` proof with strict token/run/hash checks.
+
+Do not reuse it for `process_spawned`, `runtime_ready`, `inbox_poller_ready`, `bootstrap_submit_attempted`, or `bootstrap_submitted`.
+
+### 13. `teamBootstrapRunner` currently reports low-signal timeout
+
+`teamBootstrapRunner.ts` currently reads only `failed` events before generic timeout:
+
+```text
+Teammate was registered but did not bootstrap-confirm before timeout.
+```
+
+After transport events exist, timeout should include last relevant stage while still failing:
+
+```text
+Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: bootstrap_submitted.
+```
+
+### 14. `writeToMailbox()` does not return persisted message id
+
+`/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/teammateMailbox.ts` currently returns `Promise` from `writeToMailbox()`.
+
+Therefore parent-owned spawn code cannot know the actual normalized mailbox row id. Do not fabricate `bootstrapMirrorId` in parent code. The only safe source for `bootstrapMirrorId` is `useInboxPoller`, after it has observed `bootstrapPendingMessage.messageId`.
+
+### 15. `onSubmitTeammateMessage()` has no structured rejection reason
+
+`useInboxPoller` receives only:
+
+```ts
+{ accepted: boolean; userMessageUuid?: string }
+```
+
+Do not write plan or tests that expect `submitted.reason`. A rejected submit can only be reported as a generic local prompt-handler rejection unless the submit API is explicitly extended.
+
+### 16. `spawnMultiAgent.ts` has multiple spawn branches
+
+The file has tmux, split-pane, native process, and in-process paths. Bootstrap runtime metadata appears in more than one place, and process backend can be selected by runtime policy.
+
+Implementation must not patch one happy path only. Before editing, do a branch inventory and apply transport outcome handling to every branch that can persist `backendType: 'process'`, while keeping tmux and in-process behavior unchanged unless a test proves shared code is required.
+
+### 17. Redaction must happen before persistence in orchestrator
+
+`failureReason` and `failedMembers` become user-visible directly from orchestrator state/progress. Desktop-side redaction is not enough. The orchestrator must sanitize first, then desktop can sanitize defensively.
+
+### 18. Runtime event paths must be treated as untrusted persisted metadata
+
+`writeTeammateRuntimeEvent()` can write to an explicit `eventsPath` or to `CLAUDE_CODE_TEAMMATE_RUNTIME_EVENTS_PATH`. Parent code creates paths with `getTeammateRuntimePaths(teamName, agentName)`, but desktop later reads `bootstrapRuntimeEventsPath` from persisted team metadata.
+
+Therefore desktop evidence readers must not blindly read arbitrary persisted paths. They should accept only paths that resolve under the expected team runtime directory, or fall back to recomputing the canonical path from `teamName + memberName`.
+
+### 19. Launch-state size budget matters
+
+`TeamLaunchStateStore` reads full `launch-state.json` up to `256 KiB`, while `TeamConfigReader` can use compact `launch-summary.json` for list/summary paths. New diagnostic fields can still make detailed launch-state reads fail if failure text or diagnostic arrays grow.
+
+The plan must keep transport diagnostics short, bounded, and deduped. Do not persist raw event histories into `launch-state.json`; persist only the selected root cause, compact runtime diagnostic, and small bounded diagnostics list.
+
+### 20. Team member bootstrap metadata is part of equality/update semantics
+
+`teamHelpers.ts` includes `bootstrapExpectedAfter`, `bootstrapProofToken`, `bootstrapRunId`, `bootstrapProofMode`, hashes, and `bootstrapRuntimeEventsPath` in `TeamFile` and member equality. Any edit/restart/update path must preserve or intentionally replace these fields. Accidental clearing will break later correlation and cause false stale/never-spawned states.
+
+Explicit lifecycle rules:
+
+- pure roster/profile edit without restart keeps existing bootstrap metadata unchanged;
+- member restart or team relaunch replaces `bootstrapExpectedAfter`, `bootstrapRunId`, proof token/hash fields, `bootstrapRuntimeEventsPath`, backend type, and runtime pid as one logical update;
+- failed spawn before metadata publication must not leave a partially updated member that points to a new runtime events path without the matching run boundary;
+- desktop projection must prefer the current launch snapshot/run boundary over older persisted team metadata if both exist and disagree;
+- if edit/restart code cannot prove whether metadata belongs to the current run, it should drop transport enrichment and keep the existing conservative failed/pending state.
+
+### 21. Desktop already has a bounded runtime proof event reader
+
+`TeamProvisioningService` already has:
+
+- `getBootstrapRuntimeEventsPath(...)`;
+- `readRuntimeBootstrapProofEvents(...)`;
+- `isRuntimeBootstrapProofEventValid(...)`;
+- `findBootstrapRuntimeProofObservedAt(...)`.
+
+This means implementation should not create a second unrelated JSONL tail reader in desktop. Extract or wrap the existing reader/path resolver into a small shared utility that both proof overlay and new transport evidence can use. The existing proof path must keep its strict `validateBootstrapRuntimeProofEnvelope(...)` semantics.
+
+### 22. Runtime event filename sanitizer must match orchestrator
+
+Orchestrator canonical runtime files use:
+
+```ts
+safeWindowsPathSegment(name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase())
+```
+
+Desktop currently has a local `sanitizeRuntimeEventFilePrefix(...)` with the non-alnum/lowercase part. The plan should align fallback path generation with orchestrator including Windows reserved basename handling, or prefer safe persisted path when available. Otherwise names like `con`, `aux`, or other reserved basenames can produce fallback mismatch on Windows.
+
+Extra fragile detail: `agentName` passed to `getTeammateRuntimePaths(...)` is usually already `sanitizeAgentName(name)`, which only replaces `@` with `-`. Runtime filename generation then applies `sanitizeName(...)`. Desktop fallback must mimic the same source identity and filename codec:
+
+```ts
+const runtimeAgentName = persistedRuntimeMember?.name ?? memberName
+const filePrefix = sanitizeName(runtimeAgentName)
+```
+
+Do not use display name, provider name, model id, or unsanitized `memberName` when persisted runtime member name is available.
+
+Collision policy:
+
+- if two configured members sanitize to the same runtime filename prefix, do not guess from filename alone;
+- require payload identity (`agentName`/`agentId`) and current attempt identity;
+- if both candidates remain ambiguous, return no enrichment for both and rely on generic timeout/proof paths;
+- add a test with names that collide after the real two-step pipeline if such names are possible.
+
+### 22.1 Runtime filename codec must be a tested contract, not a copied guess
+
+The fragile part is not just the regex. The actual orchestrator pipeline is:
+
+```ts
+sanitizeAgentName(name) // only @ -> -
+sanitizeName(agentName) // non-alnum -> -, lowercase, safeWindowsPathSegment(...)
+```
+
+`safeWindowsPathSegment(...)` prefixes Windows reserved basenames after trimming trailing spaces/dots and checking the basename stem. For example, `con`, `con.txt`, `aux`, `nul`, `com1`, and `lpt1` map to reserved-safe names.
+
+Implementation rule:
+
+- if desktop can reuse/import a small shared codec without crossing unsafe package/runtime boundaries, do that;
+- if importing orchestrator code into Electron main is not acceptable, duplicate the tiny codec locally but add golden tests that lock it to orchestrator behavior;
+- do not rely on visual/manual comparison of generated paths;
+- do not “normalize better” on desktop. Better but different normalization is a bug because it breaks correlation.
+
+Required golden vectors:
+
+```ts
+const runtimeFilePrefixCases = [
+ ['', ''],
+ ['bob', 'bob'],
+ ['Bob', 'bob'],
+ ['bob@team', 'bob-team'],
+ ['bob.team', 'bob-team'],
+ ['bob_team', 'bob-team'],
+ ['con', '_con'],
+ ['con.txt', 'con-txt'],
+ ['aux', '_aux'],
+ ['COM1', '_com1'],
+ ['LPT9', '_lpt9'],
+ ['---', '---'],
+ ['алиса', '-----'],
+]
+```
+
+The exact non-ASCII fallback looks ugly, but it matches current production behavior. Do not change it in this phase. A nicer slug format would require a separate migration because old runtime files already use the current codec.
+
+### 22.2 Member identity precedence for runtime events
+
+Runtime filename is only a lookup hint. It is not identity.
+
+Use this precedence when associating runtime events with a member:
+
+1. current launch attempt identity: `bootstrapRunId` when present, otherwise legacy session `runId` only through the existing compatibility path;
+2. runtime process identity: expected process backend pid and not-before boundary;
+3. payload member identity: exact `agentId` first, then exact runtime `agentName`;
+4. safe path containment under the expected team runtime directory;
+5. sanitized filename prefix as last-resort candidate selection only.
+
+Example shape:
+
+```ts
+interface RuntimeEventMemberMatchInput {
+ teamName: string
+ memberName: string
+ expectedAgentId?: string
+ expectedRuntimeAgentName?: string
+ expectedBootstrapRunId?: string
+ legacySessionRunId?: string
+ expectedPid?: number
+ notBeforeMs?: number
+ candidatePath: string
+}
+
+type RuntimeEventMemberMatch =
+ | { kind: 'current'; confidence: 'strict' | 'legacy' }
+ | { kind: 'diagnostic_only'; reason: 'missing_attempt_identity' | 'legacy_pid_only' }
+ | { kind: 'no_match'; reason: 'wrong_member' | 'stale_attempt' | 'ambiguous_filename' | 'path_outside_team_runtime' }
+```
+
+Rules:
+
+- `agentId` mismatch is a hard no-match even if filename and pid look plausible;
+- `bootstrapRunId` mismatch is a hard no-match even if filename and member name match;
+- missing payload identity may allow diagnostic-only enrichment only when pid/not-before/path are current and there is no filename collision;
+- diagnostic-only enrichment can explain timeout stage but cannot create `confirmed_alive`, cannot clear provider failure, and cannot kill/cleanup a process;
+- filename collision downgrades missing-identity events to no-match, not diagnostic-only.
+
+This keeps the reader fail-closed in the exact cases where stale evidence would be most damaging.
+
+### 23. Runner polling must not treat last-stage diagnostics as failure
+
+`waitForRequiredBootstrapConfirmations(...)` polls `readBootstrapRuntimeFailure(...)` before timeout. If the implementation changes that function to return non-terminal stages like `bootstrap_submitted` or retryable `bootstrap_submit_rejected`, it will fail members early.
+
+Split the responsibilities:
+
+- poll loop reads only terminal runtime failures;
+- timeout path reads last relevant transport stage for diagnostic text.
+
+### 24. `TeamBootstrapStateStore.markMemberResult()` currently preserves stale failureReason
+
+`markMemberResult(...)` spreads the old member and only sets `failureReason` when a new one is supplied. If a member was failed and later becomes `bootstrap_confirmed`, stale `failureReason` can remain in `bootstrap-state.json`.
+
+If this method is touched for redaction, make it explicit:
+
+- failed result stores sanitized failure reason;
+- non-failed result clears `failureReason`;
+- non-failed diagnostic text, for example duplicate/already-running reason, must not be stored in `failureReason`. Keep it in progress output/journal detail, or add a clearly non-terminal internal field only if implementation needs it;
+- journal keeps historical failure/detail records.
+
+### 25. Progress emitter is also a user-visible boundary
+
+`TeamBootstrapProgressEmitter` writes:
+
+- `member_spawn_result.reason`;
+- `completed.failed_members`;
+- terminal `failed.reason`.
+
+These values are structured output and can be shown in diagnostics immediately. Even if callers sanitize, the emitter should defensively sanitize/bound outbound reason fields too. This prevents a single unsanitized caller from leaking raw stderr/env/provider details.
+
+### 26. Launch summary is written from `TeamLaunchStateStore`
+
+`TeamLaunchStateStore.write(...)` writes both `launch-state.json` and `launch-summary.json`. Enrichment must flow through this store. Do not write only `launch-state.json` or only in-memory status if the UI list depends on launch-summary selection.
+
+`launch-summary.json` derives `missingMembers` from members whose `launchState === 'failed_to_start'` and counts from `snapshot.summary`. Therefore enrichment must happen before `createPersistedLaunchSummaryProjection(snapshot)`. If enrichment changes a member from pending to `failed_to_start`, the summary must show the same failed count/missing member as Team Detail.
+
+`clean_success` finished launches can clear persisted launch-state. This is fine. Transport diagnostics matter for failed/pending launches, not clean success.
+
+Clean-success clear rule:
+
+- if the normalized snapshot is truly `clean_success` after proof/provider/transport overlays and `launchPhase !== 'active'`, preserve existing behavior and clear persisted launch-state;
+- process transport enrichment must not keep a stale diagnostic alive after every expected member is confirmed/skipped according to existing summary semantics;
+- if any process member is still `runtime_pending_bootstrap`, `failed_to_start`, or permission-pending, the snapshot is not clean success and must not be cleared;
+- do not use runtime event absence to prevent clean-success clearing. Absence of a best-effort diagnostic file is not a reason to keep launch-state;
+- `clearPersistedLaunchStateNow(...)` also clears bootstrap-state. Therefore clean-success clear must be fenced by current run identity immediately before clearing, not only before building the snapshot;
+- a stale clean-success finalizer from an older run must not clear launch-state or bootstrap-state for a newer active/restarted run;
+- if the current run identity cannot be proven at clear time, skip clear and keep the conservative persisted state;
+- tests should cover clean-success launch with missing runtime event files and assert launch-state can still clear.
+- tests should cover stale clean-success finalizer racing with a newer launch and assert neither launch-state nor bootstrap-state is cleared for the newer run.
+
+### 27. Shared/public launch status types must not expose transport internals
+
+`PersistedTeamLaunchMemberState`, `MemberSpawnStatusEntry`, and `TeamAgentRuntimeEntry` expose user-facing runtime/launch status fields like `runtimeDiagnostic`, `hardFailureReason`, `diagnostics`, `livenessKind`, and `runtimePid`.
+
+They do not expose `bootstrapProofToken`, `contextHash`, `briefingHash`, or runtime events paths. Keep it that way. Transport internals are matching inputs, not renderer/API payload fields.
+
+### 28. Diagnostics must never stringify full runtime events
+
+Runtime events can contain correlation fields:
+
+- `bootstrapProofToken`;
+- `contextHash`;
+- `briefingHash`;
+- `bootstrapRunId`;
+- runtime events path through surrounding metadata.
+
+Diagnostic formatting must be allowlist-based: stage/type plus sanitized `detail` only. Never include `JSON.stringify(event)` in user-visible failure reasons.
+
+Data minimization rule:
+
+- `bootstrapProofToken`, `contextHash`, and `briefingHash` may remain on existing proof-validation events such as `bootstrap_confirmed` and native app-managed `bootstrap_context_loaded`;
+- new transport-stage events should not add proof tokens unless a current proof validator explicitly needs them;
+- correlate transport stages primarily with `bootstrapRunId + runtimePid + teamName + agentName + launch boundary`;
+- diagnostics formatters must never surface proof tokens or hashes even if they exist in the raw local event file.
+
+### 29. Path containment utility already exists
+
+`/Users/belief/dev/projects/claude/claude_team/src/main/utils/pathValidation.ts` exports `isPathWithinRoot(targetPath, rootPath)`. Reuse it for runtime event path guards instead of adding another subtly different path containment helper.
+
+For existing files, prefer realpath validation of both candidate and runtime directory to avoid symlink escapes. For missing files, lexical `path.resolve + isPathWithinRoot` is acceptable because there is nothing to read yet.
+
+### 30. Parent-owned runtime events must never default to parent pid
+
+`writeTeammateRuntimeEvent(...)` defaults `pid` to `process.pid`, which is correct for child-runtime writers such as `main.tsx`, `useInboxPoller`, and MCP client code.
+
+It is dangerous for parent-owned spawn events. `spawnMultiAgent.ts` must always pass the child `runtimePid` for:
+
+- `process_spawned`;
+- `stdout_attached`;
+- parent-written `failed`;
+- parent-written `exited`;
+- any new `mailbox_bootstrap_written` or submit outcome events written by parent code.
+
+Plan implementation should add a tiny parent helper so this cannot be accidentally omitted.
+
+### 31. Parent event details currently include paths/command snippets
+
+Current native process events include details like:
+
+```text
+command= args=
+stdout= stderr=
+```
+
+These details are useful for logs but unsafe as user-visible timeout diagnostics. Last-stage diagnostic formatting must allowlist stage plus safe detail only. For `process_spawned` and `stdout_attached`, use the stage name without raw command/path detail in user-facing text.
+
+### 32. Runtime event writes are diagnostic and best-effort
+
+`writeTeammateRuntimeEvent(...)` currently catches write errors and logs them. Keep that behavior. Runtime events improve causality but must not become a new hard dependency that can crash launch when the event file cannot be written.
+
+Implication:
+
+- parent `process_spawned`/`stdout_attached` write failure should not fail spawn by itself;
+- child event write failure can still lead to existing timeout behavior because the waiter never observes readiness/submit;
+- explicit process/provider failures should still be surfaced through thrown errors and bootstrap-state, even if diagnostic event write fails.
+
+### 33. Existing process-backend e2e has a fake runtime harness
+
+`teamBootstrapProcessBackend.e2e.test.ts` already uses `FAKE_TEAMMATE_RUNTIME_SCRIPT` and modes like crash, pipe flood, stdin-sensitive, hold-child. Extend that harness with focused modes for new transport stages instead of requiring live Claude/Codex in deterministic tests.
+
+Needed fake modes:
+
+- `submit-rejected-then-accepted`: emits `bootstrap_submit_attempted`, retryable `bootstrap_submit_rejected`, then later `bootstrap_submitted`;
+- `submit-rejected-terminal`: emits `bootstrap_submit_attempted`, non-retryable `bootstrap_submit_rejected`;
+- `accepted-without-uuid`: submit returns accepted but no persisted user message uuid;
+- `no-submit`: emits process/runtime readiness but never observes the bootstrap prompt;
+- `observed-no-submit`: observes bootstrap prompt but never accepts submit;
+- `inbox-ready-no-submit`: emits `inbox_poller_ready` but never submits;
+- `submit-then-exit`: emits `bootstrap_submitted`, then exits before bootstrap confirmation;
+- `failed-after-runtime-ready`: emits a terminal runtime `failed` event after runtime readiness;
+- `partial-corrupt-event`: writes corrupt/partial runtime event lines before a valid event;
+- `stderr-secret`: writes token-like stderr/detail text to prove diagnostics are redacted.
+
+Harness rules:
+
+- fake modes stay local and deterministic, with no live Claude/Codex/OpenCode/provider auth;
+- fake modes must be selected through existing fixture env, not through production runtime flags;
+- fake runtime event lines should be written through the same JSONL path shape as production;
+- corrupt-event tests must assert the reader skips bad lines and still reads later valid lines;
+- event-write-disabled tests should prove spawn does not crash just because diagnostic event append failed.
+
+This is the main confidence gate for this phase. Unit tests can prove classifiers, but only this harness proves the spawn runner, event file, stdin, child process, cleanup, timeout, and bootstrap-state paths work together.
+
+## Non-goals
+
+- Do not return tmux to default.
+- Do not close process backend stdin.
+- Do not make `bootstrap_submitted` count as readiness.
+- Do not make `runtime_ready`, PID, RSS, or mailbox row count as readiness.
+- Do not alter OpenCode bridge launch/delivery semantics.
+- Do not add unbounded retry loops.
+- Do not add filesystem reads inside `TeamLaunchStateEvaluator`.
+- Do not mask provider/auth/quota/model failures as transport failures.
+- Do not change task/work-sync logic.
+- Do not add renderer IPC/API fields unless existing diagnostics fields prove insufficient.
+
+## Safety invariants
+
+1. `confirmed_alive` requires durable `bootstrap_confirmed` or trusted native app-managed bootstrap proof.
+2. `bootstrap_submitted` means only that the bootstrap prompt entered the CLI session.
+3. `bootstrap_submit_attempted` means only that submit was tried.
+4. `bootstrap_submit_rejected` is diagnostic/retryable by default.
+5. `inbox_poller_ready` means only that mailbox polling reached runtime readiness.
+6. PID/RSS/process liveness is diagnostic, not availability.
+7. Startup sentinel/runtime-ready is not bootstrap confirmation.
+8. `never spawned` is valid only when no spawn/runtime/poller/submit evidence exists for the current launch window.
+9. Cleanup may update liveness fields, but must preserve the most specific root cause.
+10. Transport evidence can prevent misleading fallback, but cannot create success.
+11. New waits and diagnostics use bounded reads.
+12. Legacy `event.runId` is not a universal launch id.
+13. OpenCode bridge lanes stay out of this process-backend projection path.
+14. User-visible diagnostics are redacted and length-bounded before persistence/emission.
+15. Provider/auth/quota failures win over generic transport fallback.
+16. Desktop never reads a persisted runtime events path unless it resolves under the expected team runtime directory.
+17. Launch-state diagnostic additions must keep detailed launch-state under the existing `256 KiB` read budget; list summary should remain compact through `launch-summary.json`.
+18. Runner polling can only fail on terminal runtime events. Last-stage diagnostics are timeout-only.
+19. Non-failed bootstrap member results clear stale `failureReason`.
+20. Structured bootstrap progress output is sanitized at emitter boundary.
+21. Enriched launch status must be persisted through `TeamLaunchStateStore.write(...)` so launch-summary stays consistent.
+22. Renderer/shared types do not expose bootstrap proof tokens, hashes, or runtime event paths.
+23. User-visible diagnostics are allowlist-formatted and never stringify full runtime events.
+24. Runtime event path containment reuses `isPathWithinRoot` and validates realpaths when files exist.
+25. Parent-owned runtime events always include child runtime pid and never rely on writer default pid.
+26. Parent command/log path details are not surfaced in user-visible timeout diagnostics.
+27. Runtime event writes remain best-effort and do not introduce new launch crash paths.
+28. Deterministic e2e coverage extends the existing fake runtime harness instead of relying on live providers.
+29. Fake runtime modes cover every terminal and non-terminal transport branch before production spawn integration is considered complete.
+30. Corrupt or missing runtime event diagnostics degrade to timeout/fallback behavior and never crash launch.
+
+## Transport state lattice
+
+This phase must preserve a strict lattice. Stages can explain progress, but only confirmation proves availability.
+
+```mermaid
+flowchart LR
+ A["spawned"] --> B["runtime ready"]
+ B --> C["inbox poller ready"]
+ C --> D["bootstrap prompt observed"]
+ D --> E["submit attempted"]
+ E --> F["submit accepted"]
+ E --> G["submit rejected retryable"]
+ G --> E
+ F --> H["bootstrap submitted"]
+ H --> I["waiting for bootstrap confirmation"]
+ I --> J["confirmed alive"]
+ E --> K["terminal submit failure"]
+ H --> L["process exited or runtime failed before confirmation"]
+ I --> M["bootstrap timeout with last transport stage"]
+```
+
+Rules:
+
+- `confirmed alive` is the only success terminal state.
+- `terminal submit failure`, `process exited/runtime failed`, and timeout can become `failed_to_start`.
+- retryable rejection stays pending until later submit, terminal failure, process exit, or timeout.
+- a newer launch/restart attempt supersedes older pending/failure evidence only when the newer attempt has a distinct current boundary.
+- liveness evidence can annotate any stage but does not move the state to success.
+
+### Launch-state transition lattice
+
+The persisted member launch state must be monotonic within one attempt, except for strict proof confirmation and materialized restart boundaries.
+
+Allowed transitions for one current process-backend attempt:
+
+| From | Evidence | To | Notes |
+|---|---|---|---|
+| `starting` | `process_spawned` / `mailbox_bootstrap_written` / prompt observed | `runtime_pending_bootstrap` | Pending only. |
+| `starting` | terminal submit failure / current process exit / final timeout | `failed_to_start` | Only after current-attempt identity match. |
+| `runtime_pending_bootstrap` | stronger pending transport stage | `runtime_pending_bootstrap` | Stable diagnostic may upgrade from process-started to submitted. |
+| `runtime_pending_bootstrap` | strict bootstrap proof | `confirmed_alive` | Existing proof validator only. |
+| `runtime_pending_bootstrap` | terminal current-attempt failure / final timeout | `failed_to_start` | Provider/root failure still wins over generic transport. |
+| `failed_to_start` | later pending event from same attempt | `failed_to_start` | Do not resurrect from terminal failure in same attempt. |
+| `failed_to_start` | strict current-attempt bootstrap proof | `confirmed_alive` | Only for auto-clearable transport failures, not unrelated provider/root failures unless existing proof semantics already allow it. |
+| `failed_to_start` | materialized new restart attempt with new boundary | `runtime_pending_bootstrap` or `starting` | Old auto-clearable transport failure can clear. |
+| `confirmed_alive` | later transport failure/exit from same attempt | `confirmed_alive` plus diagnostic only if useful | Do not downgrade availability without explicit stop/restart semantics. |
+| `skipped_for_launch` / `runtime_pending_permission` | process transport event | unchanged | Skip/permission semantics are authoritative. |
+
+Forbidden transitions:
+
+- `failed_to_start -> starting` from a persisted config edit that did not materialize a new runtime attempt;
+- `failed_to_start -> runtime_pending_bootstrap` from stale pid/liveness metadata;
+- `runtime_pending_bootstrap -> failed_to_start` during active launch because of retryable submit rejection;
+- `confirmed_alive -> failed_to_start` because a stale child event or old timeout handler fires later;
+- `skipped_for_launch -> starting` from transport evidence;
+- `runtime_pending_permission -> failed_to_start` from generic timeout while permission is still unresolved.
+
+Implementation shape:
+
+```ts
+type LaunchTransitionReason =
+ | 'same_attempt_pending_progress'
+ | 'same_attempt_terminal_transport_failure'
+ | 'strict_bootstrap_proof'
+ | 'materialized_new_attempt'
+ | 'provider_root_failure'
+ | 'cleanup_liveness_only'
+ | 'forbidden_stale_or_weaker_evidence'
+
+function mergeProcessBootstrapLaunchState(
+ previous: PersistedTeamLaunchMemberState,
+ evidence: ProcessBootstrapTransportEvidence,
+ context: {
+ launchPhase: PersistedTeamLaunchPhase
+ projectionPhase: 'active' | 'final'
+ currentAttempt: boolean
+ },
+): { next: PersistedTeamLaunchMemberState; changed: boolean; reason: LaunchTransitionReason } {
+ // pure function, no filesystem, no process table, no renderer imports
+}
+```
+
+Keep this transition helper pure and test it directly. Do not scatter transition checks across `TeamProvisioningService`, summary projection, and renderer helpers.
+
+### Persisted launch phase compatibility
+
+Current shared type is:
+
+```ts
+export type PersistedTeamLaunchPhase = 'active' | 'finished' | 'reconciled'
+```
+
+Do not add new persisted values like `finalizing` or `terminal` for this phase. If merge logic needs an internal terminal/final concept, derive it as a local non-persisted value:
+
+```ts
+type ProcessTransportProjectionPhase = 'active' | 'final'
+
+function deriveProcessTransportProjectionPhase(input: {
+ launchPhase: PersistedTeamLaunchPhase
+ finalTimeoutReached?: boolean
+}): ProcessTransportProjectionPhase {
+ if (input.launchPhase !== 'active') return 'final'
+ return input.finalTimeoutReached === true ? 'final' : 'active'
+}
+```
+
+Rules:
+
+- persisted snapshots keep `launchPhase: 'active' | 'finished' | 'reconciled'` only;
+- runner UI/progress step `finalizing` is not the same thing as persisted `launchPhase`;
+- process transport merge can use internal `projectionPhase: 'final'` to decide timeout-to-failure behavior;
+- do not cast new phase strings with `as PersistedTeamLaunchPhase`;
+- tests must assert unknown phase strings are normalized by existing evaluator behavior, not introduced by this change.
+
+## Transport failure taxonomy
+
+Use typed failure categories internally. Do not decide terminal vs pending from arbitrary strings.
+
+```ts
+export type ProcessBootstrapTransportFailureKind =
+ | 'none'
+ | 'retryable_submit_rejection'
+ | 'non_retryable_submit_rejection'
+ | 'accepted_without_message_id'
+ | 'process_exited_before_confirmation'
+ | 'runtime_failed_before_confirmation'
+ | 'bootstrap_timeout_after_transport_progress'
+ | 'bootstrap_timeout_without_transport_progress'
+ | 'event_unavailable'
+ | 'stale_or_mismatched_evidence'
+```
+
+State mapping:
+
+| Kind | Terminal? | UI severity | Persisted root cause? | Notes |
+|---|---:|---|---:|---|
+| `retryable_submit_rejection` | No | warning | No | Pending until later submit/failure/timeout. |
+| `non_retryable_submit_rejection` | Yes | error | Yes | Local submit path refused permanently. |
+| `accepted_without_message_id` | Yes | error | Yes | Prompt accepted without durable mailbox id is not safe. |
+| `process_exited_before_confirmation` | Yes | error | Yes | Only current process/attempt. |
+| `runtime_failed_before_confirmation` | Yes | error | Yes | Only sanitized runtime failure detail. |
+| `bootstrap_timeout_after_transport_progress` | Yes after final timeout | error | Yes | More specific than generic timeout, but still not provider failure. |
+| `bootstrap_timeout_without_transport_progress` | Yes after final timeout | error | Yes | Existing generic path remains valid. |
+| `event_unavailable` | No | info/warning | No | Missing event file cannot itself fail launch. |
+| `stale_or_mismatched_evidence` | No | none | No | Ignore for current projection. |
+
+Rules:
+
+- arbitrary `error` string is not a failure kind;
+- failure kind is derived from structured event type and current attempt match, not regex over `detail`;
+- `event_unavailable` and `stale_or_mismatched_evidence` are never user-facing root causes;
+- provider/auth/quota/model failures are outside this taxonomy and have higher precedence;
+- terminal kind is allowed to create `failed_to_start` only after current attempt identity matches.
+
+## Attempt identity contract
+
+The implementation needs one explicit identity object for process-backend bootstrap attempts. Avoid rebuilding this tuple independently in runner, spawn, desktop projection, and tests.
+
+```ts
+export interface ProcessBootstrapAttemptIdentity {
+ teamName: string
+ memberName: string
+ agentId: string
+ backendType: 'process'
+ runtimePid?: number
+ runtimeEventsPath?: string
+ bootstrapRunId?: string
+ notBeforeMs: number
+}
+```
+
+Matching levels:
+
+| Level | Required evidence | Allowed use |
+|---|---|---|
+| `strict-current` | `teamName`, `agentId`, `bootstrapRunId`, current `runtimePid` when present | Full transport enrichment and terminal failure matching. |
+| `legacy-current` | `teamName`, `agentId`, contained current runtime path, current pid or current launch boundary | Pending diagnostics only, and only when no explicit mismatch exists. |
+| `diagnostic-only` | contained runtime path but missing current pid/run boundary | Internal logs only. Do not alter member launch state. |
+| `no-match` | any explicit team/member/agent/run/pid/path mismatch | Ignore. |
+
+Attempt identity rules:
+
+- build the identity once near process spawn/restart metadata and pass it to helpers;
+- never infer current attempt solely from `memberName` or `teamName`;
+- `bootstrapRunId` explicit mismatch is always `no-match`;
+- pid mismatch is `no-match` for parent-owned events and for current child-written events with pid;
+- legacy child-written events without pid can only be `legacy-current` if path containment and launch boundary match;
+- old event files from a previous restart are not current just because they live under the same team runtime directory;
+- if restart starts a new runtime but event path is reused, `notBeforeMs` and pid/run id must filter old lines.
+
+This contract is intentionally internal. Renderer, shared UI types, and IPC responses should not expose it.
+
+## Cancellation, stop, and restart race contract
+
+The most fragile production cases are not happy-path launch. They are overlapping lifecycle transitions:
+
+- user stops team while launch is finalizing;
+- user restarts one member while old launch finalizer still runs;
+- app restarts and reconciles persisted state while child process has already exited;
+- cleanup runs after metadata publication but before UI refresh;
+- model/provider failure arrives after transport timeout already wrote a generic failure.
+
+Hard rules:
+
+- a stale async continuation must never write launch-state for a newer run/restart;
+- cleanup may mark the current attempt failed, but cannot mutate a newer attempt;
+- restart creates a new attempt boundary before it can clear old terminal state;
+- stop/cancel should prefer explicit stopped/cancelled diagnostics over bootstrap transport timeout when both happen for the same current attempt;
+- if run/process is killed while evidence is being read, discard the evidence and let stop/cancel path own the state;
+- do not send lead/user availability corrections from stale finalizers.
+
+Recommended identity check close to every write:
+
+```ts
+function canPersistProcessBootstrapProjection(input: {
+ run: ProvisioningRun
+ identitySnapshot: ProcessBootstrapAttemptIdentity[]
+ expectedRunId: string
+ expectedRestartGeneration?: number
+}): boolean {
+ if (input.run.cancelRequested || input.run.processKilled) return false
+ if (input.run.runId !== input.expectedRunId) return false
+ if (
+ input.expectedRestartGeneration !== undefined &&
+ input.run.restartGeneration !== input.expectedRestartGeneration
+ ) {
+ return false
+ }
+ return input.identitySnapshot.every((identity) =>
+ isStillCurrentAttemptIdentity(input.run, identity)
+ )
+}
+```
+
+If `restartGeneration` does not exist today, do not add a broad public field. Use the narrowest internal counter/map needed to prevent stale restart writes.
+
+## Failure monotonicity and auto-clear contract
+
+Failure state should not flicker. Once a member has a real root failure, only stronger evidence can clear or replace it.
+
+Failure classes:
+
+| Class | Examples | Auto-clear allowed? | Required evidence to clear |
+|---|---|---:|---|
+| Provider/runtime hard failure | auth, quota, model error, explicit runtime failed | No | New materialized launch/restart attempt or valid bootstrap proof for current member. |
+| Process transport failure | submit accepted without UUID, process exited before confirmation, non-retryable submit rejection | Yes, but only if current attempt later has strict proof or a new materialized attempt starts. | Strict bootstrap proof or new attempt identity. |
+| Generic timeout fallback | no submit/no confirmation timeout | Yes | Any current strict proof or more specific current transport/provider failure. |
+| Pending transport diagnostic | runtime ready, inbox ready, submit attempted, submit retryable rejected, submitted | Not a failure | Later stronger stage or timeout. |
+| Stale/mismatched evidence | old run, old pid, wrong member | N/A | Never applies to current state. |
+
+Rules:
+
+- provider/runtime hard failure is monotonic inside the same attempt;
+- generic timeout can be replaced by a more specific current transport failure;
+- current transport failure can be replaced by strict bootstrap proof;
+- old transport failure cannot replace new pending state;
+- pending metadata alone cannot clear terminal failure;
+- a valid new restart attempt can move old failure to pending, but only after new runtime/process/mailbox evidence exists.
+
+Auto-clear helper should be narrow and pure:
+
+```ts
+function canClearLaunchFailure(input: {
+ previous: PersistedTeamLaunchMemberState
+ incoming: ProcessBootstrapTransportMergeInput
+}): boolean {
+ if (isProviderOrRuntimeHardFailure(input.previous)) {
+ return input.incoming.evidence.stage === 'bootstrap_confirmed'
+ }
+ if (input.incoming.attemptMatch !== 'strict-current') return false
+ return input.incoming.evidence.hasSubmitEvidence || input.incoming.evidence.terminalTransportFailure
+}
+```
+
+The actual implementation can use different names, but the decision must stay explicit and unit-tested.
+
+## Architecture
+
+| Component | Responsibility |
+|---|---|
+| `teammateRuntimeEvents.ts` | Append/read runtime events, add bounded recent reader, classify bootstrap submission outcome. |
+| `spawnMultiAgent.ts` | Spawn process, write parent-owned events, wait for submission outcome, handle terminal transport failure. |
+| `useInboxPoller.ts` | Emit facts when bootstrap prompt is observed and submit is attempted/accepted/rejected/deferred. |
+| `main.tsx#getInputPrompt()` | Avoid initial stdin peek only for headless teammate text mode. |
+| `teamBootstrapRunner.ts` | Surface last relevant transport stage in timeout reasons while keeping confirmation gate strict. |
+| `ProcessBootstrapAttemptIdentity` helper | Build and validate current attempt identity once; avoid duplicated matching logic. |
+| `TeamRuntimeEventEvidenceReader` | Safe path resolution and bounded tail reads for desktop runtime events; shared by proof overlay and transport evidence. |
+| `ProcessBootstrapTransportEvidence.ts` | Summarize process transport evidence for `claude_team`; no proof semantics. |
+| `ProcessBootstrapTransportDiagnostic.ts` | Convert evidence/failure kind to sanitized, bounded, user-safe diagnostic summaries. |
+| `ProcessBootstrapLaunchStateMerge` | Pure precedence matrix for confirmed/provider failure/pending/terminal transport states. |
+| `TeamBootstrapStateReader` | Read persisted bootstrap failure reasons defensively sanitized; no runtime event IO. |
+| `TeamProvisioningService` | Enrich launch statuses from process transport evidence before projection. |
+| `TeamRuntimeLivenessResolver` | Existing liveness/readiness separation. Reuse its sanitizer where appropriate. |
+| `TeamLaunchStateEvaluator` | Pure state normalization/projection only. No filesystem IO. |
+| Renderer | Display existing status/diagnostic fields. |
+| Process backend fake runtime harness | Deterministic integration coverage for transport stages, stdin behavior, cleanup, and bounded event reads. |
+
+SOLID constraints:
+
+- SRP: evidence IO lives in evidence reader/service layer, not evaluator.
+- OCP: event types/helpers are additive; old wait helper keeps behavior.
+- ISP: renderer does not receive provider transport internals.
+- DIP: renderer depends on launch status contract, not process/tmux/OpenCode internals.
+
+## Public/internal API changes
+
+No new Electron IPC channels and no renderer-facing transport fields.
+
+Internal additive changes:
+
+- runtime event type/params add optional fields `bootstrapRunId`, `retryable`, and `attempt`;
+- new process transport helper modules are internal to orchestrator/desktop services;
+- `TeamBootstrapStateStore.markMemberResult(...)` may add optional `detail?: string`, but `failureReason` remains terminal-failure only;
+- no shared UI contract should expose `bootstrapProofToken`, hashes, runtime events path, or raw transport event data.
+
+Shared type surface contract:
+
+Existing renderer-visible fields are enough. Do not add new transport-specific fields to shared renderer payloads in this phase.
+
+| Type | Existing fields to use | Do not add/use |
+|---|---|---|
+| `MemberSpawnStatusEntry` | `launchState`, `status`, `error`, `hardFailureReason`, `runtimeAlive`, `bootstrapConfirmed`, `hardFailure`, `livenessKind`, `runtimeDiagnostic`, `runtimeDiagnosticSeverity`, `bootstrapStalled`, timestamps | `diagnostics[]`, `transportKind`, `bootstrapRunId`, proof tokens, runtime events path |
+| `PersistedTeamLaunchMemberState` | same launch fields plus existing `diagnostics[]`, `runtimePid`, `runtimeSessionId`, source/liveness metadata | raw event JSON, raw command, raw paths, proof token/hash |
+| `TeamAgentRuntimeEntry` | liveness/process metadata and existing diagnostics when runtime view needs it | process bootstrap transport timeline, volatile submit-stage diagnostics |
+| `PersistedTeamLaunchSnapshot` | member map + summary projection | low-level transport event arrays |
+
+Implications:
+
+- selected user-facing process bootstrap message goes into `runtimeDiagnostic`;
+- terminal root cause goes into `hardFailureReason`;
+- bounded supplementary details may go into persisted member `diagnostics[]`, but not into `MemberSpawnStatusEntry`;
+- renderer equality and presentation must continue to work without a new transport-specific field.
+
+## Compatibility and no-migration policy
+
+No migration task is required for existing teams. Old files must remain readable and safe.
+
+Existing data classes:
+
+| Existing data | New behavior |
+|---|---|
+| `launch-state.json` without `bootstrapRunId` | Keep current fallback behavior. Do not synthesize process transport failure from missing fields. |
+| `team.json` without `bootstrapRuntimeEventsPath` | Use canonical current runtime path only if current process metadata exists. Otherwise no enrichment. |
+| old runtime events with only legacy `runId` | Match only as `legacy-current` and only with current pid/path/launch boundary. |
+| old runtime events with no submit-stage events | Do not infer submit failure. Timeout/generic behavior remains. |
+| old bootstrap-state `failureReason` | Sanitize and preserve as terminal only if member state is actually failed. |
+| old bootstrap-journal raw `detail`/`reason` | Sanitize before exposing as warning/detail. |
+| stale runtime pid after app restart | Process table check can add liveness diagnostic, but cannot prove availability. |
+
+Do not rewrite old runtime event JSONL or bootstrap-state files in place. New code appends new event fields only during new launches/restarts. Persisted launch-state can self-heal on next write, but only through the normal `TeamLaunchStateStore.write(...)` path.
+
+## Cross-repo producer/consumer contract
+
+This phase crosses repo boundaries. Treat orchestrator as event/metadata producer and desktop as projection consumer.
+
+| Contract field/event | Producer | Consumer | Safety requirement |
+|---|---|---|---|
+| `bootstrapRunId` | orchestrator spawn/poller/runtime event writers | runner and desktop attempt matcher | Additive field. Explicit mismatch rejects. Missing legacy field requires current pid/path/boundary. |
+| `bootstrapRuntimeEventsPath` | orchestrator team/member metadata | desktop safe path resolver | Must resolve under canonical team runtime dir. Never trust arbitrary persisted absolute path. |
+| `runtimePid` | orchestrator process spawn metadata, desktop runtime evidence | desktop liveness/projection, cleanup helpers | Valid positive integer only. Liveness diagnostic, not readiness proof. |
+| `bootstrap_submit_*` events | orchestrator `useInboxPoller` | outcome waiter and timeout diagnostics | Retryable by default unless explicitly terminal. |
+| `bootstrap_submitted` | orchestrator `useInboxPoller` | outcome waiter and desktop pending projection | Prevents `never spawned`; does not confirm availability. |
+| `failed` / `exited` runtime event | child runtime or parent process helper | outcome waiter/projection | Terminal only for current attempt identity and process backend. |
+| `bootstrap_confirmed` | existing proof path | existing strict proof overlay | Availability proof. Do not replace with transport stages. |
+
+Known code-search anchors that must be audited during implementation:
+
+- `spawnMultiAgent.ts` currently has multiple `bootstrapProofToken`/`bootstrapRunId` creation blocks. Each block must either use the shared process attempt helper or be documented as non-process/non-scope.
+- `useInboxPoller.ts` currently writes runtime events with mixed legacy `runId` meanings. New `bootstrapRunId` writes must be explicit and additive.
+- `teamBootstrapRunner.ts` already reads runtime events for bootstrap diagnostics. It must switch to process-only terminal reader plus timeout-only stage reader.
+- `TeamProvisioningService.ts` currently reads `bootstrapRuntimeEventsPath`, `runtimePid`, `bootstrapExpectedAfter`, and persists launch snapshots. It is the right integration point, but not the right place for low-level parsing logic.
+
+## Runtime event trust boundary
+
+Runtime event JSONL files are not a database with trusted consensus semantics. They are append-only diagnostic evidence from parent and child processes. Child-owned events are useful, but they must be treated as untrusted until correlated with the current attempt identity.
+
+Trust levels:
+
+| Writer | Examples | Trusted for | Not trusted for |
+|---|---|---|---|
+| Parent orchestrator process | `process_spawned`, `mailbox_bootstrap_written`, parent-written terminal cleanup event | Current process spawn metadata, mailbox write attempt, parent-observed child exit/cleanup | Bootstrap confirmation unless existing proof path validates it. |
+| Child teammate runtime | `runtime_ready`, `inbox_poller_ready`, `bootstrap_prompt_observed`, `bootstrap_submitted`, child `failed` | Transport progress after strict current-attempt correlation | Provider/root failure replacement, availability, cleanup authority. |
+| Existing proof validator | `bootstrap_confirmed` with valid proof envelope | Availability confirmation | Generic transport stage classification. |
+| Desktop process table | pid/liveness rows | Current OS liveness diagnostic | Bootstrap submitted/confirmed, provider health, root cause. |
+
+Rules:
+
+- child-owned transport events can prevent false `never spawned`, but cannot mark `confirmed_alive`;
+- child-owned `failed` can become terminal only if it matches current attempt identity and passes sanitizer/bounding;
+- child-owned `failed` cannot overwrite a provider/auth/quota/model hard failure;
+- child-owned events cannot request process cleanup by themselves. Cleanup remains parent/service-owned and identity-guarded;
+- parent-owned events are still diagnostic unless they represent explicit terminal process lifecycle observed by the parent;
+- if writer identity is ambiguous, downgrade to `diagnostic-only` or ignore;
+- do not add any event type that makes child text authoritative for availability.
+
+Spoofing and corruption assumptions:
+
+- a broken child process can write malformed JSONL, stale `bootstrapRunId`, wrong member names, or misleading `detail`;
+- path containment and attempt identity protect current state;
+- stable diagnostic mapper protects UI from raw child text;
+- proof validator remains the only path that can convert child proof into availability.
+
+Dual-key matching rule:
+
+Transport enrichment requires both:
+
+1. Path-level match: runtime event file resolves under the expected team runtime directory and matches the expected current/canonical member runtime path policy.
+2. Payload-level match: event `teamName`, `agentName`/`agentId`, pid/run boundary, and attempt identity match the selected member.
+
+Neither side is sufficient alone.
+
+```ts
+const pathMatch = resolveRuntimeEventPathMatch(candidatePath, expectedRuntimePath)
+const payloadMatch = validateProcessBootstrapAttemptEvent(event, identity)
+
+if (!pathMatch.ok || payloadMatch.level === 'no-match') {
+ return { kind: 'stale_or_mismatched_evidence' }
+}
+```
+
+Rationale:
+
+- path-only trust can misattribute a corrupted/reused file;
+- payload-only trust can accept an event injected through the wrong file;
+- requiring both keeps enrichment conservative and makes wrong-member bugs fail closed.
+
+## Runtime filename collision and ambiguity contract
+
+Process runtime files are per sanitized agent name, not globally collision-proof identities. This means the reader must handle ambiguous filename candidates conservatively.
+
+Ambiguity sources:
+
+- member display names that differ but sanitize to the same runtime file prefix;
+- old persisted `bootstrapRuntimeEventsPath` pointing at a file now reused by another member;
+- Windows reserved basename normalization;
+- case-insensitive filesystem behavior;
+- member rename/edit where config name, runtime metadata name, and UI display name temporarily differ.
+
+Rules:
+
+- prefer persisted runtime member metadata when it has current `bootstrapRunId`/pid boundary;
+- if only fallback path exists and multiple members can map to the same path, do not enrich based on path alone;
+- payload identity can disambiguate only when `agentId` or exact current `agentName` matches;
+- if payload identity is also ambiguous/missing, return `stale_or_mismatched_evidence`;
+- do not create or rename runtime event files during desktop read/projection;
+- do not migrate filenames in this phase.
+
+Test matrix:
+
+| Case | Expected |
+|---|---|
+| Two members share sanitized filename but events have distinct `agentId` | Correct member gets enrichment. |
+| Two members share sanitized filename and event lacks `agentId` | No enrichment. |
+| Persisted path for member A points to member B file | No enrichment unless payload identity also matches A, and current attempt path policy allows it. |
+| Reserved Windows basename like `con` / `aux` | Desktop fallback matches orchestrator `safeWindowsPathSegment` result. |
+| Name with `@` like `bob@team` | Desktop matches the two-step `sanitizeAgentName` then `sanitizeName` runtime filename. |
+| Name with non-ASCII characters | Desktop preserves current hyphen-only fallback and does not invent a different slug. |
+| Two names collide after non-ASCII or punctuation replacement | Missing payload identity yields no enrichment for both. |
+| Member renamed after runtime file created | Prefer persisted runtime metadata for current attempt; otherwise no enrichment. |
+| Payload `agentId` mismatches current member but path matches | Hard no-match. |
+| Payload `bootstrapRunId` mismatches current attempt but pid matches | Hard no-match. |
+
+## Artifact source-of-truth model
+
+The implementation must not treat every persisted artifact as equally authoritative. Each file has a different responsibility.
+
+| Artifact | Authoritative for | Not authoritative for |
+|---|---|---|
+| `bootstrap-state.json` | Deterministic bootstrap confirmation/failure truth from orchestrator. | Process transport stage unless explicitly recorded by the runner. |
+| runtime events JSONL | Process transport chronology and last-stage diagnostics. | Availability/readiness proof unless it contains valid bootstrap confirmation handled by the existing proof validator. |
+| `team.json` / member bootstrap metadata | Expected runtime metadata and canonical runtime event path hints. | Current liveness, current failure, or current readiness by itself. |
+| `launch-state.json` | Desktop persisted projection for UI/reconcile. | Source-of-truth for new transport proof if current run boundary is missing. |
+| `launch-summary.json` | Compact list/banner projection. | Detailed diagnostic source or recovery source. |
+| process table | Current OS liveness. | Bootstrap submit, bootstrap confirmation, provider health, or task availability. |
+
+Projection rule:
+
+1. Read source artifacts.
+2. Normalize/sanitize them into internal evidence objects.
+3. Merge through pure precedence helpers.
+4. Persist only through `TeamLaunchStateStore.write(...)`.
+
+Do not update `launch-summary.json` directly. Do not let `launch-state.json` feed back into source truth unless it is scoped to the current run and only used as previous projection state.
+
+This prevents loops where a stale projection from yesterday becomes today's source of truth.
+
+List/detail consistency rule:
+
+- team list can read `launch-summary.json` for speed;
+- team detail/reconcile should prefer detailed `launch-state.json` plus current evidence;
+- if `launch-summary.json` is stale or ignored by existing summary projection rules, do not use it to resurrect failed/pending state;
+- after enriched projection, write detailed state and summary together via `TeamLaunchStateStore.write(...)`;
+- tests should compare the member-level detail and summary counters after enrichment, not only one file.
+
+Two-file persistence limitation:
+
+`TeamLaunchStateStore.write(...)` currently performs two separate atomic writes: first detailed `launch-state.json`, then compact `launch-summary.json`. This is not a single atomic transaction across both files.
+
+Plan implications:
+
+- do not claim state/summary writes are globally atomic;
+- design readers so detailed `launch-state.json` is preferred when both files exist and conflict;
+- stale/missing summary after a detailed write is acceptable and self-heals on the next store write;
+- if summary write fails after detailed write, do not roll back detailed state;
+- list-level stale summary mitigation must stay in `choosePreferredLaunchStateSummary(...)` and related stale-summary guards;
+- tests should simulate summary stale/missing after detailed state moved on.
+
+Do not add a custom transaction protocol for this phase. It would increase complexity and is not needed if readers treat summary as compact projection only.
+
+No-op write skip caveat:
+
+`TeamProvisioningService.writeLaunchStateSnapshotNow(...)` currently skips writes when detailed launch-state is semantically unchanged for the same run. That is normally correct, but it can leave `launch-summary.json` stale or missing if a previous two-file write failed after detailed state succeeded.
+
+Plan implication:
+
+- transport enrichment must happen before semantic no-op comparison;
+- no-op skip remains allowed only when the compact summary projection is also known fresh enough or summary repair is not needed;
+- if implementation detects missing/stale summary while detailed state is unchanged, force a store write through `TeamLaunchStateStore.write(...)` to repair the summary projection;
+- do not direct-write `launch-summary.json` as a shortcut;
+- if checking summary freshness would add too much IO in a hot path, rely on the existing no-op refresh TTL but document that list view may lag until refresh. Preferred for this phase: bounded summary freshness check only inside the existing serialized store operation.
+
+Example safe skip shape:
+
+```ts
+const canSkipDetailedWrite =
+ allowNoopSkip &&
+ sameRun &&
+ previousSnapshot &&
+ semanticallyEqual(previousSnapshot, normalizedSnapshot) &&
+ !isLaunchStateNoopRefreshDue(previousSnapshot)
+
+if (canSkipDetailedWrite && !(await needsLaunchSummaryRepair(teamName, normalizedSnapshot))) {
+ return { snapshot: previousSnapshot, wrote: false }
+}
+
+await launchStateStore.write(teamName, normalizedSnapshot)
+```
+
+## Observability without UI spam
+
+This phase improves launch diagnostics, not general runtime error notification behavior.
+
+Logging/diagnostic rules:
+
+- structured progress may mention transport stage, but not raw event payload;
+- launch-state can store one selected diagnostic per member plus a bounded diagnostics list;
+- runtime logs/events may contain more detail, but UI projections expose only the selected sanitized summary;
+- retryable submit rejection should not trigger critical runtime error notifications;
+- pending transport stages should not send lead/user messages by themselves;
+- terminal current-attempt transport failure can update member card/banner through existing launch-state fields;
+- repeated identical terminal diagnostics should be deduped by member/run/kind before emitting progress/notifications;
+- diagnostics should include provider/member/team context through structured fields, not by concatenating long text.
+
+Recommended internal shape:
+
+```ts
+export interface ProcessBootstrapTransportDiagnostic {
+ kind: ProcessBootstrapTransportFailureKind
+ stage: ProcessBootstrapTransportStage
+ severity: 'info' | 'warning' | 'error'
+ message: string
+ stableCode: string
+ observedAt?: string
+ currentAttempt: boolean
+}
+```
+
+Renderer rule:
+
+- renderer continues to consume existing status/error/diagnostic fields;
+- no new renderer field for transport kind in this phase;
+- if future UI needs richer transport explanations, add a dedicated presentation mapper later, not raw runtime event fields.
+
+## Renderer presentation contract
+
+Current renderer equality and presentation code creates a few important constraints:
+
+- `memberSpawnStatuses` equality compares `status`, `launchState`, `error`, `hardFailureReason`, `runtimeDiagnostic`, `runtimeDiagnosticSeverity`, liveness fields, and bootstrap flags;
+- `MemberSpawnStatusEntry` has no `diagnostics[]` field today. Do not add it for this phase;
+- `memberSpawnStatuses` equality intentionally ignores raw timing fields;
+- `teamAgentRuntime` equality compares `runtimeDiagnostic`, `runtimeDiagnosticSeverity`, `runtimeLastSeenAt`, and `diagnostics[]`;
+- launch diagnostic presentation prefers selected `runtimeDiagnostic`/`hardFailureReason` over long diagnostic arrays;
+- `bootstrapStalled` has existing presentation semantics and should not be used for ordinary submit-pending state.
+
+Plan implications:
+
+- for card/banner visibility, put the selected stable transport message in `runtimeDiagnostic`;
+- for terminal root cause, put stable provider/transport root cause in `hardFailureReason`;
+- do not rely only on `diagnostics[]` for user-visible launch failure;
+- do not write volatile process transport diagnostics into `TeamAgentRuntimeEntry.diagnostics[]`;
+- pending states use `runtimeDiagnosticSeverity: 'info' | 'warning'`, not `'error'`;
+- terminal current-attempt transport failures use `runtimeDiagnosticSeverity: 'error'` and `launchState: 'failed_to_start'`;
+- do not set `bootstrapStalled` for normal `bootstrap_submitted` waiting-for-confirmation; reserve it for the existing bounded stall semantics.
+
+Example:
+
+```ts
+// Pending and visible, but not critical.
+{
+ launchState: 'runtime_pending_bootstrap',
+ runtimeDiagnostic: 'Bootstrap prompt was submitted; waiting for bootstrap confirmation.',
+ runtimeDiagnosticSeverity: 'info',
+}
+
+// Terminal and actionable.
+{
+ launchState: 'failed_to_start',
+ status: 'error',
+ hardFailure: true,
+ hardFailureReason: 'Teammate process exited before bootstrap confirmation.',
+ runtimeDiagnosticSeverity: 'error',
+}
+```
+
+## Write-churn and render stability budget
+
+Transport diagnostics must be stable across polling/refresh cycles. Existing semantic equality for launch-state ignores `lastEvaluatedAt` and `lastRuntimeAliveAt`, but it does not ignore `runtimeDiagnostic`, `hardFailureReason`, or `diagnostics[]`. If transport diagnostics include changing timestamps, attempt counts, raw event order, or noisy stderr tails, the app can re-write launch-state and re-render on every refresh.
+
+Rules:
+
+- persisted `runtimeDiagnostic`, `hardFailureReason`, and `diagnostics[]` must use stable text for the same `(team, member, run, failureKind, stage)`;
+- do not embed `observedAt`, wall-clock timestamps, raw event index, PID command line, stdout/stderr tail, or retry attempt counter in persisted diagnostic text;
+- keep changing metadata in non-semantic fields only when existing types already support it, for example `lastEvaluatedAt`;
+- dedupe diagnostics by stable diagnostic code/message, not by full raw detail;
+- a repeated same-stage pending diagnostic should not change semantic launch-state;
+- a later stronger stage can replace/append a diagnostic, for example `runtime_ready` -> `bootstrap_submitted` -> terminal failure;
+- terminal provider/root failure text remains stable after redaction and is not replaced by later generic transport text;
+- renderer/store equality should not see a new object unless meaningful status/diagnostic semantics changed.
+
+Example stable diagnostic mapping:
+
+```ts
+const TRANSPORT_DIAGNOSTIC_MESSAGES = {
+ bootstrap_submitted:
+ 'Bootstrap prompt was submitted; waiting for bootstrap confirmation.',
+ process_started_no_submit:
+ 'Process backend started but bootstrap prompt has not been submitted yet.',
+ accepted_without_message_id:
+ 'Bootstrap prompt submit was accepted without a durable message id.',
+ process_exited_before_confirmation:
+ 'Teammate process exited before bootstrap confirmation.',
+} as const
+```
+
+Do not format this:
+
+```ts
+// Bad: changes every refresh and breaks no-op skip.
+`Bootstrap submitted at ${event.timestamp}; waiting for confirmation.`
+```
+
+## Runtime event ordering and clock policy
+
+Runtime events come from multiple processes. Their timestamps are useful, but not strong enough to define truth alone.
+
+Ordering rules:
+
+- unknown event types are skipped for transport classification, not treated as failure;
+- within one JSONL file, append order is the primary order;
+- `timestamp` is used for display and for coarse boundary checks, not as the only ordering mechanism;
+- `notBeforeMs` from the parent/current attempt is the launch boundary;
+- event lines before `notBeforeMs` are ignored unless they have an explicit matching current `bootstrapRunId`;
+- if file append order and timestamp disagree, prefer append order for stage selection and use timestamp only as diagnostic metadata;
+- a later low-value event such as `heartbeat` must not hide an earlier high-value submit/failure stage for timeout summary;
+- corrupt/partial final lines are ignored without discarding earlier valid lines.
+
+Clock-skew expectation:
+
+- all processes normally run on the same machine, but tests should still cover out-of-order timestamps;
+- no state transition should require exact timestamp equality;
+- never use wall-clock timestamp alone to match a restart attempt.
+
+## Runtime event causality and partial-stream policy
+
+Runtime events are a diagnostic stream, not a transactional log. Parent and child processes can write events in different orders, and any individual event write can be missing because event writes are best-effort.
+
+Expected causal chain:
+
+```text
+process_spawned
+mailbox_bootstrap_written
+bootstrap_prompt_observed
+bootstrap_submit_attempted
+bootstrap_submitted
+bootstrap_confirmed proof through existing strict proof validator
+```
+
+Do not implement this as a required linear sequence. Implement it as partial facts:
+
+| Observed facts | Interpretation |
+|---|---|
+| `bootstrap_submitted` without `mailbox_bootstrap_written` | Submitted wins for transport pending. Missing parent event is diagnostic only. |
+| `bootstrap_prompt_observed` without `mailbox_bootstrap_written` | Child saw a prompt; parent event may be missing. Pending transport stage. |
+| `mailbox_bootstrap_written` without `bootstrap_prompt_observed` | Parent wrote the mailbox row, but child did not observe it. Timeout diagnostic: mailbox written, teammate did not pick it up. |
+| `process_spawned` only | Process started, no bootstrap prompt evidence. Timeout diagnostic: process started but bootstrap prompt was not observed/submitted. |
+| `bootstrap_submit_attempted` without accepted/submitted | Attempted locally, no durable message id. Timeout diagnostic remains submit-stage pending or terminal only if explicit non-retryable rejection/exit/failure appears. |
+| `bootstrap_submitted` followed by process exit before confirmation | Transport submit succeeded, runtime exited before durable bootstrap confirmation. Terminal process-exited unless strict proof later confirms. |
+
+Safety rules:
+
+- missing intermediate events must not erase a later stronger event;
+- missing parent-owned events must not fail a child that later submitted;
+- parent-only events must never imply child observation or availability;
+- child-only submit events are allowed to prevent false `never spawned`, but still cannot mark readiness;
+- if only `mailbox_bootstrap_written` is present at timeout, use a stable diagnostic like `Bootstrap prompt was written to mailbox, but teammate did not observe it before timeout.`;
+- if only `process_spawned` is present at timeout, use a stable diagnostic like `Process started, but no bootstrap prompt observation was recorded before timeout.`;
+- these stable messages must not include raw mailbox path, prompt text, pid command, or timestamps.
+
+## IO and performance budget
+
+This phase must not reintroduce broad scans or team-open freezes.
+
+Rules:
+
+- runtime event reads are bounded tail reads, not full-file reads;
+- only read runtime events for members that are process backend, current/recent launch candidates, and not already confirmed/skipped/removed unless proof overlay needs them;
+- skip OpenCode lanes and tmux/in-process lanes before file IO;
+- do not read runtime event files from team list summary path unless detailed launch-state/current evidence needs repair;
+- do not scan all historical session directories or transcript roots;
+- do not read runtime events for every team on app startup;
+- per-team projection enrichment runs inside existing serialized launch-state operation and should read at most one runtime event file per relevant process member;
+- if a team has many members, cap total event bytes read per projection pass and degrade remaining members to no enrichment rather than blocking UI;
+- missing/unreadable event files return no evidence quickly.
+
+Suggested limits:
+
+```ts
+const PROCESS_BOOTSTRAP_EVENT_TAIL_BYTES = 256 * 1024
+const PROCESS_BOOTSTRAP_MAX_EVENT_FILES_PER_PASS = 32
+const PROCESS_BOOTSTRAP_MAX_TOTAL_EVENT_BYTES_PER_PASS = 2 * 1024 * 1024
+```
+
+These numbers can be adjusted during implementation, but the plan requires explicit limits.
+
+## Risk register and mitigations
+
+| Risk | Why it is dangerous | Mitigation in this plan |
+|---|---|---|
+| `bootstrap_submitted` accidentally becomes readiness | Would mark broken teammates as available | `confirmed_alive` remains gated only by durable bootstrap confirmation/proof. Submitted only prevents misleading `never spawned`. |
+| Retryable submit rejection becomes terminal | Temporary mailbox/runtime race would fail otherwise healthy launch | `bootstrap_submit_rejected` is retryable unless explicitly `retryable: false`; later submit supersedes it. |
+| Transport diagnostics leak secrets or paths | Runtime stderr/command/env can include keys, cwd, config paths | Sanitizers run at write/emitter/read/projection boundaries, and user-visible diagnostics use allowlisted stage text. |
+| Runtime event file grows large or corrupt | Full read can freeze or fail; corrupt last line is common during concurrent append | Bounded tail reader, regular-file checks, corrupt-line skip, later-valid-event preservation. |
+| Parent event pid defaults to parent process | Desktop could attribute wrong process and kill wrong pid | Parent wrapper requires child `runtimePid`; e2e asserts event pid is child pid. |
+| Post-registration cleanup kills wrong process | PID reuse or stale metadata can kill unrelated user process | Only `killProcessBackendRuntime` with expected team/agent identity after registration. Raw `killProcessTree` only for freshly spawned pre-registration child. |
+| Bootstrap-state stale `failureReason` survives later success | UI keeps showing old errors after recovery | `markMemberResult` clears stale failureReason for non-failed states; tests cover stale failure cleanup. |
+| Launch summary diverges from launch-state | Banner/counts disagree with cards | All enrichment persists through `TeamLaunchStateStore.write(...)`; no direct summary writes. |
+| Non-process backends start using process diagnostics | tmux/in-process/OpenCode could show wrong failure messages | `shouldReadProcessRuntimeTransportEvents` requires `backendType === 'process'` and valid runtime pid/path. |
+| Fake e2e misses actual production branches | Implementation looks tested but launch still unstable | Phase 0 branch inventory hard stop plus fake modes for all terminal/non-terminal transport outcomes. |
+| Old restart/runtime event contaminates a new attempt | A stale `bootstrap_submitted` or `failed` event can make current UI flip between starting/failed/ready-looking | Evidence key includes team/member/agent id plus current `bootstrapRunId` when present and current `runtimePid`/launch boundary. Old pid/path/run events are ignored. |
+| Persisted runtime pid is stale after app restart | UI can show failed-but-alive or alive-but-failed using old pid metadata | Stale pid can explain liveness only after process table verification. It cannot prove submit or confirmation. |
+| A new restart clears a real old provider failure too early | User loses the real reason before replacement attempt has materialized | Clear old terminal failure only when a new launch/restart attempt has a distinct current runtime/attempt marker. Pending metadata alone does not erase root cause. |
+| Concurrent refresh reads half-updated artifacts | Cards/banner can disagree for one refresh cycle or persist wrong merge | Read artifacts into immutable snapshots, merge once, then persist through a single store write. Never mutate member state while still reading evidence. |
+| Timestamp order differs from append order | Last-stage diagnostic can point to wrong event | Use JSONL append order as primary sequence and timestamp only for boundary/display. |
+| Restart occurs while launch finalization is running | Old launch finalizer can overwrite newer restart state | Attempt identity and selected launch boundary must be checked immediately before persisting projection. If current run changed, drop the old projection. |
+| Live process exits between liveness check and cleanup | Cleanup can report wrong state or try to kill a dead process | Treat process kill/liveness as best-effort and re-check identity where possible. Dead process becomes diagnostic, not exception unless it proves terminal launch failure. |
+| Transport progress triggers notification storm | Retryable/pending stages can repeat during launch | Only terminal current-attempt failures update error state. Retryable/pending stages stay card/banner diagnostics and are deduped by member/run/kind. |
+| Missing event file is treated as failure | Best-effort event writes can fail on filesystem race or permissions | Missing/unreadable event file is `event_unavailable`, not root failure. Existing timeout path remains. |
+| Provider failure gets hidden under transport timeout | Real auth/quota/model failures become harder to debug | Provider failure precedence matrix always wins and transport timeout can only append secondary diagnostic. |
+| Transport diagnostic text changes every refresh | Launch-state no-op skip breaks and UI rerenders repeatedly | Persist stable diagnostic messages. Put volatile time/pid metadata only in existing non-semantic fields/logs. |
+| Runtime evidence reads become broad scans | Team open/refresh performance regresses | Read bounded tails only for current process-backend candidates, cap files/bytes per pass, skip list path. |
+| Diagnostic is persisted only in `diagnostics[]` and not shown | User still sees generic or disappearing card state | Selected user-facing transport message must populate `runtimeDiagnostic` or `hardFailureReason` depending on terminality. |
+| Pending transport warning is marked severity `error` | UI treats normal waiting as critical failure | Pending transport states use info/warning. Error severity only for terminal current-attempt failures. |
+| Runtime snapshot diagnostics get volatile transport details | Runtime polling causes render churn | Keep process bootstrap transport details in launch-state projection, not volatile runtime diagnostics arrays. |
+| Implementation adds `diagnostics[]` to `MemberSpawnStatusEntry` | Shared IPC/UI contract expands unnecessarily and equality/presentation behavior becomes ambiguous | Use existing `runtimeDiagnostic`/`hardFailureReason`; keep supplementary details in persisted launch member state only. |
+| Stop/cancel races with final timeout | User stops team, then stale timeout rewrites member as bootstrap failed | Check cancel/processKilled/current run immediately before persist. Stop/cancel path owns state. |
+| Restart races with old finalizer | Old finalizer overwrites new restart pending state with old failure | Attempt identity plus internal restart generation/current runtime boundary before write. |
+| Failure state flickers | UI alternates between failed/starting because weak evidence clears root cause | Failure monotonicity helper. Pending metadata cannot clear terminal root failure. |
+| Generic timeout hides later provider failure | User sees transport issue instead of real auth/quota/model problem | Provider/runtime hard failure wins inside same attempt. Generic timeout can be replaced by provider failure. |
+| Child-owned event is over-trusted | Broken child can write misleading `failed`/`submitted` events and corrupt current state | Runtime event trust boundary. Child events require strict current-attempt match and never confirm availability. |
+| Child `detail` text drives state machine | Regex/string parsing can turn arbitrary text into terminal state | Failure kind derives from event type and match level only. Detail is diagnostic text after sanitizer. |
+| Parent and child events conflict | Parent saw process exit, child wrote late `submitted` | Parent-observed terminal lifecycle for the current pid wins over later child progress unless strict proof confirms. |
+| Event file path points to another member's runtime file | One member receives another member's transport state | Path containment plus event payload identity plus expected filename/member matching. Wrong-member events ignored. |
+
+## Uncertainties to resolve before coding
+
+These are not optional implementation details. Resolve them during Phase 0 inventory and update the plan if facts differ.
+
+1. Exact process-backend branch count in `spawnMultiAgent.ts`.
+ Expected: one primary helper can cover every `backendType === 'process'` path. If not, document each exception.
+2. Restart semantics for process backend members.
+ Determine whether restart always creates a new process, can reuse an existing process, or can only send a restart instruction. Attempt matching depends on this.
+3. Runtime event path ownership.
+ Confirm the canonical events path is stable per member or per runtime attempt. If stable per member, `notBeforeMs` and pid/run filtering become mandatory.
+4. Submit result contract in `useInboxPoller`.
+ Current known shape does not include a rejection reason. If implementation finds richer error data, sanitize it first and still keep retryable default conservative.
+5. Cross-platform realpath behavior.
+ Validate symlink/path containment on macOS/Linux/Windows-like reserved basenames in tests. Do not assume POSIX-only paths.
+6. Event append atomicity.
+ JSONL append can be partially written or interleaved. Reader must tolerate partial/corrupt lines and never require a perfect final line.
+7. Provider hard failure source.
+ Identify all places that can set provider/auth/quota/model failure so process transport diagnostics do not overwrite them.
+8. Shared type pressure.
+ If implementation appears to require new renderer/shared fields, stop and update this plan. The expected design uses existing `runtimeDiagnostic`, `runtimeDiagnosticSeverity`, `hardFailureReason`, and launch-state fields.
+9. Runtime vs launch diagnostic placement.
+ If a diagnostic is about bootstrap transport, prefer launch-state projection. Do not put volatile bootstrap transport timelines into `TeamAgentRuntimeEntry.diagnostics[]` unless the runtime panel specifically needs stable liveness info.
+
+If any uncertainty is unresolved, prefer no enrichment over wrong enrichment. A generic timeout is better than a false ready or a wrong root cause.
+
+## Phase 0 - Process backend branch inventory and scope lock
+
+Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+Before implementation, identify every branch that can produce `backendType: 'process'` or `backend_type: 'process'`.
+
+Hard stop condition:
+
+- if any process-backend branch can spawn a teammate without going through the new shared outcome waiter, update this plan before implementation;
+- if any branch writes `runtimePid`, `backendType`, `bootstrapExpectedAfter`, or `bootstrapRuntimeEventsPath` without the expected metadata set, update this plan before implementation;
+- if any process branch still uses direct `killProcessTree(runtimePid)` after team/member registration is materialized, update this plan before implementation;
+- if a branch cannot provide stable `teamName`, `agentName`, `agentId`, `runtimePid`, and launch boundary to the outcome waiter, leave it on existing behavior and document why.
+
+Known anchors from code search:
+
+- `handleSpawnNativeProcess(...)` is the primary app-launched process backend path;
+- `handleSpawnSplitPane(...)` can persist `detectionResult.backend.type`, so it must be checked for process backend selection;
+- `handleSpawnSeparateWindow(...)` is tmux-only today;
+- `handleSpawnInProcess(...)` is not part of this phase;
+- `registerOutOfProcessTeammateTask(...)` handles abort/cleanup for pane and process backends.
+- code search currently shows multiple `bootstrapProofToken`/`bootstrapRunId` creation blocks in `spawnMultiAgent.ts`; do not assume the first one is the only process path.
+- desktop code has raw `killProcessTree(child)` call sites. They must be classified as freshly-spawned child cleanup or replaced/guarded before being considered safe for registered process runtimes.
+
+Inventory checklist:
+
+- normal deterministic team launch process backend;
+- launch reattach/restart path for existing team members;
+- app-managed native bootstrap path;
+- forced process teammates path from desktop launch env;
+- mixed provider launch path where process backend is used for Claude/Codex while OpenCode lanes are handled separately;
+- cleanup path for pre-registration child process;
+- cleanup path for post-registration process backend runtime;
+- tests/fakes that bypass normal spawn helpers.
+
+For restart/reattach specifically, capture whether the code creates a new runtime process, reuses an existing process, or only writes a restart instruction to an existing lane. The outcome waiter is valid only for paths that have a current process runtime and current bootstrap prompt attempt. It must not infer submit state from a previous runtime event file after a manual restart.
+
+Kill-path audit:
+
+- `killProcessTree(child)` is acceptable only when the child process was spawned by the current function and has not yet been published as a registered teammate runtime;
+- after metadata publication, cleanup must use identity-guarded process backend termination;
+- if the process is already gone, cleanup records stale/dead diagnostic and does not throw unless the current launch state requires terminal failure;
+- if identity cannot be verified, cleanup must fail closed and leave process alone.
+
+Concurrency lock expectation:
+
+- do not introduce a new broad global lock;
+- reuse existing per-team/per-run coordination where available;
+- `TeamProvisioningService.persistLaunchStateSnapshot(...)` already routes through `enqueueLaunchStateStoreOperation(...)`; integrate projection enrichment inside that flow instead of adding a parallel writer;
+- if no suitable lock exists for a projection write, add the smallest per-team serialization point around `read evidence -> merge -> TeamLaunchStateStore.write`;
+- never hold a lock while waiting on a provider/model response;
+- bounded local file reads for current runtime evidence are acceptable inside the store operation, but they must stay capped and best-effort;
+- do hold the attempt identity/current run check close to the final persist step;
+- if the selected run changed while evidence was being read, discard the stale projection and let the next refresh handle the new run.
+
+Implementation rule:
+
+```ts
+if (backendType === 'process') {
+ // use shared process bootstrap transport helper
+} else {
+ // keep tmux/iterm2/in-process behavior unchanged
+}
+```
+
+Do not duplicate submit-outcome waiting logic separately in each branch. Extract a small helper if more than one process branch needs it.
+
+Add a parent-owned event wrapper:
+
+```ts
+async function writeProcessBackendRuntimeEvent(params: {
+ type: TeammateRuntimeEventType
+ runtimePid: number
+ runtimeEventsPath: string
+ teamName: string
+ agentName: string
+ agentId: string
+ bootstrapRunId?: string
+ source: string
+ detail?: string
+}): Promise {
+ await writeTeammateRuntimeEvent({
+ type: params.type,
+ eventsPath: params.runtimeEventsPath,
+ pid: params.runtimePid,
+ teamName: params.teamName,
+ agentName: params.agentName,
+ agentId: params.agentId,
+ bootstrapRunId: params.bootstrapRunId,
+ source: params.source,
+ detail: sanitizeRuntimeDiagnosticText(params.detail ?? ''),
+ })
+}
+```
+
+Do not change the generic writer default pid behavior, because child-runtime writers rely on it.
+
+The wrapper should preserve best-effort semantics:
+
+```ts
+await writeProcessBackendRuntimeEvent(...).catch(() => undefined)
+```
+
+or call the existing writer, which already catches internally. Do not make parent event write failure throw a launch error.
+
+Suggested helper shape:
+
+```ts
+async function waitForProcessBackendBootstrapSubmitOrThrow(params: {
+ runtimePid: number
+ processPaneId: string
+ runtimeEventsPath: string
+ teamName: string
+ agentName: string
+ agentId: string
+ bootstrapRunId: string
+ launchBoundaryIso: string
+ teammateId: string
+}): Promise {
+ // calls waitForBootstrapSubmissionOutcome
+ // terminates only identity-matched process backend runtime on terminal submit failure
+ // throws sanitized, bounded error
+}
+```
+
+Cleanup rule:
+
+```ts
+async function terminateLaunchOwnedProcessBackendRuntime(params: {
+ runtimePid: number
+ processPaneId: string
+ teammateId: string
+ teamName: string
+ phase: 'before-registration' | 'after-registration'
+}): Promise {
+ if (params.phase === 'before-registration') {
+ // Freshly spawned child that has not been exposed as team runtime metadata yet.
+ killProcessTree(params.runtimePid, 'SIGTERM')
+ return true
+ }
+
+ // Once the process is published as a process backend pane, never blind-kill by pid.
+ return killProcessBackendRuntime(params.processPaneId, {
+ expectedAgentId: params.teammateId,
+ teamName: params.teamName,
+ signal: 'SIGTERM',
+ })
+}
+```
+
+Reason: `killProcessBackendRuntime(...)` already fails closed unless the live command line contains the exact `--agent-id` and `--team-name`. A raw PID can be stale or reused after app restart, so post-registration cleanup must not fall back to `killProcessTree(...)` if the identity guard fails.
+
+Do not add a second process identity parser for this cleanup path. Session shutdown cleanup already uses `killProcessBackendRuntime(...)`; the submit-outcome helper should reuse the same utility so process backend identity semantics stay consistent.
+
+Safety checks:
+
+- no change to tmux pane lifecycle;
+- no change to in-process teammate lifecycle;
+- no change to OpenCode bridge launch path;
+- process cleanup only targets the launch-owned `runtimePid`, not a shared host.
+- after process backend metadata is registered, cleanup uses `killProcessBackendRuntime(processPaneId, { expectedAgentId, teamName })`;
+- raw `killProcessTree(runtimePid)` is allowed only for a freshly spawned child before it is published as teammate runtime metadata;
+- if the identity guard cannot verify the process, cleanup records a diagnostic and leaves the process alone instead of killing an unrelated PID.
+
+## Phase 1 - Add runtime diagnostic redaction/bounding in both repos
+
+Repos:
+
+- `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+- `/Users/belief/dev/projects/claude/claude_team`
+
+Desktop helper exists:
+
+```text
+/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamRuntimeLivenessResolver.ts
+sanitizeProcessCommandForDiagnostics()
+```
+
+Do not use `sanitizeDisplayContent(...)` from `src/shared/utils/contentSanitizer.ts` as the security sanitizer for launch/runtime diagnostics. That helper is for UI/XML message display. It removes some display noise, but it does not guarantee provider key redaction, path removal, command-line filtering, or full event payload bounding.
+
+Plan:
+
+- orchestrator must sanitize before writing runtime `detail`, bootstrap-state `failureReason`, and structured progress summaries, because these strings can be persisted/emitted before desktop reads them;
+- `TeamBootstrapStateStore.markMemberResult(...)` should enforce this at the persistence boundary too: sanitize failed `failureReason`, clear stale `failureReason` for non-failed results, and write sanitized journal detail;
+- `TeamBootstrapJournal.append(...)` should defensively sanitize `detail` and `reason` fields before appending JSONL, because journal records are also persisted diagnostic output;
+- `TeamBootstrapProgressEmitter` should defensively sanitize/bound outgoing `reason`, `member_spawn_result.reason`, and `failed_members[].reason` fields before calling `structuredIO.write(...)`;
+- desktop must defensively sanitize again when projecting runtime/launch diagnostics into UI fields;
+- keep command sanitizer there or move to small shared runtime diagnostic helper if needed;
+- cap persisted user-visible diagnostics, for example `2_000` chars per reason and bounded diagnostic list;
+- do not classify provider/runtime errors with regex here. This helper only redacts and bounds text.
+
+Example orchestrator-side shape:
+
+```ts
+export function sanitizeRuntimeDiagnosticText(input: string, maxLength = 2_000): string {
+ return redactSecrets(input)
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, maxLength)
+}
+```
+
+In orchestrator, prefer the existing low-level `redactSecrets()` implementation if dependency direction is safe. If it is not safe to import from its current location, extract the secret-pattern redaction into a lower-level shared utility first instead of adding another unrelated redactor.
+
+In desktop, there is no equivalent production helper in the team main-service layer today, so add a narrow team runtime diagnostic sanitizer or extract one if another production-safe sanitizer is found during implementation.
+
+Desktop read points that must use the defensive sanitizer before exposing diagnostics:
+
+- `TeamBootstrapStateReader` when mapping bootstrap-state `failureReason`;
+- `TeamBootstrapStateReader` owner-dead overlay when applying owner failure reason to pending members;
+- `TeamBootstrapStateReader` journal warning/detail extraction from existing `bootstrap-journal.jsonl`, because legacy journal records may already contain unsanitized `detail` or `reason`;
+- `TeamProvisioningService` when merging transport evidence into `MemberSpawnStatusEntry`;
+- `TeamLaunchStateEvaluator` only through already-normalized input fields, without adding IO there. It should still bound/redact `hardFailureReason`, `runtimeDiagnostic`, `skipReason`, and `diagnostics[]` during normalization because persisted launch-state can come from older builds or tests;
+- renderer-facing diagnostics helpers should keep their existing bounding, but should not become the primary secret-redaction layer.
+
+Desktop launch-state normalizer shape:
+
+```ts
+const MAX_PERSISTED_MEMBER_DIAGNOSTICS = 6
+
+function normalizeLaunchDiagnosticText(value: unknown, maxChars = 500): string | undefined {
+ const text = typeof value === 'string' ? value.trim() : ''
+ if (!text) return undefined
+ return redactTeamRuntimeDiagnosticText(text).replace(/\s+/g, ' ').slice(0, maxChars)
+}
+
+function normalizeLaunchDiagnostics(value: unknown): string[] | undefined {
+ if (!Array.isArray(value)) return undefined
+ const normalized = value
+ .map((item) => normalizeLaunchDiagnosticText(item))
+ .filter((item): item is string => Boolean(item))
+ return normalized.length > 0
+ ? Array.from(new Set(normalized)).slice(0, MAX_PERSISTED_MEMBER_DIAGNOSTICS)
+ : undefined
+}
+```
+
+Do not add filesystem IO to `TeamLaunchStateEvaluator`; this is pure normalization of already provided state.
+
+Concrete `markMemberResult(...)` shape:
+
+```ts
+const sanitizedFailureReason =
+ result.status === 'failed' && result.failureReason
+ ? sanitizeRuntimeDiagnosticText(result.failureReason, MAX_FAILURE_REASON_CHARS)
+ : undefined
+const rawJournalDetail = result.detail ?? result.failureReason
+const sanitizedJournalDetail = rawJournalDetail
+ ? sanitizeRuntimeDiagnosticText(rawJournalDetail, MAX_MEMBER_DIAGNOSTIC_CHARS)
+ : undefined
+
+const nextMember = {
+ ...member,
+ status: result.status,
+ lastObservedAt: timestamp,
+ ...(result.agentId ? { agentId: result.agentId } : {}),
+ ...(result.backendType ? { backendType: result.backendType } : {}),
+ ...(typeof result.runtimePid === 'number' && result.runtimePid > 0
+ ? { runtimePid: result.runtimePid }
+ : {}),
+}
+
+if (sanitizedFailureReason) {
+ nextMember.failureReason = sanitizedFailureReason
+} else {
+ delete nextMember.failureReason
+}
+```
+
+Do not rely on an omitted object spread to clear stale state. The clear must be explicit and tested because the current implementation spreads the previous member first.
+
+Call-site rule: update duplicate/already-running call sites to pass `detail: duplicate.reason` instead of `failureReason: duplicate.reason`, because `failureReason` is terminal-failure semantics.
+
+Concrete progress/journal boundary shape:
+
+```ts
+function sanitizeFailedMembersForBootstrapProgress(
+ failedMembers: Array<{ name: string; reason: string }>,
+): Array<{ name: string; reason: string }> {
+ return failedMembers.map((member) => ({
+ name: member.name,
+ reason: sanitizeRuntimeDiagnosticText(member.reason, MAX_FAILURE_REASON_CHARS),
+ }))
+}
+
+async append(record: BootstrapJournalRecord): Promise {
+ await appendJsonLine(this.journalPath, sanitizeBootstrapJournalRecord(record))
+}
+```
+
+The sanitizer must preserve structured fields like member name, phase, status, backend type, and outcome. Only free-text diagnostic fields are redacted/bounded.
+
+Diagnostic formatter rule:
+
+```ts
+function formatTransportDiagnostic(event: ProcessBootstrapTransportRuntimeEvent): string {
+ const stage = event.type
+ const detail = isUserVisibleTransportDetailAllowed(event.type)
+ ? sanitizeRuntimeDiagnosticText(event.detail ?? '')
+ : ''
+ return detail ? `${stage}: ${detail}` : stage
+}
+```
+
+Do not include proof token, hashes, raw path, pid command, cwd, or full event JSON in diagnostics. If a pid is useful, expose it through existing structured pid fields, not diagnostic text.
+
+Suggested user-visible detail allowlist:
+
+- `failed`;
+- `exited`;
+- `bootstrap_submit_rejected`;
+- `bootstrap_submit_accepted_without_uuid`;
+- `bootstrap_submit_deferred` only if detail is a bounded enum/generic reason.
+
+Do not show details from `process_spawned`, `stdout_attached`, `cli_started`, `runtime_ready`, `inbox_poller_ready`, or `mailbox_bootstrap_written`.
+
+Specific orchestrator write points to include:
+
+- `writeTeammateRuntimeEvent(...)` itself should sanitize/bound `detail` at the writer boundary;
+- parent-owned process wrapper should pass only already-sanitized/generic `detail`;
+- `useNativeAppManagedBootstrapContextInjection(...)` load failure event should sanitize the error message before writing a `failed` runtime event;
+- `useInboxPoller(...)` submit-stage event details should use fixed generic/enum strings only;
+- `TeamBootstrapProgressEmitter` and `TeamBootstrapJournal` still sanitize independently because they are separate output/persistence boundaries.
+
+Diagnostic budget:
+
+```ts
+const MAX_RUNTIME_DIAGNOSTIC_CHARS = 500
+const MAX_FAILURE_REASON_CHARS = 1_000
+const MAX_MEMBER_DIAGNOSTICS = 6
+const MAX_MEMBER_DIAGNOSTIC_CHARS = 500
+```
+
+These values are intentionally smaller than file read caps because team summaries may read launch-state through a `32 KiB` budget. If implementation needs different constants, keep the invariant: a normal team with many members should remain summary-readable.
+
+## Phase 2 - Extend runtime event schema additively
+
+Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+File: `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts`
+
+Current union already has:
+
+```ts
+| 'process_spawned'
+| 'stdout_attached'
+| 'cli_started'
+| 'runtime_ready'
+| 'inbox_poller_ready'
+| 'bootstrap_submitted'
+| 'bootstrap_context_loaded'
+| 'bootstrap_confirmed'
+| 'heartbeat'
+| 'failed'
+| 'exited'
+```
+
+Add only missing events:
+
+```ts
+| 'mailbox_bootstrap_written'
+| 'bootstrap_prompt_observed'
+| 'bootstrap_submit_attempted'
+| 'bootstrap_submit_deferred'
+| 'bootstrap_submit_rejected'
+| 'bootstrap_submit_accepted_without_uuid'
+```
+
+Extend event and writer params additively:
+
+```ts
+export type TeammateRuntimeEvent = {
+ version: 1
+ type: TeammateRuntimeEventType
+ timestamp: string
+ pid: number
+ teamName?: string
+ agentName?: string
+ agentId?: string
+ runId?: string
+ bootstrapRunId?: string
+ cwd?: string
+ source?: string
+ bootstrapProofToken?: string
+ contextHash?: string
+ briefingHash?: string
+ bootstrapMirrorId?: string
+ bootstrapMessageId?: string
+ retryable?: boolean
+ attempt?: number
+ detail?: string
+}
+```
+
+The `writeTeammateRuntimeEvent(...)` params type must accept the same new optional fields:
+
+```ts
+bootstrapRunId?: string
+retryable?: boolean
+attempt?: number
+```
+
+Do not add these fields by casting call sites to `any`. The type extension is part of the contract and should make invalid call sites visible.
+
+Backward compatibility rules:
+
+- keep writing the existing `runId` field exactly where current code already writes it;
+- add `bootstrapRunId` as the canonical deterministic launch correlation field;
+- for native app-managed bootstrap, `useInboxPoller` may currently put the native run id into legacy `runId`; new code should write both `runId` and `bootstrapRunId` when `nativeBootstrapInjection.runId` is available;
+- outcome matching should prefer explicit `bootstrapRunId`;
+- only as a legacy fallback, an event with missing `bootstrapRunId` may match `expected.bootstrapRunId` through `event.runId` when the event source/type is known bootstrap-scoped (`bootstrap_submitted`, `bootstrap_context_loaded`, `bootstrap_confirmed`, `failed` from teammate runtime);
+- do not treat arbitrary session `runId` from unrelated events as deterministic bootstrap correlation.
+- absence of `bootstrapRunId`, `attempt`, or new submit-stage events in old files is not a migration error;
+- old launch-state/team config entries without `bootstrapRuntimeEventsPath` must keep existing behavior and must not be converted into transport failure;
+- old events without `retryable` on `bootstrap_submit_rejected` should be treated as retryable unless another terminal event follows;
+- legacy event matching requires a current runtime path/pid/launch-boundary match before falling back to legacy `runId`;
+- never rewrite old runtime event JSONL files in place. New events append only.
+
+Do not remove or change `waitForTeammateRuntimeEvent()`.
+
+Producer call-site requirements:
+
+- all new event writes should go through a typed helper that requires `teamName`, `agentName`, `agentId`, `pid`, and optional `bootstrapRunId`;
+- parent-owned writes must use a helper that requires explicit child `runtimePid`;
+- child-owned writes may keep default `process.pid`;
+- no call site should add `bootstrapRunId` by copying a session id unless that session id is known to be the deterministic bootstrap run id;
+- no call site should include `bootstrapProofToken` on generic transport events. Proof token remains only for proof/context events that already require it.
+
+Preferred writer split:
+
+```ts
+type ParentOwnedRuntimeEventParams = Omit & {
+ pid: number
+ source: 'spawnMultiAgent.process' | 'teamBootstrapRunner.process'
+}
+
+type ChildOwnedRuntimeEventParams = TeammateRuntimeEventWriteParams & {
+ source: 'useInboxPoller' | 'headlessRuntime' | 'runtimeBootstrap'
+}
+
+export async function writeParentOwnedTeammateRuntimeEvent(
+ params: ParentOwnedRuntimeEventParams,
+): Promise {
+ if (!Number.isInteger(params.pid) || params.pid <= 0) {
+ throw new Error('Parent-owned runtime event requires child runtime pid')
+ }
+ return writeTeammateRuntimeEvent(params)
+}
+
+export async function writeChildOwnedTeammateRuntimeEvent(
+ params: ChildOwnedRuntimeEventParams,
+): Promise {
+ return writeTeammateRuntimeEvent(params)
+}
+```
+
+The inner writer can remain backward-compatible, but new process-backend code should use explicit wrappers. This keeps the parent-pid bug out of process-backend launch code without breaking existing child-owned event writes.
+
+Event write failure policy:
+
+- runtime event writes are best-effort and should not kill a teammate process;
+- if event write fails, log a bounded warning and let existing timeout/bootstrap-state paths handle the launch;
+- do not retry event writes in a tight loop;
+- do not persist event-write failure as member provider failure;
+- tests should simulate an unwritable event path and prove launch falls back to existing timeout behavior without crashing the orchestrator.
+
+Compatibility trap to avoid:
+
+```ts
+// Wrong: silently changes meaning of legacy runId everywhere.
+runId: bootstrapRunId
+```
+
+Correct:
+
+```ts
+runId: existingLegacyRunId,
+bootstrapRunId,
+```
+
+Only do this where both values are known. If not known, omit `bootstrapRunId` and let matching fall back conservatively.
+
+Event field allowlist:
+
+| Event family | Allowed fields | Forbidden fields |
+|---|---|---|
+| parent transport: `process_spawned`, `stdout_attached`, `mailbox_bootstrap_written` | `teamName`, `agentName`, `agentId`, child `pid`, legacy `runId`, `bootstrapRunId`, stable `source`, stable sanitized `detail` if needed | `bootstrapProofToken`, `contextHash`, `briefingHash`, raw command, cwd, stdout/stderr path |
+| submit transport: `bootstrap_prompt_observed`, `bootstrap_submit_attempted`, `bootstrap_submit_deferred`, `bootstrap_submit_rejected`, `bootstrap_submit_accepted_without_uuid`, `bootstrap_submitted` | `teamName`, `agentName`, `agentId`, child `pid`, legacy `runId`, `bootstrapRunId`, `bootstrapMirrorId`, `bootstrapMessageId` when relevant, `retryable`, `attempt`, stable sanitized `detail` | raw inbox row, raw prompt text, proof token/hash except existing proof-specific submitted path if already required by proof validator |
+| proof/context: `bootstrap_context_loaded`, `bootstrap_confirmed` | existing strict proof fields as required by validator | process command/log paths, raw event timelines |
+| terminal runtime: `failed`, `exited` | current-attempt identity, child `pid`, stable sanitized reason/detail | raw stderr tail beyond bounded sanitizer, proof token/hash unless proof validator already needs it |
+
+If TypeScript makes this awkward with one broad event type, add tiny typed writer wrappers rather than passing broad objects everywhere.
+
+## Phase 3 - Add bounded recent runtime event reader
+
+Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+File: `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts`
+
+Add:
+
+```ts
+export async function readRecentTeammateRuntimeEvents(
+ eventsPath: string,
+ options: { maxBytes?: number } = {},
+): Promise {
+ const maxBytes = options.maxBytes ?? 256 * 1024
+ // stat file
+ // if file size <= maxBytes, read whole file
+ // else read tail range
+ // when tailing, drop first partial line
+ // ignore final partial line from concurrent writer
+ // preserve chronological order
+}
+```
+
+Keep `readTeammateRuntimeEvents()` unchanged.
+
+Reader safety:
+
+- tolerate missing file as empty event list;
+- validate with `lstat -> open -> fstat` and reject symlink/non-regular files, following the safety model of `teamBootstrapBoundFileRead.ts`;
+- do not reuse `readBoundRegularUtf8File(...)` directly for runtime JSONL, because oversized runtime logs should be tailed rather than rejected;
+- use the opened file handle's `stat().size` as the source of truth for the tail range. If the file grows while reading, that is acceptable; read only the selected stable range and skip a final partial line;
+- tolerate corrupt/partial JSON lines by skipping only those lines;
+- impose a per-line byte/char cap before `JSON.parse`, for example `16 KiB`. Oversized lines are treated as corrupt diagnostic noise and skipped;
+- if a single oversized line consumes the whole tail slice, return an empty list and rely on timeout/fallback. Do not retry with a larger read just to recover diagnostics;
+- parse at most a bounded number of lines/events from one file, for example last `256` candidate lines, after tail slicing;
+- normalize optional fields defensively: `retryable` is used only when it is a boolean, `attempt` only when it is a positive finite number, and unknown event types remain readable but are ignored by process-transport classifiers;
+- when reading a tail slice, drop the first line because it can be a partial middle-of-file line;
+- if the last line has no trailing newline, treat it as potentially partial and skip it unless it parses cleanly and has required event shape;
+- never throw from malformed runtime event content during launch finalization;
+- keep max bytes low enough for hot polling but high enough for bursty startup, default `256 KiB`.
+
+Reader output must preserve append order among accepted parsed events. Skipped corrupt/oversized lines do not create placeholder events and do not affect stage rank except through absence.
+
+## Phase 4 - Add bootstrap submission outcome waiter
+
+Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+File: `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts`
+
+Add:
+
+```ts
+export type BootstrapSubmissionOutcome =
+ | { type: 'submitted'; event: TeammateRuntimeEvent }
+ | { type: 'failed'; reason: string; event?: TeammateRuntimeEvent }
+ | { type: 'accepted_without_uuid'; reason: string; event: TeammateRuntimeEvent }
+ | { type: 'process_exited'; reason: string; event?: TeammateRuntimeEvent }
+ | {
+ type: 'timeout'
+ reason: string
+ lastEventType?: TeammateRuntimeEventType
+ lastEventDetail?: string
+ }
+
+export async function waitForBootstrapSubmissionOutcome(params: {
+ eventsPath: string
+ pid: number
+ teamName?: string
+ agentName?: string
+ agentId?: string
+ bootstrapRunId?: string
+ bootstrapProofToken?: string
+ bootstrapMirrorId?: string
+ sinceMs?: number
+ timeoutMs: number
+ pollIntervalMs?: number
+ isStillAlive?: () => boolean
+}): Promise {
+ // use readRecentTeammateRuntimeEvents
+}
+```
+
+Classification:
+
+- `failed` terminal;
+- `exited` terminal;
+- dead `isStillAlive` terminal;
+- `bootstrap_submit_accepted_without_uuid` terminal;
+- `bootstrap_submit_rejected` terminal only when `retryable === false`;
+- retryable/unknown rejected stays last-stage. Missing or malformed `retryable` must be treated as retryable for backward compatibility;
+- later `bootstrap_submitted` can supersede earlier retryable rejection;
+- timeout includes last stage/detail.
+
+Stage ordering:
+
+```ts
+const bootstrapTransportStageRank: Partial> = {
+ process_spawned: 10,
+ stdout_attached: 20,
+ cli_started: 30,
+ runtime_ready: 40,
+ inbox_poller_ready: 50,
+ mailbox_bootstrap_written: 60,
+ bootstrap_prompt_observed: 70,
+ bootstrap_submit_attempted: 80,
+ bootstrap_submit_deferred: 90,
+ bootstrap_submit_rejected: 100,
+ bootstrap_submit_accepted_without_uuid: 110,
+ bootstrap_submitted: 120,
+ failed: 900,
+ exited: 910,
+}
+```
+
+Use timestamp as primary order and stage rank only to pick the most informative last-stage when multiple relevant events share a timestamp or are observed in one poll. Do not let a lower-rank later heartbeat hide a higher-value submit-stage diagnostic.
+
+Relevance:
+
+```ts
+function isRelevantBootstrapTransportEvent(event, expected) {
+ if (typeof expected.pid === 'number' && typeof event.pid === 'number' && event.pid !== expected.pid) return false
+ if (expected.teamName && event.teamName && event.teamName !== expected.teamName) return false
+ if (expected.agentName && event.agentName && event.agentName !== expected.agentName) return false
+ if (expected.agentId && event.agentId && event.agentId !== expected.agentId) return false
+ if (expected.bootstrapRunId && event.bootstrapRunId && event.bootstrapRunId !== expected.bootstrapRunId) return false
+ if (expected.bootstrapProofToken && event.bootstrapProofToken && event.bootstrapProofToken !== expected.bootstrapProofToken) return false
+ if (expected.bootstrapMirrorId && event.bootstrapMirrorId && event.bootstrapMirrorId !== expected.bootstrapMirrorId) return false
+
+ if (expected.sinceMs) {
+ const eventMs = Date.parse(event.timestamp)
+ if (!Number.isFinite(eventMs) || eventMs < expected.sinceMs) return false
+ }
+
+ return true
+}
+```
+
+Minimum correlation contract:
+
+- process submission outcome requires a valid expected `pid`; if no valid pid is available, return timeout/fallback instead of scanning loosely by file path;
+- process submission outcome and desktop enrichment should require a valid launch boundary (`sinceMs` / `bootstrapExpectedAfter`) when matching historical event files. If the boundary is missing or invalid, skip transport enrichment instead of scanning the whole file;
+- `teamName` and `agentName` should be provided whenever the caller knows them and should reject mismatches;
+- `bootstrapRunId` and `bootstrapProofToken` reject explicit mismatches, but missing legacy fields may still be accepted only when `pid + sinceMs + teamName + agentName` all match;
+- events with no timestamp or invalid timestamp are ignored when `sinceMs` is set;
+- a desktop projection reader must not use transport events to revive/fail a member if it cannot establish at least `teamName + memberName + current run boundary` and either `runtimePid` or `bootstrapRunId`.
+
+Do not rely on legacy `runId` unless the source/type is known launch-scoped.
+
+## Phase 5 - Emit submit-stage facts from `useInboxPoller`
+
+Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+File: `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/hooks/useInboxPoller.ts`
+
+Emit:
+
+```text
+bootstrap_prompt_observed
+bootstrap_submit_attempted
+bootstrap_submit_deferred
+bootstrap_submit_rejected
+bootstrap_submit_accepted_without_uuid
+bootstrap_submitted
+```
+
+Base fields:
+
+```ts
+const base = {
+ teamName,
+ agentName,
+ agentId: currentAppState.teamContext?.selfAgentId,
+ runId: nativeBootstrapInjection?.runId ?? sessionId,
+ bootstrapRunId: nativeBootstrapInjection?.runId,
+ bootstrapMirrorId: bootstrapPendingMessage.messageId,
+ source: 'useInboxPoller',
+}
+```
+
+Submit logic:
+
+```ts
+await writeTeammateRuntimeEvent({ type: 'bootstrap_prompt_observed', ...base })
+await writeTeammateRuntimeEvent({ type: 'bootstrap_submit_attempted', ...base })
+
+const submitted = onSubmitTeammateMessage(prompt, {
+ isMeta: true,
+ ...(nativeBootstrapInjection ? { prefixMessages: nativeBootstrapInjection.prefixMessages } : {}),
+})
+
+if (!submitted.accepted) {
+ await writeTeammateRuntimeEvent({
+ type: 'bootstrap_submit_rejected',
+ ...base,
+ detail: sanitizeRuntimeDiagnosticText('submit rejected by local prompt handler'),
+ retryable: true,
+ })
+ return
+}
+
+if (!submitted.userMessageUuid) {
+ await writeTeammateRuntimeEvent({
+ type: 'bootstrap_submit_accepted_without_uuid',
+ ...base,
+ detail: 'submit accepted without userMessageUuid',
+ retryable: false,
+ })
+ return
+}
+
+await writeTeammateRuntimeEvent({
+ type: 'bootstrap_submitted',
+ ...base,
+ bootstrapMessageId: submitted.userMessageUuid,
+})
+```
+
+Important current contract:
+
+- `onSubmitTeammateMessage(...)` currently returns only `{ accepted: boolean; userMessageUuid?: string }`;
+- there is no structured `submitted.reason` field today;
+- do not add plan logic that depends on a rejection reason unless the submit API is explicitly extended in the same change;
+- a rejected bootstrap submit is still useful as a transport stage even with a generic sanitized detail.
+- native app-managed bootstrap must stay app-managed: these transport events must not reintroduce a requirement that the model itself calls `member_briefing` before the app can load bootstrap context;
+- `nativeBootstrapInjection.markContextLoaded()` should remain tied to accepted prompt submission, not to later model proof;
+- if `nativeBootstrapInjection` exists, emit `bootstrap_context_loaded` after `markContextLoaded()` succeeds so timeout diagnostics can distinguish "prompt never submitted" from "context loaded, waiting for durable proof".
+- do not copy `bootstrapProofToken` into every submit-stage event. Existing native app-managed context/proof events may carry proof metadata, but submit-stage transport events should stay low-sensitivity.
+
+Dedup/throttle:
+
+- `bootstrap_prompt_observed`: once per `bootstrapMirrorId`;
+- `bootstrap_submit_attempted`: once per actual submit call;
+- `bootstrap_submit_deferred`: throttle by reason;
+- no event per poll tick.
+
+Ref lifecycle:
+
+- keep dedup state in session-local refs/maps, not persisted storage;
+- delete dedup entries when the bootstrap inbox row becomes `read`, is removed, or enters terminal failure;
+- cap defensive dedup maps by recent count, for example last `100` mirror ids, so a long-lived teammate session cannot grow unbounded;
+- `bootstrap_submit_deferred.detail` should be a small enum such as `member_briefing_not_callable` or `submit_cooldown`, not raw prompt/error text.
+
+## Phase 6 - Integrate outcome waiter in process backend spawn path
+
+Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+File: `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/tools/shared/spawnMultiAgent.ts`
+
+Scope: process backend branch(es) only, as identified in Phase 0.
+
+Do not hand-copy the same wait/kill/diagnostic block across branches. Put the outcome wait, terminal classification, and sanitized throw into one helper, then call it only where `backendType === 'process'`.
+
+Shared helper shape:
+
+```ts
+async function waitForProcessBackendBootstrapSubmission(input: {
+ identity: ProcessBootstrapAttemptIdentity
+ child: ChildProcess
+ eventsPath: string
+ timeoutMs: number
+ cleanupOnTerminalFailure: (diagnostic: ProcessBootstrapTransportDiagnostic) => Promise
+}): Promise {
+ // bounded event reads
+ // current-attempt matching only
+ // retryable rejection stays pending
+ // terminal current-attempt failure calls identity-guarded cleanup
+}
+```
+
+Branch integration rules:
+
+- process backend branches pass identity into the helper;
+- tmux/iterm/in-process branches do not call it;
+- OpenCode bridge lanes do not call it;
+- helper returns typed outcome, not arbitrary error strings;
+- helper never writes launch-state directly. It reports outcome to the existing runner/state flow.
+
+Write parent-owned events:
+
+```ts
+await writeTeammateRuntimeEvent({
+ type: 'process_spawned',
+ eventsPath: runtimePaths.eventsPath,
+ pid: runtimePid,
+ teamName,
+ agentName: sanitizedName,
+ agentId: teammateId,
+ runId: getSessionId(),
+ bootstrapRunId,
+ source: 'spawnMultiAgent.process',
+})
+```
+
+Parent-owned event rule:
+
+- never call `writeTeammateRuntimeEvent(...)` from `spawnMultiAgent` without explicit child `pid`;
+- the existing helper default of `pid = process.pid` is acceptable only for child-owned events emitted inside the teammate runtime process;
+- if a parent-owned event cannot identify the child pid, skip the event or write a diagnostic-only event that cannot participate in current-attempt matching;
+- tests should fail if `process_spawned`, `mailbox_bootstrap_written`, or parent-owned terminal `failed` events contain the orchestrator parent pid instead of the child runtime pid.
+
+After `writeToMailbox(...)`:
+
+```ts
+await writeTeammateRuntimeEvent({
+ type: 'mailbox_bootstrap_written',
+ eventsPath: runtimePaths.eventsPath,
+ pid: runtimePid,
+ teamName,
+ agentName: sanitizedName,
+ agentId: teammateId,
+ runId: getSessionId(),
+ bootstrapRunId,
+ source: 'spawnMultiAgent.process',
+})
+```
+
+Important current contract:
+
+- `writeToMailbox(...)` returns `Promise`;
+- parent process cannot rely on it to return a persisted inbox message id;
+- therefore parent-owned `mailbox_bootstrap_written` should not include `bootstrapMirrorId` unless implementation explicitly changes mailbox API in a separately tested way;
+- `bootstrapMirrorId` should come from `useInboxPoller`, where the bootstrap row is actually observed as `bootstrapPendingMessage.messageId`.
+
+Replace wait for only `bootstrap_submitted`:
+
+```ts
+const outcome = await waitForBootstrapSubmissionOutcome({
+ eventsPath: runtimePaths.eventsPath,
+ pid: runtimePid,
+ teamName,
+ agentName: sanitizedName,
+ agentId: teammateId,
+ bootstrapRunId,
+ sinceMs: Date.parse(launchBoundaryIso),
+ timeoutMs: 15_000,
+ isStillAlive: () => isProcessAlive(runtimePid),
+})
+
+if (outcome.type !== 'submitted') {
+ const reason = sanitizeRuntimeDiagnosticText(formatBootstrapSubmissionFailure(outcome))
+ await terminateLaunchOwnedProcessBackendRuntime({
+ runtimePid,
+ processPaneId,
+ teammateId,
+ teamName,
+ phase: 'after-registration',
+ })
+ await writeTeammateRuntimeEvent({
+ type: 'failed',
+ eventsPath: runtimePaths.eventsPath,
+ pid: runtimePid,
+ teamName,
+ agentName: sanitizedName,
+ agentId: teammateId,
+ runId: getSessionId(),
+ bootstrapRunId,
+ source: 'spawnMultiAgent.process',
+ detail: reason,
+ })
+ throw new Error(`Teammate process ${teammateId} did not submit bootstrap prompt: ${reason}`)
+}
+```
+
+Do not fail immediately on retryable `bootstrap_submit_rejected`.
+
+## Phase 7 - Fix headless teammate stdin peek without closing stdin
+
+Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+File: `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/main.tsx`
+
+Use narrow text-mode skip:
+
+```ts
+function shouldSkipInitialStdinPeekForHeadlessTeammate(): boolean {
+ return process.env.CLAUDE_CODE_TEAMMATE_RUNTIME === 'headless'
+}
+
+if (!process.stdin.isTTY) {
+ if (inputFormat === 'text' && shouldSkipInitialStdinPeekForHeadlessTeammate()) {
+ return prompt
+ }
+
+ if (inputFormat === 'stream-json') {
+ return process.stdin
+ }
+
+ // existing stdin peek remains for normal CLI use
+}
+```
+
+Keep spawn stdio as `['pipe', 'pipe', 'pipe']`.
+
+Important:
+
+- keep `stream-json` exactly as-is;
+- keep normal CLI text mode stdin behavior exactly as-is;
+- this only removes the misleading startup probe warning. It is not bootstrap proof and must not write `bootstrap_submitted`;
+- returning `prompt` here means “use the already parsed CLI/headless prompt string”, not “the teammate accepted the app-managed bootstrap message”;
+- if the parsed prompt is empty, keep existing runtime behavior but rely on later transport events/timeout diagnostics. Do not synthesize success or submission from an empty prompt;
+- do not key this on `CLAUDE_CODE_ENTRYPOINT`, because headless teammate mode is already normalized from `--teammate-runtime headless` into `CLAUDE_CODE_TEAMMATE_RUNTIME=headless`;
+- the skip is valid only after the existing early CLI parsing has had a chance to set `CLAUDE_CODE_TEAMMATE_RUNTIME`;
+- do not close stdin;
+- do not change child process stdio from `pipe`;
+- do not call `process.stdin.resume()` or attach permanent stdin listeners in this skip path;
+- do not hide real submit failures behind “stdin probe skipped”. Missing `bootstrap_submitted` is still diagnosed by runtime events and final timeout;
+- add/keep fake runtime test that fails if stdin is closed and passes when no initial peek warning appears.
+
+Extra test cases:
+
+- headless text teammate with no stdin data does not print the 3 second warning;
+- same process still waits for later app-managed bootstrap submission proof;
+- headless text teammate with empty parsed prompt does not become submitted/confirmed;
+- normal non-headless text CLI still prints the existing warning when appropriate;
+- stream-json still returns `process.stdin` and is not affected by this shortcut.
+
+## Phase 8 - Improve deterministic runner timeout diagnostics
+
+Repo: `/Users/belief/dev/projects/claude/agent_teams_orchestrator`
+
+File: `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapRunner.ts`
+
+Do not make the existing polling failure reader return non-terminal transport stages. Add two helpers or one helper with explicit mode:
+
+```ts
+async function readTerminalBootstrapRuntimeFailure(params: {
+ teamName: string
+ memberName: string
+ backendType?: string
+ runtimePid?: number
+ bootstrapExpectedAfter?: string | null
+}): Promise {
+ // use readRecentTeammateRuntimeEvents
+ // return only terminal failed/exited/accepted-without-uuid/non-retryable rejection
+ // never return bootstrap_submitted or retryable bootstrap_submit_rejected
+}
+
+async function readBootstrapRuntimeTimeoutDiagnostic(params: {
+ teamName: string
+ memberName: string
+ backendType?: string
+ runtimePid?: number
+ bootstrapExpectedAfter?: string | null
+}): Promise<{
+ failure: string | null
+ lastStage: string | null
+ lastStageDetail: string | null
+}> {
+ // use readRecentTeammateRuntimeEvents
+ // exact failed detail wins
+ // otherwise return last relevant transport stage after boundary for timeout text only
+}
+```
+
+Fix the existing process scope predicate while extracting:
+
+```ts
+function shouldReadProcessRuntimeTransportEvents(params: {
+ backendType?: string
+ runtimePid?: number
+}): boolean {
+ return params.backendType === 'process' &&
+ typeof params.runtimePid === 'number' &&
+ Number.isFinite(params.runtimePid) &&
+ params.runtimePid > 0
+}
+```
+
+The runner must fail closed here. Do not preserve a condition that reads runtime events for a non-process backend merely because a `runtimePid` field exists. Transport diagnostics are process-backend only in this phase.
+
+Polling loop:
+
+```ts
+const runtimeFailure = await readTerminalBootstrapRuntimeFailure(...)
+if (runtimeFailure) {
+ // mark failed immediately
+}
+```
+
+Terminal event semantics while waiting for confirmation:
+
+- `failed` fails immediately;
+- `exited` fails immediately only for the same process backend runtime correlation;
+- `bootstrap_submit_accepted_without_uuid` fails immediately because no transcript user message id exists to observe later proof;
+- `bootstrap_submit_rejected` fails immediately only with `retryable === false`;
+- `bootstrap_submitted`, `bootstrap_context_loaded`, `runtime_ready`, and `inbox_poller_ready` are diagnostic stages only and must never be treated as success or failure by this polling helper.
+
+Timeout reason:
+
+```ts
+const transport = await readBootstrapRuntimeTimeoutDiagnostic(...)
+const reason = transport.failure
+ ?? (transport.lastStage
+ ? `Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: ${transport.lastStage}.`
+ : 'Teammate was registered but did not bootstrap-confirm before timeout.')
+```
+
+No success semantics change.
+
+## Phase 9 - Extract safe desktop runtime event reader and add process transport evidence
+
+Repo: `/Users/belief/dev/projects/claude/claude_team`
+
+Current code already reads runtime event JSONL for bootstrap proof inside `TeamProvisioningService`. Refactor that bounded tail reader/path resolver into a reusable helper first, then add process transport summarization on top. Do not create a second unrelated JSONL reader.
+
+Bootstrap-state limitation:
+
+- `TeamBootstrapStateReader` maps `bootstrap-state.json` into a launch snapshot, but raw bootstrap member state does not carry `backendType`, `runtimePid`, or `bootstrapRuntimeEventsPath`;
+- therefore process transport enrichment must not depend on bootstrap-state alone for runtime event path/pid;
+- use current live run state, persisted launch-state member metadata, or team config/runtime metadata for runtime path/pid;
+- bootstrap-state overlay remains proof/failure truth, not process transport metadata source.
+
+Shared helper:
+
+```text
+/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/TeamRuntimeEventEvidenceReader.ts
+```
+
+Responsibilities:
+
+- resolve canonical runtime event path;
+- match orchestrator filename sanitizer, including Windows reserved basename handling;
+- reject persisted paths outside the expected team runtime directory using `isPathWithinRoot`;
+- validate realpaths for existing files to avoid symlink escapes;
+- bounded tail-read runtime event JSONL;
+- ignore partial lines;
+- parse known event-like objects;
+
+Transport summarizer:
+
+```text
+/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/ProcessBootstrapTransportEvidence.ts
+```
+
+Responsibilities:
+
+- call `TeamRuntimeEventEvidenceReader`;
+- filter by team/member/pid/time/bootstrapRunId/proof token;
+- summarize strongest transport stage;
+- return diagnostics, not readiness proof.
+
+Types:
+
+```ts
+export type ProcessBootstrapTransportStage =
+ | 'none'
+ | 'process_spawned'
+ | 'runtime_ready'
+ | 'mailbox_bootstrap_written'
+ | 'inbox_poller_ready'
+ | 'bootstrap_prompt_observed'
+ | 'bootstrap_submit_attempted'
+ | 'bootstrap_submit_deferred'
+ | 'bootstrap_submit_rejected'
+ | 'bootstrap_submit_accepted_without_uuid'
+ | 'bootstrap_submitted'
+ | 'failed'
+ | 'exited'
+
+export interface ProcessBootstrapTransportEvidence {
+ stage: ProcessBootstrapTransportStage
+ observedAt?: string
+ diagnostic?: string
+ severity?: 'info' | 'warning' | 'error'
+ hasProcessEvidence: boolean
+ hasSubmitEvidence: boolean
+ terminalTransportFailure: boolean
+ retryableSubmitRejection: boolean
+}
+```
+
+Separate validator:
+
+```ts
+export function validateProcessBootstrapTransportEvent(
+ event: unknown,
+ expected: {
+ teamName: string
+ memberName: string
+ bootstrapRunId?: string
+ pid?: number
+ bootstrapProofToken?: string
+ notBeforeMs?: number
+ },
+): { ok: true; event: ProcessBootstrapTransportRuntimeEvent } | { ok: false; reason: string } {
+ // known transport event types only
+ // reject explicit team/member/bootstrapRun/token/pid mismatches
+ // do not require contextHash/briefingHash
+ // do not return bootstrap_confirmed proof
+}
+```
+
+Do not reuse `BootstrapProofValidation`.
+
+Do not weaken existing proof overlay:
+
+- `findBootstrapRuntimeProofObservedAt(...)` must continue using strict `validateBootstrapRuntimeProofEnvelope(...)`;
+- shared reader/path resolver can be reused, but process transport validator must remain separate;
+- transport events can explain pending/failure, but cannot clear a failed state as proof-confirmed.
+
+Module split:
+
+- `TeamRuntimeEventEvidenceReader.ts`: path containment, bounded tail read, corrupt-line skip, field normalization.
+- `ProcessBootstrapAttemptIdentity.ts`: identity tuple, matching level, legacy fallback rules.
+- `ProcessBootstrapTransportEvidence.ts`: event-to-stage summarization and timeout diagnostic selection.
+- `ProcessBootstrapTransportDiagnostic.ts`: typed failure kind to safe diagnostic summary mapping.
+- `ProcessBootstrapLaunchStateMerge.ts`: pure merge precedence for launch member status.
+- `ProcessBootstrapProjectionCoordinator.ts` or equivalent small service method: orchestrates read evidence -> merge -> persist with current-run recheck. This can live inside `TeamProvisioningService` if a separate module would add indirection without reuse.
+
+Keep these modules small and dependency-light. None of them should import renderer code, IPC handlers, React, Zustand, or provider-specific adapters. The only orchestration-heavy code should remain in `TeamProvisioningService`.
+
+Architecture guardrails:
+
+- event reading has one reason to change: runtime event file format and safe IO;
+- identity matching has one reason to change: attempt/member correlation rules;
+- transport classification has one reason to change: process bootstrap state machine;
+- diagnostic mapping has one reason to change: user-safe wording/redaction;
+- launch-state merge has one reason to change: precedence between proof, provider failure, transport, and liveness;
+- `TeamProvisioningService` should orchestrate these helpers, not embed all transition logic inline;
+- do not make helpers import each other in a cycle. The dependency direction should be reader -> normalized events -> identity/classifier -> diagnostic -> merge;
+- adding a new transport event type should usually extend classifier tables/tests, not require editing renderer code or IPC contracts.
+
+Existing proof reader refactor:
+
+`TeamProvisioningService` already has `readRuntimeBootstrapProofEvents(...)` and `findBootstrapRuntimeProofObservedAt(...)`. Do not duplicate a second unsafe tail reader next to it.
+
+Preferred implementation:
+
+- extract the bounded file read and JSONL parsing mechanics into `TeamRuntimeEventEvidenceReader.ts`;
+- keep proof validation in the existing strict proof path with `validateBootstrapRuntimeProofEnvelope(...)`;
+- add process transport validation as a separate validator using the same parsed event list;
+- add tests proving app-managed/native proof confirmation still works after extraction.
+
+The shared reader may parse events. It must not decide whether an event is proof or transport success.
+
+## Phase 10 - Enrich launch status before persisted snapshot projection
+
+Repo: `/Users/belief/dev/projects/claude/claude_team`
+
+File: `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts`
+
+Integration point:
+
+```text
+persistLaunchStateSnapshotNow(run)
+ -> overlayPrimaryBootstrapTruthIntoRunStatusesFromBootstrapState(run)
+ -> enrichProcessBootstrapTransportEvidenceForProjection(run)
+ -> buildLiveLaunchSnapshotForRun(run, launchPhase)
+ -> TeamLaunchStateStore.write(snapshot)
+ -> writes launch-state.json and launch-summary.json together
+```
+
+Scope:
+
+- process/native backends only;
+- skip OpenCode bridge lanes;
+- only starting/waiting/runtime_pending_bootstrap/failed_to_start/stale states;
+- do not change `TeamRuntimeLivenessResolver` liveness kinds or `isStrongRuntimeEvidence(...)`; process transport stages are launch diagnostics, not runtime liveness;
+- bounded tail read;
+- path must be canonical team runtime path or a persisted path that resolves under the same team runtime directory;
+- no reads inside pure evaluator.
+
+Runtime metadata source precedence:
+
+1. Live provisioning run metadata for the currently tracked run.
+2. Current persisted launch-state member metadata only when `runId` matches the selected/current launch window.
+3. Team config bootstrap metadata only when it has current `bootstrapRunId` or matches the selected launch boundary.
+4. Process table liveness only as diagnostic. It cannot prove submit or bootstrap confirmation.
+
+Reject enrichment when:
+
+- metadata has no current launch/restart boundary;
+- `bootstrapRunId` explicitly mismatches the selected launch;
+- event `teamName`, `agentName`, or `agentId` mismatches the selected member;
+- event pid mismatches the selected runtime pid, unless the event is a legacy child-written event without pid and the path is already current and contained;
+- runtime events path resolves outside the current team's runtime directory;
+- evidence is older than the launch/restart boundary and has no current `bootstrapRunId`;
+- member was removed or skipped for launch.
+
+Restart/reattach merge rules:
+
+- a new restart attempt may move an old terminal failure to pending only after a new runtime attempt is materialized;
+- pending evidence from an old runtime must not clear a terminal failure from the current attempt;
+- terminal failure from an old runtime must not overwrite pending/confirmed state for a newer attempt;
+- if old and new evidence conflict, prefer evidence whose `bootstrapRunId` matches the current attempt;
+- if no `bootstrapRunId` exists, prefer evidence from the current runtime pid/path and selected launch boundary;
+- if still ambiguous, do not enrich and leave existing state unchanged.
+
+Path guard example:
+
+```ts
+function resolveSafeRuntimeEventsPath(input: {
+ teamName: string
+ memberName: string
+ persistedPath?: string | null
+}): string {
+ const expected = getCanonicalTeamRuntimeEventsPath(input.teamName, input.memberName)
+ const runtimeDir = path.dirname(expected)
+ const candidate = input.persistedPath?.trim() ? path.resolve(input.persistedPath) : expected
+ return isPathWithinRoot(candidate, runtimeDir) ? candidate : expected
+}
+```
+
+Existing-file symlink hardening:
+
+```ts
+function resolveReadableRuntimeEventsPath(candidate: string, runtimeDir: string): string | null {
+ const resolved = path.resolve(candidate)
+ if (!isPathWithinRoot(resolved, runtimeDir)) return null
+
+ const realCandidate = realpathIfExists(resolved)
+ const realRuntimeDir = realpathIfExists(runtimeDir)
+ if (realCandidate && realRuntimeDir && !isPathWithinRoot(realCandidate, realRuntimeDir)) {
+ return null
+ }
+ return resolved
+}
+```
+
+Canonical filename must match orchestrator:
+
+```ts
+function sanitizeRuntimeEventFilePrefix(name: string): string {
+ const basic = String(name ?? '').replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
+ return isWindowsReservedBasename(basic) ? `_${basic}` : basic
+}
+```
+
+Never read arbitrary absolute paths from `team.json`.
+
+Do not add a desktop fallback like `name || 'default'`. If implementation cannot produce a valid expected runtime agent name, it should skip fallback path enrichment instead of inventing a filename that orchestrator never wrote.
+
+Mapping:
+
+```ts
+function toRuntimePendingBootstrapLaunchState(status) {
+ return status.launchState === 'starting' || !status.launchState
+ ? 'runtime_pending_bootstrap'
+ : status.launchState
+}
+
+function applyProcessTransportEvidenceToStatus(status, evidence) {
+ if (status.bootstrapConfirmed === true || status.launchState === 'confirmed_alive') return status
+ if (hasProviderOrRuntimeHardFailure(status)) return mergeDiagnosticOnly(status, evidence)
+
+ if (evidence.terminalTransportFailure) {
+ return {
+ ...status,
+ status: 'error',
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ hardFailureReason: evidence.diagnostic ?? status.hardFailureReason,
+ runtimeDiagnostic: evidence.diagnostic ?? status.runtimeDiagnostic,
+ runtimeDiagnosticSeverity: 'error',
+ }
+ }
+
+ if (evidence.hasSubmitEvidence) {
+ return {
+ ...status,
+ launchState: toRuntimePendingBootstrapLaunchState(status),
+ runtimeDiagnostic:
+ status.runtimeDiagnostic ?? 'Bootstrap prompt was submitted; waiting for bootstrap confirmation.',
+ runtimeDiagnosticSeverity: status.runtimeDiagnosticSeverity ?? 'info',
+ }
+ }
+
+ if (evidence.hasProcessEvidence) {
+ return {
+ ...status,
+ agentToolAccepted:
+ status.agentToolAccepted === true || evidence.hasParentSpawnAcceptedEvidence === true,
+ launchState: toRuntimePendingBootstrapLaunchState(status),
+ runtimeDiagnostic:
+ status.runtimeDiagnostic ?? 'Process backend started but bootstrap prompt has not been submitted yet.',
+ runtimeDiagnosticSeverity: status.runtimeDiagnosticSeverity ?? 'warning',
+ }
+ }
+
+ return status
+}
+```
+
+Retryable rejection during active launch remains pending/warning. It becomes failed only after parent emits `failed` or final timeout snapshot records failure.
+
+Typed merge input:
+
+```ts
+export interface ProcessBootstrapTransportMergeInput {
+ status: MemberSpawnStatusEntry
+ evidence: ProcessBootstrapTransportEvidence
+ diagnostic: ProcessBootstrapTransportDiagnostic
+ attemptMatch: 'strict-current' | 'legacy-current' | 'diagnostic-only' | 'no-match'
+ launchPhase: PersistedTeamLaunchPhase
+ projectionPhase: 'active' | 'final'
+}
+```
+
+Merge helper rules:
+
+- `no-match` returns status unchanged;
+- `diagnostic-only` can append internal log/debug diagnostics but cannot change launch state;
+- `legacy-current` can only produce pending diagnostics, not terminal failure, unless the final timeout path also confirms the current launch boundary;
+- `strict-current` is required for immediate terminal transport failure;
+- `projectionPhase: 'active'` cannot convert retryable rejection or timeout-only pending stages into `failed_to_start`;
+- `projectionPhase: 'final'` can convert timeout kinds into `failed_to_start` if no higher-priority provider/root failure exists.
+
+Projection coordinator shape:
+
+```ts
+async function writeLaunchStateSnapshotNow(teamName, snapshot, options) {
+ const previousSnapshot = await this.launchStateStore.read(teamName).catch(() => null)
+ const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => [])
+
+ const openCodeOverlaid = await applyOpenCodeSecondaryEvidenceOverlay({
+ teamName,
+ snapshot,
+ previousSnapshot,
+ metaMembers,
+ })
+
+ const processOverlaid = await applyProcessTransportEvidenceOverlay({
+ teamName,
+ snapshot: openCodeOverlaid,
+ previousSnapshot,
+ metaMembers,
+ runIdentity: options?.runIdentity,
+ })
+
+ if (!isStillCurrentBeforeLaunchStateWrite(teamName, options?.runIdentity)) {
+ return { snapshot: previousSnapshot ?? processOverlaid, wrote: false, stale: true }
+ }
+
+ const normalizedSnapshot =
+ applyOpenCodeSecondaryBootstrapStallOverlay(processOverlaid) ?? processOverlaid
+
+ if (await canSkipLaunchStateWriteAndSummaryIsFresh(previousSnapshot, normalizedSnapshot, options)) {
+ return { snapshot: previousSnapshot, wrote: false }
+ }
+
+ if (normalizedSnapshot.teamLaunchState === 'clean_success' && normalizedSnapshot.launchPhase !== 'active') {
+ if (!isStillCurrentBeforeLaunchStateClear(teamName, options?.runIdentity)) {
+ return { snapshot: previousSnapshot ?? normalizedSnapshot, wrote: false, stale: true }
+ }
+ await clearPersistedLaunchStateNow(teamName)
+ return { snapshot: null, wrote: true, cleared: true }
+ }
+
+ await this.launchStateStore.write(teamName, normalizedSnapshot)
+ return { snapshot: normalizedSnapshot, wrote: true }
+}
+```
+
+Rules:
+
+- the existing per-team `enqueueLaunchStateStoreOperation(...)` remains the serialization boundary. Do not create a parallel writer or a second read/compare/write queue;
+- `previousSnapshot` is read once inside the queued operation and passed into overlays. Do not let each overlay call `launchStateStore.read(...)` independently;
+- `metaMembers` is read once inside the queued operation and passed into overlays. Do not let OpenCode and process overlays race on separate member metadata reads;
+- `runIdentity` / attempt identity is immutable for the projection pass;
+- evidence read failures degrade to no enrichment, not launch crash;
+- evidence read budget exhaustion degrades remaining members to no enrichment and records internal debug/log detail only;
+- `isStillCurrentRun(...)` checks team name, run id, cancellation/killed state, and any restart generation if available;
+- `TeamLaunchStateStore.write(...)` remains the only persistence point for detailed and compact launch projections;
+- never mutate `run.memberStatuses` while iterating over event files. Build a merged copy and persist it atomically through the store;
+- integrate this inside the existing `persistLaunchStateSnapshot(...)` / `enqueueLaunchStateStoreOperation(...)` flow;
+- do not add a second launch-state writer for transport enrichment;
+- if `TeamLaunchStateStore.write(...)` writes detail but summary write later fails, accept detailed state as truth and let existing stale-summary logic/list refresh recover.
+- account for existing `writeLaunchStateSnapshotNow(...)` overlays: OpenCode secondary overlays and bootstrap-stall overlays already run before normalized snapshot write. Process transport enrichment should compose with them without changing OpenCode behavior.
+- preferred order inside `writeLaunchStateSnapshotNow(...)`: previous snapshot read -> existing OpenCode overlays -> process transport enrichment for non-OpenCode process members -> bootstrap-stall/normalization -> no-op/summary repair check -> store write.
+- if this order conflicts with existing proof overlay in `persistLaunchStateSnapshotNow(...)`, preserve proof overlay as higher priority and document the exact order in code comments.
+- do not evaluate clean-success clear before process transport enrichment and final current-run fence. A pre-enrichment clean-success check can erase evidence that would have kept a partial launch visible.
+
+Queue and IO budget:
+
+- bounded runtime event tail reads may happen inside the queued operation only if the total budget is small and deterministic. This keeps previous-snapshot comparison consistent;
+- do not perform broad process table scans, project transcript scans, or network/provider checks inside the queued operation;
+- if runtime event IO budget would be exceeded, stop reading more members and return no enrichment for the rest. Do not hold the queue for best-effort diagnostics;
+- do not recursively call `persistLaunchStateSnapshot(...)` or `writeLaunchStateSnapshot(...)` from inside an overlay;
+- overlay helpers return new snapshot objects. They do not write files, emit IPC, notify lead/user, or mutate live run maps.
+
+No-op skip and summary repair:
+
+- transport enrichment must be included before `areLaunchStateSnapshotsSemanticallyEqual(...)`;
+- no-op skip is allowed only after checking whether `launch-summary.json` is present/fresh enough for the normalized detailed snapshot;
+- if summary is missing/stale and detailed state is semantically unchanged, force `TeamLaunchStateStore.write(...)` to repair both files;
+- the summary repair check must be bounded and must run inside the same serialized operation as the detailed-state comparison;
+- do not direct-write `launch-summary.json`;
+- do not update `launchStateWrittenRunIdByTeam` before the detailed + summary write path has succeeded or a verified no-op skip has occurred.
+- clean-success clear is a write operation and must happen inside the same serialized queue with the same final run-identity fence as normal writes;
+- because clear also removes bootstrap-state, it needs a stricter current-run check than ordinary diagnostic enrichment.
+
+Normalizer interaction hazard:
+
+`TeamLaunchStateEvaluator` currently synthesizes missing expected members as `starting`, and when `launchPhase !== 'active'` it converts untouched `starting` members into:
+
+```text
+Teammate was never spawned during launch.
+```
+
+That behavior is correct for truly missing members, but it becomes wrong if process transport evidence exists and enrichment runs too late.
+
+Plan rule:
+
+- process transport enrichment must run before the final normalized snapshot is passed through summary projection;
+- if current transport evidence proves `process_spawned`, `mailbox_bootstrap_written`, `bootstrap_prompt_observed`, or `bootstrap_submitted`, the member must not still satisfy the normalizer predicate `launchState === 'starting' && !agentToolAccepted && !runtimeAlive && !hardFailure`;
+- pending transport evidence should set at least `launchState: 'runtime_pending_bootstrap'` and a stable `runtimeDiagnostic`;
+- do not redefine `agentToolAccepted` as "bootstrap submitted". Existing code treats it as spawn/agent-tool acceptance and uses it in diagnostics as `spawn accepted`;
+- parent-owned `process_spawned` / `mailbox_bootstrap_written` for the current attempt may set or preserve `agentToolAccepted: true`, because the parent launch path owns that spawn fact;
+- child-owned `bootstrap_prompt_observed` / `bootstrap_submitted` without parent spawn evidence should move `launchState` out of `starting`, but should not fake `agentToolAccepted`;
+- durable submit evidence is represented by pending launch state and stable `runtimeDiagnostic`, not by changing the meaning of `agentToolAccepted`;
+- process transport events should not set `runtimeAlive`. Current liveness comes only from `TeamRuntimeLivenessResolver` / process table checks, because an old `process_spawned` event can outlive the process;
+- tests must cover a terminal launchPhase with `bootstrap_submitted` evidence and assert the member is pending/unconfirmed, not `never spawned`.
+
+Projection merge order:
+
+1. preserve removed/skipped/permission-pending states;
+2. preserve strict confirmed bootstrap;
+3. preserve provider/auth/quota/runtime hard failures;
+4. preserve same-attempt terminal transport failure against weaker same-attempt pending evidence;
+5. apply terminal process transport failure when current-attempt strict match and launch phase allows it;
+6. apply submit/process evidence as pending diagnostics only;
+7. let liveness resolver contribute stale pid/process-table diagnostics without turning those diagnostics into root cause unless no better root cause exists.
+
+Provider/root-cause preservation:
+
+- if `hardFailureReason` already exists, terminal transport failure may append a bounded diagnostic but must not replace the root cause;
+- if only generic `error` exists on a terminal failed member, normalize/bound it and preserve it unless the new terminal transport failure is more specific for the same current run;
+- if a member is pending and has an arbitrary `error` string without `hardFailure === true` or `launchState === 'failed_to_start'`, do not treat that as terminal root cause.
+
+Failure precedence matrix:
+
+| Existing/current evidence | Incoming process transport evidence | Result |
+|---|---|---|
+| `bootstrapConfirmed === true` or `confirmed_alive` | any transport event | Preserve confirmed. Append diagnostic only if useful and non-error. |
+| provider/auth/quota/model hard failure | submit/process evidence | Preserve provider failure. Transport can be secondary diagnostic. |
+| provider/auth/quota/model hard failure | terminal transport failure | Preserve provider failure as root cause. Append transport diagnostic only if same current attempt. |
+| pending with no terminal root cause | submit evidence | Keep pending, set safe info diagnostic. |
+| pending with no terminal root cause | process evidence only | Keep pending, set safe warning diagnostic. |
+| pending with no terminal root cause | terminal current transport failure | Mark `failed_to_start`. |
+| current terminal transport failure | later pending event from same attempt | Preserve terminal failure. Append diagnostic only if useful. |
+| terminal old attempt failure | pending current attempt evidence | Move to pending only if current attempt identity is distinct and materialized. |
+| current terminal failure | stale old submit/process event | Ignore stale event. |
+| removed/skipped member | any transport event | Ignore transport event. |
+| permission-pending member | generic timeout/process exit | Preserve `runtime_pending_permission` unless permission result itself fails. |
+
+This matrix should be implemented as a small pure merge helper and unit-tested directly. Do not spread this precedence logic across multiple ad hoc `if` blocks.
+
+Summary counter expectations:
+
+- `confirmedCount` changes only when `launchState === 'confirmed_alive'`;
+- process transport submit/process evidence must keep the member in pending counts, not confirmed counts;
+- if liveness kind is `runtime_process` but bootstrap is unconfirmed, summary should count it as pending with `runtimeProcessPendingCount`, not success;
+- terminal process transport failure should move the member to `failedCount` and `missingMembers`.
+
+## Phase 11 - Preserve specific failure while applying cleanup liveness
+
+Repo: `/Users/belief/dev/projects/claude/claude_team`
+
+File: `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts`
+
+Avoid:
+
+```ts
+if (preserveExistingFailure && hasExistingFailure) {
+ return prev
+}
+```
+
+Correct merge:
+
+```ts
+function mergeCleanupIntoFailedStatus(prev, cleanup) {
+ const rootCause = selectRootFailure(prev)
+ return {
+ ...prev,
+ ...cleanup.livenessFields,
+ status: 'error',
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ hardFailureReason: rootCause.hardFailureReason ?? cleanup.hardFailureReason,
+ error: rootCause.error ?? cleanup.error,
+ runtimeDiagnostic: cleanup.runtimeDiagnostic ?? prev.runtimeDiagnostic,
+ diagnostics: mergeBoundedDiagnostics(prev.diagnostics, cleanup.diagnostics),
+ }
+}
+```
+
+Strict terminal predicate:
+
+```ts
+function hasTerminalFailure(status): boolean {
+ return (
+ status.status === 'error' ||
+ status.launchState === 'failed_to_start' ||
+ status.hardFailure === true ||
+ Boolean(status.hardFailureReason)
+ )
+}
+```
+
+Do not treat arbitrary `status.error` on waiting/pending member as terminal unless paired with terminal state or hard failure.
+
+## Phase 11.5 - Deterministic process backend harness expansion
+
+This phase is required before trusting production changes. The existing `teamBootstrapProcessBackend.e2e.test.ts` fake runtime already models crash, pipe flood, stdin-sensitive, and hold-child behavior. Extend that harness to model the new transport events without live providers.
+
+Fake runtime mode contract:
+
+```ts
+type FakeTeammateMode =
+ | 'success'
+ | 'exit-before-ready'
+ | 'stdin-sensitive'
+ | 'hold-child'
+ | 'submit-rejected-then-accepted'
+ | 'submit-rejected-terminal'
+ | 'accepted-without-uuid'
+ | 'no-submit'
+ | 'observed-no-submit'
+ | 'inbox-ready-no-submit'
+ | 'submit-then-exit'
+ | 'failed-after-runtime-ready'
+ | 'partial-corrupt-event'
+ | 'stderr-secret'
+ | 'restart-stale-event'
+ | 'wrong-member-event'
+```
+
+Example fake event helper:
+
+```ts
+async function writeFakeRuntimeEvent(type: string, extra: Record = {}) {
+ if (!eventsPath) return
+ await mkdir(dirname(eventsPath), { recursive: true })
+ await appendFile(
+ eventsPath,
+ JSON.stringify({
+ version: 1,
+ type,
+ timestamp: new Date().toISOString(),
+ pid: process.pid,
+ teamName,
+ agentName,
+ agentId,
+ bootstrapRunId: process.env.FAKE_BOOTSTRAP_RUN_ID,
+ ...extra,
+ }) + '\n',
+ )
+}
+```
+
+Required mode behavior:
+
+- `submit-rejected-then-accepted`: writes `bootstrap_prompt_observed`, `bootstrap_submit_attempted`, retryable `bootstrap_submit_rejected`, then `bootstrap_submitted`;
+- `submit-rejected-terminal`: writes `bootstrap_prompt_observed`, `bootstrap_submit_attempted`, `bootstrap_submit_rejected` with `retryable: false`;
+- `accepted-without-uuid`: writes `bootstrap_submit_accepted_without_uuid` and never writes `bootstrap_submitted`;
+- `no-submit`: writes only `cli_started`/`runtime_ready` and never observes prompt;
+- `observed-no-submit`: writes `bootstrap_prompt_observed` but never attempt/submit;
+- `inbox-ready-no-submit`: writes `inbox_poller_ready` but never attempt/submit;
+- `submit-then-exit`: writes `bootstrap_submitted`, then exits non-zero before confirmation;
+- `failed-after-runtime-ready`: writes `runtime_ready`, then terminal `failed`;
+- `partial-corrupt-event`: writes invalid JSON, a truncated line, then a valid event;
+- `stderr-secret`: writes stderr/detail containing token-shaped text and absolute paths, asserting UI diagnostics redact/bound them.
+- `restart-stale-event`: writes a valid event for an old `bootstrapRunId`/pid, then starts a new attempt; current projection must ignore old evidence;
+- `wrong-member-event`: writes a valid-looking event with different `agentName` or `agentId`; current member must ignore it.
+
+Harness assertions:
+
+- no fake mode should depend on live Claude/Codex/OpenCode, API keys, OAuth, tmux, or network;
+- fake runtime must keep stdio shape close to production process backend;
+- parent-owned events must include the fake child pid, not the parent pid;
+- event write failure must not crash spawn by itself;
+- corrupt event lines must be skipped without losing later valid events;
+- retryable rejection must not fail immediately when a later submit exists;
+- terminal rejection must fail without waiting for generic bootstrap timeout;
+- submit evidence must prevent `never spawned` fallback but must not create `confirmed_alive`;
+- no fake mode may include proof token, hashes, absolute runtime paths, full command, or full event JSON in user-visible diagnostics.
+- restart/stale-event mode must prove old events cannot flip the current attempt from pending to failed or submitted.
+
+This keeps deterministic e2e at the process transport boundary. Live mixed smoke remains useful after implementation, but it must not be the first proof that branch logic works.
+
+## Phase 12 - Renderer expectations
+
+No new renderer API required.
+
+Expected outcomes:
+
+- transport failure: card shows failed with exact transport reason;
+- stale pid after cleanup: stale pid is liveness diagnostic, not root cause if better root cause exists;
+- submitted but not confirmed: bootstrap unconfirmed/stalled, not ready;
+- no process evidence: `never spawned` remains possible;
+- OpenCode bridge lanes keep OpenCode-specific diagnostics.
+
+## Edge cases
+
+### Stale pid after app restart
+
+Keep root failure and add liveness diagnostic:
+
+```text
+hardFailureReason: Teammate process atlas@signal-ops did not submit bootstrap prompt: ...
+runtimeDiagnostic: persisted runtime pid is not alive
+```
+
+### Old event file from previous run
+
+Reject explicit mismatches:
+
+- team;
+- member/agent;
+- pid when both sides have pid;
+- bootstrapRunId;
+- proof token;
+- timestamp before boundary.
+
+Legacy `runId` mismatch rejects only for known launch-scoped sources/types.
+
+### Submit rejection
+
+Default `retryable: true`. Do not kill immediately. Final failure happens on explicit failure/exit/accepted-without-uuid/non-retryable rejection/timeout.
+
+During runner polling, retryable rejection and `bootstrap_submitted` are not failures. They can only appear in timeout diagnostic text if confirmation never arrives.
+
+### Prompt observed repeatedly
+
+Use `bootstrapMirrorId` for mailbox/bootstrap row id. Throttle repeated deferred/rejection events.
+
+### Provider/model/auth failures
+
+Provider failures win over generic transport fallback:
+
+- API key/auth errors;
+- quota/credit/model limit errors;
+- permission pending;
+- explicit provider/CLI exit;
+- user skipped/removed/stopped states.
+
+### OpenCode
+
+OpenCode bridge is out of scope. Do not route OpenCode through process transport projection.
+
+### Multiple process backend entry points
+
+If `backendType: 'process'` can be reached from more than one spawn branch, each process branch must get the same transport outcome handling through a shared helper. Tmux and in-process branches must not get process-specific kill/wait logic.
+
+### Missing runtime events path
+
+If `bootstrapRuntimeEventsPath` is missing from team metadata:
+
+- orchestrator launch path still uses the in-memory `runtimePaths.eventsPath`;
+- desktop projection falls back to existing launch-state/runtime metadata;
+- no crash and no broad directory scan;
+- diagnostic can say transport events are unavailable only if the member is already in a non-ready state.
+
+### Runtime events path outside team runtime dir
+
+Treat as suspicious metadata and fall back to canonical runtime path for the same team/member. Do not read it and do not surface the path to UI.
+
+If the path is a symlink inside runtime dir that resolves outside runtime dir, reject it too.
+
+### Correlation token/hash leakage
+
+Never include `bootstrapProofToken`, `contextHash`, `briefingHash`, full runtime event JSON, or runtime event file paths in `hardFailureReason`, `runtimeDiagnostic`, structured progress reasons, or renderer diagnostics.
+
+Allowed user-visible transport diagnostic shape:
+
+```text
+Last transport stage: bootstrap_submitted.
+Last transport stage: bootstrap_submit_rejected: submit rejected by local prompt handler.
+```
+
+### Parent event accidentally written with orchestrator pid
+
+If a parent-owned event is written without explicit child pid, it will default to the parent process pid. Outcome matching by runtime pid will miss it, or worse, future fallback matching could confuse events.
+
+Use a parent wrapper that requires `runtimePid`. Add tests that fail if parent process events are emitted with `process.pid` instead of child pid.
+
+### Parent event path/command details
+
+Command/log-path details are internal runtime logs, not UI diagnostics. Keep them out of timeout summaries unless explicitly redacted and allowlisted.
+
+### Runtime event file cannot be written
+
+The launch should not crash solely because the diagnostic event file cannot be written. Existing readiness/submit waits may timeout and produce the normal failure, but event append failure itself should stay best-effort.
+
+### Large launch-state after diagnostics
+
+If a team has many members, diagnostics must remain compact enough that detailed launch-state reads stay within `TeamLaunchStateStore`'s `256 KiB` cap. The team list can use compact `launch-summary.json`, but team detail/reconcile still needs readable `launch-state.json`.
+
+Do not append full event timelines to member diagnostics. If more detail is needed, keep it in runtime logs/events and expose only the selected summary.
+
+### Launch-state / launch-summary mismatch
+
+Do not bypass `TeamLaunchStateStore.write(...)`. It updates both detailed launch-state and compact launch-summary. If enrichment only mutates memory or writes one file, list view and team detail can disagree.
+
+### Restart/edit preserving bootstrap metadata
+
+Restart paths should replace bootstrap metadata for the new launch window. Pure team edit paths should not clear current bootstrap metadata unless they intentionally invalidate/restart the member. This prevents old launch windows from being mixed with new runtime evidence.
+
+Regression cases:
+
+- edit member display role/model without restart preserves current bootstrap metadata;
+- restart member updates boundary/path/run metadata and does not reuse previous launch window;
+- failed spawn before registration does not publish a stale new `bootstrapRuntimeEventsPath`;
+- relaunch after app restart ignores stale previous metadata when current launch snapshot has a newer run boundary.
+
+### Failed then later confirmed
+
+If a member has a stale `failureReason` in bootstrap-state and then receives `bootstrap_confirmed`, the state store must clear the stale failure reason. The journal can keep the historical failed event.
+
+### Corrupt or very large runtime event file
+
+Reader behavior:
+
+- bounded tail read;
+- skip corrupt/partial lines;
+- return available valid events;
+- if no valid matching events remain, keep existing generic failure behavior;
+- never block launch finalization on parsing a diagnostic file.
+
+### Submit rejection without structured reason
+
+Current submit result has no rejection reason. Use stable generic detail:
+
+```text
+submit rejected by local prompt handler
+```
+
+Do not assert provider/model-specific text for this path.
+
+## Test plan
+
+## Edge-case matrix
+
+| Scenario | Should change launch state? | Expected behavior |
+|---|---:|---|
+| Current `bootstrap_submitted` without confirmation | Yes, to pending only | Prevent `never spawned`, keep unconfirmed. |
+| Retryable submit rejection during active launch | Yes, to pending/warning only | Wait for later submit/failure/timeout. |
+| Retryable submit rejection accidentally marked `error` severity | No | This is a plan violation; pending submit issues are warning/info only. |
+| Non-retryable submit rejection for current attempt | Yes | `failed_to_start` with sanitized local submit failure. |
+| Provider auth/quota/model failure plus later timeout | Yes | Provider failure remains root cause. Timeout is secondary diagnostic only. |
+| Old event from previous restart | No | Ignore unless current attempt identity matches. |
+| Wrong member event in valid file | No | Ignore. |
+| Runtime event file missing | No | Existing timeout/fallback behavior. |
+| Runtime event file corrupt | No direct failure | Skip corrupt lines, use later valid lines. |
+| Existing team lacks new metadata fields | No | No migration failure, existing fallback. |
+| Stale `launch-summary.json` disagrees with detailed launch-state | No direct recovery | Summary ignored for recovery truth, next store write repairs it. |
+| Current process pid reused by unrelated process | No | Liveness diagnostic only unless command identity confirms. |
+| Parent-owned event missing explicit child pid | Should fail tests | Do not rely on writer default pid. |
+| Process is already dead during cleanup | Yes only if current terminal launch failure | Record diagnostic, do not throw blindly. |
+| Detailed state unchanged but summary missing/stale | Yes, write repair only | Force `TeamLaunchStateStore.write(...)` if bounded summary freshness check says repair is needed. |
+| OpenCode secondary lane with process metadata | No for this phase | Skip process transport enrichment because OpenCode bridge has separate evidence model. |
+| Bootstrap proof overlay confirms member after transport failure | Yes | Confirmed proof wins and clears auto-clearable transport failure for the current member. |
+| Same pending diagnostic observed on repeated refresh | No semantic write | Stable message/code keeps launch-state semantic equality. |
+| More relevant later stage observed | Yes | Replace pending diagnostic with stronger stable stage diagnostic. |
+| Event read budget exceeded on large team | No failure | Skip remaining enrichment and rely on existing launch timeout/fallback. |
+| Transport diagnostic only in `diagnostics[]` | No sufficient UI signal | Move selected message to `runtimeDiagnostic` or `hardFailureReason`. |
+| New `MemberSpawnStatusEntry.diagnostics` added | No | Use existing renderer-visible fields. Adding it is out of scope. |
+| Team stopped while evidence read is in progress | No stale transport write | Discard projection before persist. Stop/cancel state remains owner. |
+| Member restarted while old timeout handler is pending | No stale failure write | Old handler fails current-attempt check and does not persist. |
+| New materialized restart attempt after old failure | Yes, to pending | New attempt boundary can clear auto-clearable transport failure. |
+| Pending restart metadata without new runtime evidence | No | Old terminal failure remains visible. |
+| Provider hard failure after generic timeout | Yes | Provider hard failure replaces generic timeout root cause for same current attempt. |
+| `bootstrap_submitted` exists but parent `mailbox_bootstrap_written` is missing | Yes, to pending only | Submitted transport evidence wins; missing parent event is diagnostic only. |
+| Parent `mailbox_bootstrap_written` exists but child never observes prompt | Yes, timeout diagnostic only | Report mailbox-written-but-not-observed, not `never spawned`. |
+| `bootstrap_prompt_observed` exists without `bootstrap_submit_attempted` | Yes, pending/timeout diagnostic | Child saw prompt but did not submit it. Do not mark ready. |
+| Child writes `bootstrap_submitted` after parent observed process exit | No success | Parent current-pid terminal lifecycle wins unless strict proof later confirms. |
+| Child writes `failed` with provider-looking text | Maybe terminal transport only | It can fail current transport, but cannot replace existing provider/root failure or bypass sanitizer. |
+| Child writes event for right member but wrong `agentId` | No | Strict identity mismatch. Ignore for current state. |
+| Event file path belongs to another member but payload names current member | No | Expected path and payload identity must both pass. |
+| Event payload belongs to another member but path is current member | No | Wrong-member event ignored. |
+
+## Test Plan
+
+Blocking gates:
+
+1. Unit tests for sanitizer, attempt identity, evidence classification, and merge precedence must pass before touching production spawn integration.
+2. Fake process backend e2e modes must pass before enabling the new outcome waiter in every process branch.
+3. Desktop projection tests must pass before adding enriched diagnostics to `persistLaunchStateSnapshotNow`.
+4. Typecheck must pass after each repo's integration step.
+5. Live mixed smoke is last. It can reveal provider/model issues, but it must not be used as the first proof for deterministic branch logic.
+
+Required negative checks:
+
+- intentionally malformed old files do not crash startup/team open;
+- missing new fields do not create new `failed_to_start`;
+- old valid-looking events from a previous restart do not affect current attempt;
+- wrong-member event path or payload does not affect current member;
+- provider/quota/auth error remains visible even when transport timeout happens later;
+- stale summary file cannot resurrect old pending/failed state after detailed state moved on.
+- detailed state write succeeds while summary write fails: detail remains source truth and next write repairs summary;
+- old proof confirmation tests still pass after shared reader extraction;
+- transport validator cannot accept `bootstrap_confirmed` unless proof validator handles it.
+- desktop and orchestrator runtime filename codec golden vectors match for case, punctuation, `@`, Windows reserved names, and current non-ASCII fallback;
+- runtime filename collision without payload identity does not enrich either member;
+- mismatched payload `agentId` beats matching path/pid and returns no-match;
+- mismatched `bootstrapRunId` beats matching path/pid and returns no-match.
+
+### Orchestrator event helper tests
+
+Target:
+
+```text
+/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.test.ts
+```
+
+Cases:
+
+- bounded reader reads tail and drops first partial line;
+- bounded reader skips corrupt and final partial JSONL lines without throwing;
+- bounded reader skips oversized JSONL lines before parse and does not increase read budget to recover diagnostics;
+- bounded reader caps candidate lines/events per file and preserves append order for accepted events;
+- missing event file returns empty list;
+- append order beats misleading future/past timestamps for stage selection;
+- low-value later heartbeat does not hide earlier submit/failure stage in timeout diagnostic;
+- desktop shared runtime event reader preserves existing bootstrap proof overlay behavior;
+- desktop canonical runtime filename matches orchestrator sanitizer for reserved names like `con` and `aux`;
+- desktop canonical runtime filename matches orchestrator sanitizer for `@`, punctuation, underscores, uppercase, all-punctuation names, and non-ASCII names;
+- desktop canonical runtime filename matches orchestrator sanitizer for empty input and does not substitute `default`;
+- filename codec tests document that non-ASCII names currently become hyphen sequences and must not be changed in this phase;
+- existing full reader behavior unchanged;
+- outcome returns submitted on matching `bootstrap_submitted`;
+- failed beats non-terminal events;
+- exited returns process-exited;
+- accepted without UUID fails;
+- retryable rejected does not fail if later submitted appears;
+- non-retryable rejected fails immediately;
+- timeout includes last relevant stage/detail;
+- stale timestamp ignored;
+- bootstrapRunId/proof token mismatch ignored as non-match;
+- legacy session-id `runId` is not wrongly rejected when bootstrapRunId absent.
+- runtime diagnostic sanitizer redacts token-like strings, absolute paths, command snippets, proof tokens, hashes, and oversized text;
+- runtime diagnostic sanitizer preserves meaningful provider error text after redaction.
+- attempt identity classifies strict-current, legacy-current, diagnostic-only, and no-match;
+- explicit bootstrapRunId mismatch beats pid/path match and returns no-match;
+- current pid/path/notBefore can accept legacy events only as legacy-current;
+- stale pre-restart event with same member name but old pid/run is ignored.
+- failure taxonomy maps retryable submit rejection to non-terminal warning;
+- failure taxonomy maps accepted-without-uuid and non-retryable rejection to terminal error;
+- failure taxonomy ignores unknown event types for transport classification;
+- failure taxonomy does not classify by regex over event `detail`;
+- missing/unreadable event file maps to `event_unavailable`, not terminal failure;
+- stale/mismatched evidence maps to `stale_or_mismatched_evidence`, not user-visible root cause.
+- diagnostic mapper returns stable message/code for repeated same-stage evidence;
+- diagnostic mapper does not include timestamp, pid command, path, raw stderr tail, or raw detail in persisted message;
+- event reader/budget helper enforces max files and total bytes per projection pass.
+- diagnostic mapper maps pending states to info/warning severity and terminal current-attempt failures to error severity.
+- event writer wrappers reject/omit forbidden fields for parent transport events;
+- generic transport events do not include proof token, context hash, briefing hash, raw command, cwd, or log paths.
+- parent-owned `process_spawned`, `mailbox_bootstrap_written`, and terminal `failed` events require explicit child runtime pid and never default to orchestrator parent pid.
+- parent-owned writer wrapper throws or rejects invalid pid at development/test boundary, while raw inner writer remains backward-compatible for existing callers;
+- unwritable runtime events path logs bounded warning and does not crash launch/spawn path;
+- partial-stream classifier handles `bootstrap_submitted` without parent mailbox event as pending submitted;
+- partial-stream classifier handles parent mailbox-only timeout as mailbox-written-but-not-observed, not `never spawned`;
+- partial-stream classifier handles prompt-observed-without-submit as prompt-observed pending/timeout diagnostic;
+- missing intermediate events never erase a later stronger transport stage.
+
+### Orchestrator poller tests
+
+Target:
+
+```text
+/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/hooks/useInboxPoller.test.ts
+```
+
+Cases:
+
+- observed emits `bootstrap_prompt_observed`;
+- attempt emits `bootstrap_submit_attempted`;
+- rejected emits `bootstrap_submit_rejected` with `retryable: true`;
+- rejected uses generic sanitized detail because submit result has no reason field;
+- accepted without UUID emits terminal event;
+- accepted with UUID emits `bootstrap_submitted`;
+- native app-managed path includes `bootstrapRunId` and proof token;
+- bootstrap row id is `bootstrapMirrorId`, submitted UUID is `bootstrapMessageId`;
+- repeated polls do not spam observed events.
+
+### Orchestrator process backend e2e
+
+Target:
+
+```text
+/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapProcessBackend.e2e.test.ts
+```
+
+Cases:
+
+- process backend utility refuses to kill a live process when `expectedAgentId` or `teamName` mismatches;
+- process backend utility refuses to kill when command line lookup fails, instead of falling back to blind PID kill;
+- stdin-sensitive runtime stays alive;
+- working process backend confirms;
+- headless text mode has no 3s stdin warning;
+- normal non-interactive text CLI without `CLAUDE_CODE_TEAMMATE_RUNTIME=headless` still peeks stdin and preserves existing warning behavior;
+- `stream-json` keeps returning `process.stdin` even for headless teammate runtime;
+- fake runtime harness has modes for retryable submit rejection, terminal submit rejection, accepted without UUID, no submit, observed-no-submit, inbox-ready-no-submit, submit-then-exit, terminal failed, corrupt/partial event line, stderr-secret, stale restart event, and wrong-member event;
+- retryable rejection can later submit;
+- retryable rejection without later submit times out with the last safe transport stage, not a terminal provider-looking failure;
+- terminal submit rejection fails before generic bootstrap timeout;
+- accepted without UUID fails explicitly;
+- submit-then-exit fails as process exit while preserving submitted evidence;
+- failed-after-runtime-ready surfaces the terminal failed event instead of `never spawned`;
+- partial-corrupt-event skips corrupt lines and still reads later valid event;
+- stderr-secret diagnostics are redacted and length-bounded before bootstrap-state/progress/UI;
+- stale restart event is ignored for the current launch/restart attempt;
+- wrong-member event is ignored even when event path is otherwise valid;
+- child progress event after parent-observed current-pid process exit does not resurrect pending/success;
+- child `failed` event with provider-looking text is sanitized and treated as transport failure only;
+- current member path with wrong payload identity is ignored;
+- wrong member path with current member payload is ignored;
+- timeout includes last transport stage;
+- every branch that can persist `backendType: 'process'` uses the shared submit outcome helper;
+- terminal submit failure after registration uses identity-guarded cleanup;
+- direct `killProcessTree(runtimePid)` remains limited to freshly spawned pre-registration child cleanup;
+- tmux branch does not call process submit outcome helper;
+- in-process branch does not call process submit outcome helper.
+- runtime event write failure does not crash parent spawn by itself and falls back to timeout/error path.
+- parent-owned runtime events in e2e carry child pid, not parent pid.
+- user-visible timeout diagnostics do not include command line, cwd, stdout/stderr path, proof token, context hash, briefing hash, or full event JSON.
+- every current `bootstrapProofToken`/`bootstrapRunId` creation block in `spawnMultiAgent.ts` is covered by branch inventory or explicitly excluded as non-process.
+- direct `killProcessTree(child)` call sites are classified as pre-registration child cleanup or replaced with identity-guarded cleanup.
+
+### Runner tests
+
+Target:
+
+```text
+/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapRunner.test.ts
+```
+
+Cases:
+
+- existing failed event still fails immediately;
+- process exits after `bootstrap_submitted` but before bootstrap confirmation and runner reports process-exited instead of waiting until generic timeout;
+- non-process backend with an accidental `runtimePid` does not read process runtime events;
+- timeout with last `bootstrap_submitted` reports stage but fails;
+- timeout with retryable rejection reports the rejected submit stage and sanitized generic detail;
+- retryable rejection during polling does not mark member failed before timeout;
+- `bootstrap_submitted` during polling does not mark member failed before timeout;
+- timeout without transport evidence keeps old generic message;
+- stale pre-boundary event ignored;
+- persisted `failureReason` is redacted and bounded.
+- `bootstrap_confirmed` after a previous failed state clears stale `failureReason` in bootstrap-state.
+- duplicate/already-running registered result does not persist `failureReason`; its reason remains only as sanitized non-terminal progress/journal detail.
+- `TeamBootstrapStateStore.markMemberResult(...)` call sites compile after separating terminal `failureReason` from non-terminal `detail`.
+- progress emitter redacts/bounds `member_spawn_result.reason`, `failed_members[].reason`, and terminal `failed.reason`.
+
+### `claude_team` service tests
+
+Target:
+
+```text
+/Users/belief/dev/projects/claude/claude_team/test/main/services/team/TeamProvisioningService.test.ts
+```
+
+Cases:
+
+- process transport evidence prevents false `never spawned`;
+- no evidence still allows `never spawned`;
+- terminal launchPhase with `bootstrap_submitted` evidence does not trigger normalizer's `Teammate was never spawned during launch.`;
+- terminal launchPhase with only true missing member still triggers `Teammate was never spawned during launch.`;
+- process transport merge uses internal `projectionPhase` and never writes persisted launchPhase values outside `active | finished | reconciled`;
+- unknown persisted launchPhase strings continue to normalize through existing evaluator fallback and are not produced by new code;
+- clean-success finished launch can still clear persisted launch-state even when runtime event files are missing/unreadable;
+- partial/pending process member prevents clean-success clearing and keeps launch-state visible;
+- `bootstrap_submitted` yields pending/unconfirmed, not confirmed;
+- retryable rejection remains pending/warning during active launch;
+- parent failed event yields `failed_to_start` with exact reason;
+- failure precedence matrix is covered through direct pure merge helper tests;
+- confirmed member cannot be downgraded by later process transport failure;
+- provider/auth/quota/model failure is not overwritten by terminal transport failure;
+- new materialized restart attempt can clear old terminal failure into pending;
+- non-materialized pending restart metadata cannot clear old terminal failure;
+- provider hard failure is not overwritten by stale pid cleanup;
+- cleanup updates liveness while preserving root reason;
+- arbitrary non-terminal `error` string does not block cleanup;
+- same-attempt pending event does not clear or downgrade existing terminal transport failure;
+- `failed_to_start -> starting` is forbidden unless a new materialized restart attempt boundary exists;
+- permission-pending member is not converted to generic `failed_to_start` by transport timeout;
+- skipped/removed member ignores process transport evidence;
+- stale prior-run event does not revive/fail current launch;
+- mismatched bootstrapRunId blocks enrichment;
+- persisted events path outside team runtime dir is ignored/falls back to canonical path;
+- symlinked runtime events path resolving outside runtime dir is rejected;
+- user-visible diagnostics do not include proof tokens, hashes, runtime event paths, or full event JSON;
+- shared renderer-facing types are not extended with transport internals;
+- parent-owned process events are emitted with child runtime pid, not parent process pid;
+- process cleanup after metadata publication uses identity-guarded `killProcessBackendRuntime`, not blind pid kill;
+- timeout diagnostics do not expose `process_spawned` command detail or `stdout_attached` log paths;
+- diagnostic additions keep normalized launch-state below detailed launch-state read budget for a representative large team;
+- edit path preserves bootstrap metadata unless member restart/launch intentionally replaces it;
+- existing app-managed bootstrap proof event still confirms after reader extraction;
+- transport event without proof does not clear failed state as confirmed;
+- OpenCode member ignored by process enrichment;
+- user-visible diagnostics are redacted and bounded;
+- legacy/raw bootstrap-state failureReason is sanitized in `TeamBootstrapStateReader`;
+- owner-dead failureReason overlay is sanitized before applying to pending members;
+- legacy/raw bootstrap-journal detail/reason is sanitized before it becomes warning text;
+- persisted launch-state `diagnostics[]`, `runtimeDiagnostic`, `hardFailureReason`, and `skipReason` are redacted/bounded in `TeamLaunchStateEvaluator` normalization;
+- missing `bootstrapRuntimeEventsPath` does not crash and keeps existing fallback behavior.
+- persisted enrichment updates launch-state and launch-summary through `TeamLaunchStateStore.write(...)`.
+- terminal process transport failure changes both detailed launch-state and compact launch-summary missing/failed counts consistently.
+- submit/process evidence without durable confirmation leaves compact summary in pending state and increments the relevant pending runtime bucket, not confirmed count.
+- projection coordinator discards evidence if current run changes before persist;
+- projection coordinator handles missing/unreadable event file as no enrichment;
+- launch-state is treated as previous projection only and does not become source truth without current run boundary;
+- launch-state and launch-summary remain consistent after one projection write.
+- stale launch-summary projection is ignored when detailed launch-state/current evidence disagrees;
+- summary write failure after detailed write does not corrupt detailed launch-state and is repaired by next successful store write;
+- no-op skip does not prevent summary repair when summary is missing/stale;
+- list/detail consistency holds after terminal transport failure and after pending submit evidence;
+- team detail never uses launch-summary as recovery truth for process transport state.
+- existing `findBootstrapRuntimeProofObservedAt(...)` behavior remains strict after moving file read mechanics to shared reader;
+- process transport validator does not classify proof events as transport success.
+- existing OpenCode secondary overlay behavior remains unchanged when process transport enrichment is enabled;
+- proof overlay confirmation has higher precedence than current-attempt transport failure when strict proof is valid.
+- repeated same pending transport diagnostic does not force semantic launch-state write;
+- stronger later transport stage changes semantic state exactly once;
+- large team event read budget prevents broad runtime event scanning.
+- pending transport diagnostic appears through `runtimeDiagnostic`, not only `diagnostics[]`;
+- terminal current-attempt transport failure appears through `hardFailureReason` and `runtimeDiagnosticSeverity: 'error'`;
+- ordinary `bootstrap_submitted` waiting state does not set `bootstrapStalled`.
+- `MemberSpawnStatusEntry` shape is not extended with transport diagnostics array or transport kind.
+- stop/cancel during evidence read discards stale transport projection before persist;
+- member restart during old timeout/finalizer prevents old failure from overwriting new pending state;
+- pending restart metadata without new runtime evidence does not clear existing terminal failure;
+- new materialized restart attempt can move old auto-clearable transport failure to pending;
+- provider hard failure after generic timeout replaces generic timeout as root cause for the same attempt;
+- provider hard failure is not auto-cleared by pending process evidence in the same attempt.
+- same-attempt pending evidence does not downgrade an existing terminal transport failure;
+- `failed_to_start` does not become `starting` from profile edit or stale liveness metadata;
+- permission-pending and skipped states are absorbing against generic process transport evidence;
+- child-owned transport event cannot mark `confirmed_alive`;
+- child-owned `failed` cannot overwrite provider/auth/quota/model hard failure;
+- parent-observed process exit wins over later child progress for same current pid unless strict proof confirms;
+- path identity and payload identity must both match before process transport enrichment applies.
+
+### Typecheck
+
+After implementation:
+
+```bash
+pnpm typecheck --pretty false
+```
+
+Orchestrator commands should use actual package scripts. Likely:
+
+```bash
+bun test src/utils/swarm/teammateRuntimeEvents.test.ts
+bun test src/hooks/useInboxPoller.test.ts
+bun test src/services/teamBootstrap/teamBootstrapRunner.test.ts
+bun test src/services/teamBootstrap/teamBootstrapProcessBackend.e2e.test.ts
+```
+
+## Implementation order
+
+0. Process backend branch inventory and helper boundary.
+1. Shared runtime diagnostic redaction/bounding.
+2. Transport failure taxonomy and diagnostic mapper tests.
+3. Attempt identity helper and matching-level tests.
+4. Event schema fields, explicit parent/child writer wrappers, and writer-boundary field filtering.
+5. Bounded recent reader.
+6. Bootstrap submission outcome helper.
+7. Deterministic fake runtime harness modes for all new transport branches.
+8. `useInboxPoller` event emissions.
+9. Process backend integration in `spawnMultiAgent.ts` through shared helper.
+10. Narrow stdin peek skip.
+11. `teamBootstrapRunner` timeout diagnostics.
+12. Orchestrator tests/e2e.
+13. `claude_team` process transport evidence reader.
+14. Pure launch-state merge helper and precedence tests.
+15. `claude_team` projection enrichment.
+16. Cleanup liveness merge fix.
+17. `claude_team` tests/typecheck.
+18. Real mixed smoke: Anthropic API key, Codex subscription, OpenCode if auth valid.
+
+Do not implement `claude_team` projection enrichment before orchestrator emits enough facts.
+Do not integrate production process branches before the fake harness can reproduce every terminal and non-terminal outcome listed above.
+
+## Implementation review checklist
+
+Use this checklist before committing implementation.
+
+- No new renderer-facing fields expose `bootstrapProofToken`, hashes, runtime event paths, raw events, or attempt identity internals.
+- No `MemberSpawnStatusEntry` field is added for process transport internals.
+- No `as any` is used to bypass runtime event type additions.
+- No generic transport event writer can include proof token/hash/path/raw command fields.
+- No parent-owned runtime event write uses the raw writer directly when a wrapper requiring child pid exists.
+- No branch with `backendType === 'process'` bypasses the shared process outcome helper unless documented in Phase 0 inventory.
+- No non-process backend calls process transport evidence reader.
+- No post-registration cleanup uses raw `killProcessTree(runtimePid)`.
+- No user-visible diagnostic is built from `JSON.stringify(event)`, raw stderr tail, raw command, cwd, or runtime path.
+- No provider/auth/quota/model failure is overwritten by process transport timeout.
+- No retryable rejection triggers terminal failed state during active launch.
+- No `bootstrap_submitted`, `runtime_ready`, PID, RSS, or mailbox row count marks a teammate confirmed.
+- No pure evaluator imports filesystem/process/event reader modules.
+- No direct writes to `launch-summary.json`; use `TeamLaunchStateStore.write(...)`.
+- No code assumes `launch-state.json` and `launch-summary.json` are an atomic pair.
+- No no-op skip leaves a known missing/stale `launch-summary.json` unrepaired when detailed state is current.
+- No projection persist happens after current run/restart identity changed.
+- No volatile timestamp/attempt/pid/path/raw detail is embedded into semantic persisted diagnostics.
+- No process transport evidence reader runs for team-list-only summary refresh.
+- No selected user-facing transport diagnostic is stored only in `diagnostics[]`.
+- No pending transport state uses `runtimeDiagnosticSeverity: 'error'`.
+- No ordinary submitted/waiting transport state sets `bootstrapStalled`.
+- No new persisted `launchPhase` string is introduced. Internal final/terminal concepts stay internal.
+- No stale finalizer/timeout path can persist after stop/cancel/restart identity changed.
+- No pending-only evidence clears provider/runtime hard failure.
+- No generic transport timeout overwrites provider/auth/quota/model root cause.
+- No child-owned transport event is used as availability proof.
+- No child-owned event detail text drives state transitions.
+- No runtime event file is accepted by path alone without payload identity match.
+- No runtime event payload is accepted by identity alone without expected path/current attempt match.
+- Missing/corrupt/unreadable runtime event files degrade to no enrichment or timeout, not crash.
+- Old launch-state/team config without new fields remains readable and does not create new failure.
+- Shared runtime event reader does not decide proof vs transport semantics.
+- Existing OpenCode overlays remain isolated from process transport enrichment.
+- Tests cover strict-current, legacy-current, diagnostic-only, and no-match.
+- Tests cover stale restart event and wrong-member event.
+- Tests cover two-file state/summary mismatch recovery.
+- Tests cover proof-reader behavior after shared reader extraction.
+- Tests cover write-churn stability for repeated same diagnostic.
+- Tests cover large-team read budget.
+- Tests cover renderer-visible diagnostic field placement.
+- Tests cover runtime filename codec parity with orchestrator, including Windows reserved basenames and current non-ASCII fallback.
+- Tests cover filename collision fail-closed behavior and prove path match alone is never enough.
+- Tests cover `agentId` and `bootstrapRunId` mismatch taking precedence over matching pid/path.
+
+## Definition of done
+
+Implementation is complete only when all these are true:
+
+- A process-backend teammate that reaches `bootstrap_submitted` but never confirms no longer shows `never spawned`.
+- The same teammate remains unconfirmed/pending or terminal failed according to the strict state lattice. It is not marked `confirmed_alive`.
+- A non-retryable submit failure becomes `failed_to_start` with stable sanitized reason.
+- A retryable submit rejection stays pending during active launch and does not create a critical notification.
+- Provider/auth/quota/model failure remains the primary root cause even if transport timeout follows.
+- Stop/cancel/restart race tests prove stale finalizers cannot overwrite newer state.
+- Existing OpenCode secondary launch, bootstrap-stall, delivery, and retry behavior is unchanged.
+- Existing app-managed native proof confirmation remains strict and green after shared reader extraction.
+- Existing teams without new fields open without migration and without new false failures.
+- Detailed launch-state and compact launch-summary are consistent after successful writes and recover from stale/missing summary.
+- Repeated refresh with the same pending transport stage does not cause repeated semantic writes or renderer churn.
+- Large team projection reads bounded runtime event data and does not scan historical sessions/transcripts.
+- No renderer/shared type expansion is needed.
+- Runtime event enrichment requires both safe path match and payload identity match.
+- Child-owned runtime events never create availability and never overwrite provider/root failures.
+- Tests cover deterministic fake process backend modes before live provider smoke.
+
+## Code comments to add
+
+Only add comments where non-obvious:
+
+1. Why stdin stays open while text-mode peek is skipped.
+2. Why `bootstrapRunId` exists instead of redefining legacy `runId`.
+3. Why `bootstrapMirrorId` and `bootstrapMessageId` are separate.
+4. Why parent-side `mailbox_bootstrap_written` does not include `bootstrapMirrorId`.
+5. Why transport validation is separate from bootstrap proof validation.
+6. Why `bootstrap_submitted` prevents `never spawned` but does not mark ready.
+7. Why retryable submit rejection remains pending until later submit/failure/exit/timeout.
+8. Why cleanup liveness can update failed member without replacing root cause.
+9. Why persisted runtime events paths are validated against the team runtime directory.
+10. Why diagnostics stay compact instead of embedding event history in launch-state.
+11. Why last-stage transport diagnostics are timeout-only in bootstrap runner polling.
+12. Why non-failed bootstrap results clear stale `failureReason`.
+13. Why progress emitter sanitizes even if callers are expected to sanitize.
+14. Why enrichment persists through `TeamLaunchStateStore.write(...)` rather than writing files directly.
+15. Why diagnostics intentionally omit proof tokens, hashes, paths, and full event JSON.
+16. Why runtime event path validation uses `isPathWithinRoot` plus realpath checks.
+17. Why parent-owned runtime events use a wrapper requiring child runtime pid.
+18. Why parent command/log path event details are not shown in timeout diagnostics.
+19. Why runtime event writes are best-effort and absence falls back to existing timeout behavior.
+20. Why deterministic tests use fake runtime modes instead of live provider launches.
+21. Why post-registration process cleanup refuses blind PID kill and requires command-line identity match.
+22. Why runtime filename matching is only a lookup hint and payload/attempt identity wins.
+23. Why desktop duplicates or imports the orchestrator filename codec with golden tests instead of using a nicer slug.
+24. Why ambiguous filename collisions return no enrichment rather than best-effort matching.
+25. Why process transport enrichment must run before the launch-state normalizer can synthesize `Teammate was never spawned during launch.`
+22. Why initial stdin peek is skipped only for `CLAUDE_CODE_TEAMMATE_RUNTIME=headless`, not for all non-interactive text sessions.
+23. Why runtime event append order is primary and timestamp is secondary.
+24. Why `launch-state.json` is a projection, not transport source truth.
+25. Why projection is discarded if current run/restart identity changes before persist.
+26. Why `launch-state.json` and `launch-summary.json` are treated as recoverable two-file projection, not one atomic transaction.
+27. Why shared runtime event reader parses JSONL only and leaves proof/transport semantics to separate validators.
+28. Why no-op launch-state write skip must still consider summary repair.
+29. Why process transport enrichment runs after existing OpenCode overlays but remains skipped for OpenCode lanes.
+30. Why transport diagnostics use stable codes/messages and keep volatile timestamps out of persisted semantic fields.
+31. Why process runtime event reads are capped per projection pass.
+32. Why pending transport diagnostics use info/warning severity and terminal diagnostics use error severity.
+33. Why selected transport diagnostic is written to `runtimeDiagnostic`/`hardFailureReason`, not only `diagnostics[]`.
+34. Why stale finalizers are discarded after stop/cancel/restart identity changes.
+35. Why failure auto-clear is monotonic and requires strict proof or materialized new attempt.
+36. Why child-owned runtime events are diagnostic transport evidence, not availability proof.
+37. Why event path and event payload identity both have to match.
+
+## Rollback strategy
+
+No feature flag. Rollback via git revert.
+
+Reason: a flag would double launch state combinations and make diagnostics harder. The design is additive and bounded.
+
+## Final expected behavior
+
+Instead of:
+
+```text
+spawn failed
+Teammate was never spawned during launch.
+pid: 65704
+runtimeDiagnostic: persisted runtime pid is not alive
+```
+
+show causally correct status:
+
+```text
+spawn failed
+Teammate process atlas@signal-ops did not submit bootstrap prompt before timeout. Last transport stage: bootstrap_submit_rejected: submit rejected by local prompt handler.
+runtimeDiagnostic: persisted runtime pid is not alive
+```
+
+or:
+
+```text
+bootstrap unconfirmed
+Bootstrap prompt was submitted; waiting for bootstrap confirmation.
+```
+
+`confirmed_alive` remains unchanged and requires durable bootstrap confirmation.
+
+## Confidence
+
+- Transport event vocabulary and bounded reader: 🎯 9.6 🛡️ 9.5 🧠 5.8
+- Process backend branch inventory/helper boundary: 🎯 9.2 🛡️ 9.3 🧠 6.8
+- Retryable submit rejection semantics: 🎯 9.5 🛡️ 9.5 🧠 6.2
+- `bootstrapRunId` compatibility guard: 🎯 9.4 🛡️ 9.4 🧠 6.3
+- Redaction/bounding: 🎯 9.3 🛡️ 9.5 🧠 4.8
+- Stdin peek narrow fix: 🎯 9.2 🛡️ 9.0 🧠 4.5
+- Runner timeout diagnostic enrichment: 🎯 9.2 🛡️ 9.2 🧠 5.4
+- `claude_team` projection enrichment: 🎯 9.1 🛡️ 9.0 🧠 7.2
+- Overall plan: 🎯 9.4 🛡️ 9.3 🧠 7.2
+
+Main remaining risks:
+
+- `spawnMultiAgent.ts` has multiple spawn branches. First identify every branch that can persist `backendType: 'process'`, then route all such branches through the shared process submit-outcome helper. Do not patch only the primary happy path.
+- Legacy `runId` semantics are mixed. Use `bootstrapRunId` for new launch-scoped matching.
+- Current submit rejection branch keeps messages queued. Treating every rejection as terminal would create false failures.
+- Current submit API has no rejection reason. Do not invent one or rely on it for tests.
+- Runtime failure text can be persisted by orchestrator before desktop sees it. Redact/bound in orchestrator first, then defensively in desktop.
+- Existing runtime event reader is full-file. New waits must use bounded recent reads.
+- Persisted `bootstrapRuntimeEventsPath` is metadata, not authority. Validate path before read.
+- Diagnostic strings can make detailed launch-state exceed read budget. Keep persisted member diagnostics compact.
+- Transport evidence must not overwrite provider/auth/quota/root failure diagnostics.
+- Last-stage transport diagnostics must not be returned from polling failure reader.
+- State store must not preserve stale `failureReason` after confirmed/non-failed result.
+- Progress emitter must sanitize because it is a direct user-visible output boundary.
+- Launch-state and launch-summary must stay consistent through the existing store.
+- Proof tokens, hashes, runtime event paths, and full event JSON must never appear in diagnostics.
+- Symlink escapes from persisted runtime event paths must be rejected.
+- Parent-owned runtime events must use child runtime pid, not writer default pid.
+- Command/log path event details must not leak through last-stage timeout summaries.
+- Runtime event write failures must not become new hard launch failures.
+- Deterministic e2e must cover transport states through fake runtime modes.
+- User-visible failureReason is persisted/emitted, so missing redaction would be a high-severity bug.
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index f27fb60f..9b69acad 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -132,6 +132,7 @@ export default defineConfig({
}
},
renderer: {
+ cacheDir: resolve(__dirname, 'node_modules/.vite/electron-renderer'),
optimizeDeps: {
include: ['@codemirror/language-data'],
exclude: ['@claude-teams/agent-graph']
diff --git a/graph-log-preview-smoke.png b/graph-log-preview-smoke.png
new file mode 100644
index 00000000..b8aec00b
Binary files /dev/null and b/graph-log-preview-smoke.png differ
diff --git a/landing/composables/useGithubRepo.ts b/landing/composables/useGithubRepo.ts
index 1a6358ae..364c4e99 100644
--- a/landing/composables/useGithubRepo.ts
+++ b/landing/composables/useGithubRepo.ts
@@ -1,7 +1,7 @@
export const useGithubRepo = () => {
const config = useRuntimeConfig();
const githubRepo = computed(
- () => (config.public.githubRepo as string) || '777genius/claude_agent_teams_ui',
+ () => (config.public.githubRepo as string) || '777genius/agent-teams-ai',
);
const repoUrl = computed(() => `https://github.com/${githubRepo.value}`);
const releasesUrl = computed(
diff --git a/landing/composables/useReleaseDownloads.ts b/landing/composables/useReleaseDownloads.ts
index cc876b4d..70491956 100644
--- a/landing/composables/useReleaseDownloads.ts
+++ b/landing/composables/useReleaseDownloads.ts
@@ -111,7 +111,7 @@ function writeCache(data: DownloadsApiResponse): void {
export const useReleaseDownloads = () => {
const config = useRuntimeConfig();
- const githubRepo = (config.public.githubRepo as string) || "777genius/claude_agent_teams_ui";
+ const githubRepo = (config.public.githubRepo as string) || "777genius/agent-teams-ai";
const fallbackUrl =
(config.public.githubReleasesUrl as string) ||
diff --git a/landing/nuxt.config.ts b/landing/nuxt.config.ts
index ae995584..8c5fe8d0 100644
--- a/landing/nuxt.config.ts
+++ b/landing/nuxt.config.ts
@@ -4,8 +4,8 @@ import { generateI18nRoutes, supportedLocales } from "./data/i18n";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const process: any;
-const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui";
-const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
+const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai";
+const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
diff --git a/landing/product-docs/.vitepress/config.ts b/landing/product-docs/.vitepress/config.ts
index e1c00dbf..bc860deb 100644
--- a/landing/product-docs/.vitepress/config.ts
+++ b/landing/product-docs/.vitepress/config.ts
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
import { defineConfig, type DefaultTheme } from "vitepress";
import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms";
-const REPO = "777genius/claude_agent_teams_ui";
+const REPO = "777genius/agent-teams-ai";
const SITE_TITLE = "Agent Teams Docs";
const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration.";
@@ -23,7 +23,7 @@ const withTrailingSlash = (value: string) => `${trimTrailingSlash(value)}/`;
const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/");
const base = appBase === "/" ? "/docs/" : `${appBase}docs/`;
const siteUrl = trimTrailingSlash(
- process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui"
+ process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai"
);
const publicBaseUrl =
appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase))
diff --git a/landing/product-docs/.vitepress/theme/InstallBlock.vue b/landing/product-docs/.vitepress/theme/InstallBlock.vue
index e9854148..d885d931 100644
--- a/landing/product-docs/.vitepress/theme/InstallBlock.vue
+++ b/landing/product-docs/.vitepress/theme/InstallBlock.vue
@@ -8,7 +8,7 @@ const props = withDefaults(
copiedLabel?: string;
}>(),
{
- command: "git clone https://github.com/777genius/claude_agent_teams_ui.git",
+ command: "git clone https://github.com/777genius/agent-teams-ai.git",
label: "Click to copy",
copiedLabel: "Copied"
}
diff --git a/landing/product-docs/guide/agent-workflow.md b/landing/product-docs/guide/agent-workflow.md
index 0817b1f7..7e63c6f4 100644
--- a/landing/product-docs/guide/agent-workflow.md
+++ b/landing/product-docs/guide/agent-workflow.md
@@ -2,34 +2,84 @@
Agent Teams makes agent work visible as task state, messages, logs, and reviewable code changes.
-## Lifecycle
+## Modes
-| Stage | What happens |
+| Mode | Description |
| --- | --- |
-| Provisioning | The app starts the team and confirms runtime readiness |
-| Planning | The lead creates tasks and may assign teammates |
-| In progress | Agents work in parallel and update task state |
-| Review | Changes are reviewed by agents or by you |
-| Done | Accepted work stays linked to its task history |
+| Solo | One teammate with self-managed tasks |
+| Team | Many teammates working in parallel, reviewing each other |
+
+Both modes share the same kanban, task logs, and code review surfaces.
+
+## Task lifecycle
+
+| Stage | What happens | Owner |
+| --- | --- | --- |
+| Provisioning | The app starts the runtime, confirms the process is alive, and waits for bootstrap confirmation | App |
+| Planning | The lead creates tasks, optionally assigns teammates, and sets dependencies | Lead or user |
+| In progress | Agents work in parallel and update task state via board MCP tools | Teammates |
+| Review | Changes are reviewed by agents or by you before final acceptance | Team lead or user |
+| Done | Accepted work stays linked to its task history and can still be inspected later | User |
+
+### Planning → In progress
+
+When a teammate starts a task, the board status becomes `in_progress`. The agent creates a task comment with its plan and continues working. All native tool actions (read, bash, edit, write) are streamed into a task log.
+
+### In progress → Review
+
+When the teammate finishes work, it posts a result comment and marks the task `completed`. The lead can then decide whether to accept it immediately or move it into review.
+
+### Review → Done
+
+If the review surface shows acceptable changes, approve the review. The task is finalized and linked to its diff.
+
+::: warning Fix-first review
+If a teammate is asked for changes during review, it should post a follow-up comment with the fixes, then the lead can approve.
+:::
## Kanban board
-The board is the primary operating surface. It lets you scan work, spot blocked tasks, open task detail, inspect logs, and review changes without reading raw session files.
+The board is the primary operating surface. It lets you:
+
+- Scan open, blocked, and in-review work
+- Open task detail and inspect runtime logs
+- Review changes without reading raw session files
+- Assign or reassign owners
+
+::: tip
+Use quick action buttons on cards to start, complete, or request review without opening the detail panel.
+:::
## Messages and comments
-Use direct messages when you need to redirect an agent. Use task comments when the note belongs to a specific piece of work. Comments preserve context for later review.
+| Channel | When to use |
+| --- | --- |
+| Direct message | Redirect an agent, ask a quick question |
+| Task comment | Notes that belong to a specific task |
+
+Comments preserve context for later review and appear in the task timeline.
+
+::: tip Prefer task comments
+If the remark is about a specific task, add it as a comment on that task rather than sending a direct message. It keeps the history linked to the work.
+:::
## Task logs
-Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them when you need to answer:
+Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them to answer:
-- what did this agent run?
-- why did it change this file?
-- did it ask another teammate for help?
-- which task produced this diff?
+- What did this agent run?
+- Why did it change this file?
+- Did it ask another teammate for help?
+- Which task produced this diff?
+
+## Parallel work patterns
+
+Teammates can work on independent tasks at the same time. You can also create dependency links (`blocked-by`) so that one task waits until another is complete. Watch the board for blocked lanes and reassign owners if one teammate is idle while another is overloaded.
## Live processes
-The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results.
+The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results. Processes remain registered until they are explicitly stopped or the runtime exits.
+## Cross-team communication
+
+Agents can send messages to other teams when teams are linked. Use this for handoffs, shared libraries, or status checks between squads.
diff --git a/landing/product-docs/guide/code-review.md b/landing/product-docs/guide/code-review.md
index 8c1fb48e..48771c1c 100644
--- a/landing/product-docs/guide/code-review.md
+++ b/landing/product-docs/guide/code-review.md
@@ -4,32 +4,60 @@ Code review in Agent Teams is task-centered. You inspect what changed for a spec
## Review surface
-Use the review UI to:
+For each completed task that touched files, the review UI lets you:
-- inspect changed files
-- accept or reject individual hunks
-- leave comments
-- connect the diff back to the task and agent logs
+- Inspect changed files with before/after context
+- Accept or reject individual hunks
+- Leave inline comments
+- Connect the diff back to the task description and agent logs
## Hunk-level decisions
Accept small correct changes and reject isolated mistakes without throwing away the whole task. This is useful when an agent mostly solved the task but overreached in one file.
+::: tip Accept incrementally
+If a diff is mostly correct, accept the good hunks first and request changes only for the parts that need fixing. This keeps the board moving.
+:::
+
+## Initiating review
+
+1. Open a completed task
+2. Look at the **Changes** tab
+3. If the diff looks reasonable, click **Request Review** to move the task into the review column
+
+During review the task is not yet considered done, so other teammates or the lead can still comment on it.
+
+## Review states
+
+| State | Meaning |
+| --- | --- |
+| `none` | Task is new, in progress, or completed but not yet in review |
+| `review` | The task is actively under review |
+| `needsFix` | Changes were requested; the owner must update before re-approval |
+| `approved` | The review was accepted and the task is finalized |
+
## Agent review workflow
Teams can review each other's work before you make the final call. This catches obvious regressions and keeps the board honest, but you should still review risky areas yourself.
+## Review participants
+
+The team lead is the default reviewer. You can configure additional reviewers in the Kanban settings if you want peers to review each other's work.
+
## What to check manually
-Prioritize:
+Prioritize these areas when reviewing:
-- provider auth and runtime detection
-- IPC, preload, and filesystem boundaries
-- Git and worktree behavior
-- parsing and task lifecycle logic
-- persistence and code review flows
+- **Provider auth and runtime detection** — did the agent change runtime setup in a way that would break other paths?
+- **IPC, preload, and filesystem boundaries** — keep Electron responsibilities separated
+- **Git and worktree behavior** — verify branch naming, commits, and pushes
+- **Parsing and task lifecycle logic** — changes to task references, chunking, or filtering can break message delivery
+- **Persistence and code review flows** — changes to task storage or review state must stay consistent across IPC layers
## Verification
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.
+::: warning Do not auto-format across the whole project
+Unless the task is specifically about formatting, avoid running `pnpm lint:fix` on unrelated files. It creates noise in the review surface.
+:::
diff --git a/landing/product-docs/guide/create-team.md b/landing/product-docs/guide/create-team.md
index 85ffe963..838ad85f 100644
--- a/landing/product-docs/guide/create-team.md
+++ b/landing/product-docs/guide/create-team.md
@@ -6,14 +6,28 @@ A team is a named group of agents with roles, a lead, a target project, and a co
Start with a small team:
-| Role | Purpose |
-| --- | --- |
-| Lead | Splits work, creates tasks, coordinates teammates |
-| Builder | Implements scoped tasks |
+| Role | Purpose |
+| -------- | --------------------------------------------------- |
+| Lead | Splits work, creates tasks, coordinates teammates |
+| Builder | Implements scoped tasks |
| Reviewer | Reviews output, catches regressions, asks for fixes |
This shape gives you enough coordination to see the product value without making the first launch noisy.
+::: tip
+You can add more members later. Start small, validate the workflow, then scale up.
+:::
+
+## Assign providers and models
+
+Each team member runs on a provider backend. In the team editor, pick a provider (Claude, Codex, or OpenCode) and a model for every member. The app shows only providers you have already authenticated.
+
+Mixing providers in one team is supported — for example, a Claude lead with OpenCode builders.
+
+::: info
+Gemini support is in development and will appear in the provider list when available.
+:::
+
## Write a good team brief
The team brief should include:
@@ -30,10 +44,40 @@ Example:
Build a focused improvement to the download flow. Keep changes inside the landing app unless a shared helper is clearly needed. Create tasks before implementation, review each task diff, and run landing lint/build checks.
```
+## Worktree isolation
+
+OpenCode members can use **worktree isolation** to work in a separate Git worktree instead of the main working directory. This prevents file conflicts when multiple agents edit the same project.
+
+::: warning
+Worktree isolation requires a Git-tracked project and is currently limited to OpenCode members.
+:::
+
+To enable it, toggle the **Worktree isolation** option when adding or editing an OpenCode team member.
+
## Choose autonomy
Agent Teams supports different levels of control. Use more autonomy for routine changes and tighter review for risky areas like provider auth, IPC, persistence, Git workflows, and release tooling.
+### Effort level
+
+Each team member has an **effort** setting that controls how much reasoning the provider invests before responding. Higher effort produces more thorough output at the cost of time and tokens.
+
+| Level | When to use |
+| ------ | ---------------------------------------------------------- |
+| Low | Quick lookups, small formatting changes, routine edits |
+| Medium | Default for most implementation tasks |
+| High | Complex refactors, cross-cutting changes, risky code paths |
+
+The app offers additional levels (minimal, xhigh, max) for providers that support them. If a model does not support configurable effort, the selector is disabled and the provider default is used.
+
+### Fast mode
+
+Toggle **Fast mode** per member to prioritize speed over depth. This maps to the provider's native fast/speed mode when available. Set it to **On** for routine tasks, **Off** for careful work, or **Inherit** to follow the team-level default.
+
+### Limit context
+
+Enable **Limit context** to reduce the context window for a member. This is useful for Claude models that support extended context (e.g. 1M tokens) — limiting context avoids unnecessary token usage and can improve latency for tasks that do not need large context.
+
## Add context
Attach files, screenshots, or specific notes when they materially change the task. Agents can use task descriptions, comments, and attachments as durable context.
@@ -49,3 +93,8 @@ Good teams create tasks that are:
If the lead creates vague tasks, send a direct message asking for smaller, testable tasks.
+## Next steps
+
+- [Runtime setup](/guide/runtime-setup) — configure provider auth and models
+- [Code review](/guide/code-review) — accept, reject, or comment on agent changes
+- [Troubleshooting](/guide/troubleshooting) — common issues and fixes
diff --git a/landing/product-docs/guide/installation.md b/landing/product-docs/guide/installation.md
index 301f5ad9..8d536585 100644
--- a/landing/product-docs/guide/installation.md
+++ b/landing/product-docs/guide/installation.md
@@ -4,7 +4,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
## Download builds
-Use the latest GitHub release when you want the packaged app:
+Use the download page or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app:
- macOS Apple Silicon: `.dmg`
- macOS Intel: `.dmg`
@@ -17,29 +17,60 @@ Unsigned or newly published open-source apps can trigger SmartScreen. If you tru
## Requirements
-The packaged app is designed for zero-setup onboarding. It can guide runtime detection and provider authentication from the UI.
+The packaged app is designed for zero-setup onboarding. It guides you through runtime detection and provider authentication from the UI — no manual CLI configuration needed.
-For source development, use:
+To use agent runtimes, you need access to at least one provider:
-| Tool | Version |
-| --- | --- |
-| Node.js | 20+ |
-| pnpm | 10+ |
+| Provider | Access method |
+| ------------------ | ------------------------------------------------- |
+| Claude (Anthropic) | Claude Code CLI login or API key |
+| Codex (OpenAI) | Codex CLI login or API key |
+| Gemini (Google) | _In development_ |
+| OpenCode | API key for a supported backend (e.g. OpenRouter) |
+
+::: info
+Gemini provider support is in development. You can prepare access now, but it will not appear in the team editor until it is ready.
+:::
+
+For source development, you also need:
+
+| Tool | Version |
+| ------- | ------- |
+| Node.js | 20+ |
+| pnpm | 10+ |
## Run from source
-
+
```bash
-git clone https://github.com/777genius/claude_agent_teams_ui.git
-cd claude_agent_teams_ui
+git clone https://github.com/777genius/agent-teams-ai.git
+cd agent-teams-ai
pnpm install
pnpm dev
```
-If you want the freshest local version, use the repository branch that currently carries active development.
+The `main` branch carries the latest stable development. Switch to feature branches only if you need a specific unreleased change.
-## Updating
+## Auto-updates
-Use the latest release for packaged builds. If you run from source, pull the branch you use and rerun install when dependencies change.
+The packaged app checks for updates automatically on launch and periodically while running. When an update is available, the app prompts you to download and install it. You can also check manually from the app menu.
+::: tip
+Auto-updates are not available when running from source. Pull the latest changes and rerun `pnpm install` when dependencies change.
+:::
+
+## Updating from source
+
+If you run from source, pull the `main` branch and rerun install when dependencies change:
+
+```bash
+git pull
+pnpm install
+```
+
+## Next steps
+
+- [Quickstart](/guide/quickstart) — from install to first running team
+- [Runtime setup](/guide/runtime-setup) — provider auth and model selection per runtime
+- [Create a team](/guide/create-team) — recommended team shapes and brief writing
diff --git a/landing/product-docs/guide/quickstart.md b/landing/product-docs/guide/quickstart.md
index 119ade51..8d466f6a 100644
--- a/landing/product-docs/guide/quickstart.md
+++ b/landing/product-docs/guide/quickstart.md
@@ -1,33 +1,45 @@
# Quickstart
-This guide gets you from a fresh install to a running team.
+This guide gets you from a fresh install to a running team in a few minutes.
## 1. Install Agent Teams
-Download the latest release for your platform from the landing page or GitHub releases.
+Download the latest release for your platform from the download page or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
::: tip
-The app is free and open source. The agent runtime you choose may still require provider access, such as Claude, Codex, OpenCode, or API-key based providers.
+The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details.
:::
## 2. Open or create a project
Launch the app and select the project directory you want agents to work in. Agent Teams reads local project files and runtime/session state so the UI can show tasks, logs, diffs, and teammate activity.
+::: tip
+Pick a Git-tracked project for the best experience. Worktree isolation and diff-based review both rely on Git.
+:::
+
## 3. Choose a runtime path
-Use the setup flow to detect available runtimes. A common first setup is:
+The setup flow auto-detects installed runtimes on your machine. A common first setup is:
-| Runtime | Good for |
-| --- | --- |
-| Claude | Claude Code users and existing Anthropic access |
-| Codex | Codex-native workflows and OpenAI access |
-| OpenCode | Multimodel teams and many provider backends |
+| Runtime | Good for |
+| -------- | ----------------------------------------------- |
+| Claude | Claude Code users and existing Anthropic access |
+| Codex | Codex-native workflows and OpenAI access |
+| OpenCode | Multi-model teams and many provider backends |
+
+::: info
+Gemini support is in development and will appear in the runtime list when available.
+:::
+
+See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
## 4. Create your first team
Create a team with a lead and one or more specialists. Keep the first team small: one lead, one implementation agent, and one review-oriented agent is enough to validate the workflow.
+See [Create a team](/guide/create-team) for the recommended structure and tips.
+
## 5. Give the lead a concrete goal
Write the goal like you would brief an engineering lead:
@@ -36,15 +48,16 @@ Write the goal like you would brief an engineering lead:
Improve the onboarding flow. Split the work into tasks, keep changes small, and ask for review before broad refactors.
```
-The lead should create tasks, assign work, and coordinate teammates. You can watch progress on the kanban board and intervene with comments or direct messages.
+The lead creates tasks, assigns work, and coordinates teammates. You can watch progress on the kanban board and intervene with comments or direct messages at any time.
## 6. Review results
Open completed or review-ready tasks, inspect the diff, and accept, reject, or comment on individual changes. Use task logs when you need to understand why an agent made a choice.
+See [Code review](/guide/code-review) for the full review workflow.
+
## Next steps
-- [Create a team](/guide/create-team)
-- [Runtime setup](/guide/runtime-setup)
-- [Code review](/guide/code-review)
-
+- [Create a team](/guide/create-team) — recommended team shapes and brief writing
+- [Runtime setup](/guide/runtime-setup) — provider auth and model selection
+- [Code review](/guide/code-review) — review, approve, or request changes
diff --git a/landing/product-docs/guide/runtime-setup.md b/landing/product-docs/guide/runtime-setup.md
index b89edd0c..0d92a793 100644
--- a/landing/product-docs/guide/runtime-setup.md
+++ b/landing/product-docs/guide/runtime-setup.md
@@ -2,13 +2,25 @@
Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers.
+## Prerequisites
+
+Before launching a team, make sure:
+
+- The runtime binary is installed and on your `PATH`.
+- Your provider account has active access to the model you intend to use.
+- The project path exists and is readable.
+
+::: tip
+Start with a single teammate and one provider. Confirm one launch works before adding multimodel lanes.
+:::
+
## Supported paths
-| Path | Use when |
-| --- | --- |
-| Claude | You already use Claude Code or Anthropic-backed workflows |
-| Codex | You want Codex-native runtime integration |
-| OpenCode | You want multimodel routing and broad provider coverage |
+| Path | Default CLI | Typical providers | Use when |
+| --- | --- | --- | --- |
+| Claude | `claude` | Anthropic | You already use Claude Code or Anthropic-backed workflows |
+| Codex | `codex` | OpenAI | You want Codex-native runtime integration |
+| OpenCode | `opencode` | OpenRouter and many backends | You want multimodel routing and broad provider coverage |
The app detects supported runtimes and guides setup from the UI when possible.
@@ -16,18 +28,71 @@ The app detects supported runtimes and guides setup from the UI when possible.
Agent Teams has no paid tier of its own. You bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose.
+- **Claude** and **Codex** paths rely on their respective CLI auth tools.
+- **OpenCode** needs provider-specific API keys in a config file (e.g., `openrouter`, `openai`, `anthropic`).
+
+## Auth configuration
+
+### Claude Code
+
+Run the standard auth flow in a terminal:
+
+```bash
+claude login
+```
+
+Then verify the CLI is reachable:
+
+```bash
+claude --version
+```
+
+### Codex
+
+Install and authenticate via OpenAI's CLI flow:
+
+```bash
+codex login
+```
+
+### OpenCode
+
+Create or edit `~/.opencode/config.json` (or the equivalent path on your platform) with the provider key you want:
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "apiKey": "sk-or-..."
+ }
+ }
+}
+```
+
+Use the exact provider name that OpenCode expects. If you set a custom provider name, double-check it against the provider ID you use in the model string (for example `openrouter/moonshotai/kimi-k2.6` would use the `openrouter` block).
+
## Multimodel mode
Multimodel mode can route work through many provider backends via OpenCode-compatible configuration. Use it when you need provider flexibility or want teammates to use different model lanes.
-## Operational advice
+::: info Model lanes
+Each teammate can use a different `providerId` + `model` pair. In the team edit UI, expand member options to override the global defaults.
+:::
-- Keep the first runtime setup simple.
-- Confirm one team can launch before adding many providers.
-- Treat auth, provider model names, and runtime PATH issues as setup problems, not team-prompt problems.
-- If launch hangs, check the troubleshooting page before changing code.
+## Prelaunch checklist
+
+Before launching a team:
+
+1. The selected runtime is installed
+2. The runtime binary is in the environment `PATH`
+3. Provider auth is configured for the chosen backend
+4. The provider has access to the exact model string you specify
+5. The project path exists and is readable
## When to switch runtime paths
Switch when the current path is blocked by model availability, rate limits, provider capabilities, or team role needs. Keep the same project and team workflow, but validate one small task after switching.
+::: warning Treat setup errors as setup problems
+If auth fails, a model name is rejected, or the runtime binary cannot be found, fix the setup first. Do not change team prompts or project code to work around a runtime configuration issue.
+:::
diff --git a/landing/product-docs/guide/troubleshooting.md b/landing/product-docs/guide/troubleshooting.md
index efbb372b..8d2adfd7 100644
--- a/landing/product-docs/guide/troubleshooting.md
+++ b/landing/product-docs/guide/troubleshooting.md
@@ -1,40 +1,158 @@
# Troubleshooting
-Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, or provider limits.
+Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, and provider limits.
## Team does not launch
-Check:
+Check each item in order:
-- the selected runtime is installed or authenticated
-- the runtime is available in the environment PATH
-- the provider has access to the requested model
-- the project path exists and is readable
+1. **Runtime available** — the selected CLI (`claude`, `codex`, `opencode`) is installed
+2. **PATH reachable** — the binary is available in the environment `PATH`
+3. **Model access** — the provider has access to the requested model string (especially for OpenCode, exact provider/model names matter)
+4. **Project path** — the project directory exists and is readable
+5. **Network / VPN** — some providers drop traffic when a VPN is active
-If OpenCode shows `registered` but bootstrap is unconfirmed, inspect launch logs before changing team prompts.
+::: tip
+Run the runtime binary in a terminal to verify `PATH` and auth. Example: `claude --version` or `opencode --version`.
+:::
+
+### OpenCode: registered but bootstrap unconfirmed
+
+If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
+
+Look at the newest launch failure artifact:
+
+```bash
+~/.claude/teams//launch-failure-artifacts/latest.json
+```
+
+The manifest inside includes:
+
+- `classification` — why the launch was considered a failure
+- `bootstrapTransportBreadcrumb` — delivery path used
+- Member spawn statuses
+- Redacted logs and traces
+
+Also check the lane manifest:
+
+```bash
+jq '.lanes' ~/.claude/teams//.opencode-runtime/lanes.json
+jq '.activeRunId, .entries' ~/.claude/teams//.opencode-runtime/lanes//manifest.json
+```
+
+::: tip Do not guess from the UI
+Always correlate UI diagnostics with persisted files (`launch-state.json`, `bootstrap-journal.jsonl`) and runtime-specific evidence.
+:::
## Agent replies are missing
-Open task logs and teammate messages. Missing replies often come from runtime delivery, parsing, or task filtering issues. Do not assume the model ignored the message until logs confirm it.
+Open task logs and teammate messages. Missing replies often come from:
+
+- **Runtime delivery retry** — the agent may have answered, but the message was not delivered to the app. Check the delivery ledger.
+- **Parsing or filtering** — the agent output did not include expected markers or task references.
+- **Task attribution** — the work happened during the session but was not linked to the task because the correct task id was missing from the output.
+
+::: warning Do not assume silence means ignoring
+Do not assume the model ignored the message until logs confirm it.
+:::
## Tasks are not linked to changes
-Use task-specific logs and code review links. If a diff appears detached, check whether the task id or task reference was included in the agent output.
+Use task-specific logs and code review links. If a diff appears detached:
+
+- Check whether the task id or task reference was included in the agent output.
+- Verify the agent called `task_add_comment` before making edits.
+- Ensure the agent called `task_start` so the board knows work began.
+
+For OpenCode teammates, the authoritative proof that a session belongs to a task is in `opencode-sessions.json` and the lane manifest entry, not only the UI message stream.
## Rate limits
If a provider reports a known reset time, Agent Teams can nudge the lead to continue after cooldown. If reset time is unknown, wait or switch provider/runtime path.
+| Provider behavior | Suggested action |
+| --- | --- |
+| Known reset time displayed | Wait for cooldown and continue |
+| No reset time shown | Switch provider or runtime path |
+| Repeated 429s | Lower concurrency or use a different model lane |
+
+## CLI auth issues
+
+### `claude login` not persist
+
+If the CLI is authenticated in one terminal but the app says it is not, verify the auth is saved to the expected config path and that the app process sees the same `$HOME`.
+
+### OpenCode provider key rejected
+
+- Double-check the provider name in `config.json` matches the provider prefix in the model string
+- Ensure the key is not expired or revoked in the provider dashboard
+
+### Auth diagnostic log
+
+Each call to `CliInstallerService.getStatus()` appends one line to `claude-cli-auth-diag.ndjson` in the Electron log folder (usually `~/Library/Logs//` on macOS). If the file exceeds **512 KiB**, it is truncated to empty before the next write.
+
+Check this file if you see "Not logged in" or auth errors in the packaged app.
+
+## Lane bootstrap stuck
+
+For OpenCode secondary lanes:
+
+- A missing `inboxes/.json` is not automatically a bug. OpenCode lanes do not have to be primary-inbox-created before they start.
+- If the UI shows the team still launching while primary members are already usable, "all teammates joined" is waiting for secondary lanes.
+- If `Prepared communication channels for X/Y members` hangs, verify whether `Y` incorrectly includes secondary OpenCode members.
+
+### Lane manifest empty entries
+
+If the bridge says bootstrap succeeded but `manifest.json` shows `entries: []`, the issue is **evidence commit**, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
+
+## Common member states
+
+| State | Meaning |
+| --- | --- |
+| `confirmed_alive` + `bootstrapConfirmed` | Healthy and ready |
+| `registered` / `runtime_pending_bootstrap` | Process or lane exists, but bootstrap proof has not been committed yet |
+| `failed_to_start` + `runtime_process` | Process exists, but launch gate failed. Check diagnostics |
+| `failed_to_start` + `stale_metadata` | Saved pid/session is stale or dead |
+
+::: warning
+`member_briefing` by itself is NOT runtime evidence. For OpenCode, authoritative proof is committed runtime evidence such as `opencode-sessions.json` and the manifest entry.
+:::
+
+## Runtime debug mode
+
+For local debugging, you can force teammates to run in tmux panes:
+
+```bash
+# Launch from a terminal
+CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
+
+# Or add to custom CLI args
+--teammate-mode tmux
+```
+
+Use this to inspect interactive CLI behavior. Do not consider this fully equivalent to the process backend.
+
+## Safe cleanup
+
+When cleaning up stale processes:
+
+1. Identify the pid and confirm it belongs to the current team / lane.
+2. Stop only processes explicitly belonging to a smoke test or the launch you are debugging.
+3. **Do not kill** all OpenCode or shared host processes as a shortcut.
+
## When to collect evidence
-Collect:
+Before asking for help, collect:
-- task id
-- team name
-- runtime path
-- launch log excerpt
-- provider/model
-- exact time window
+- Task id (short or full)
+- Team name
+- Runtime path (`claude`, `codex`, or `opencode`)
+- Launch log excerpt (from `latest.json` or `bootstrap-journal.jsonl`)
+- Provider / model string
+- Exact time window when the issue occurred
-This is enough to debug most launch and task lifecycle issues.
+This data is usually enough to debug launch and task lifecycle issues.
+::: tip
+If the issue persists, open the team's persisted files under `~/.claude/teams//` and correlate UI diagnostics with the live process state before changing code.
+:::
diff --git a/landing/product-docs/reference/concepts.md b/landing/product-docs/reference/concepts.md
index 63477792..1788d04a 100644
--- a/landing/product-docs/reference/concepts.md
+++ b/landing/product-docs/reference/concepts.md
@@ -1,32 +1,75 @@
# Concepts
-This page defines the core terms used across Agent Teams.
+This page defines the core terms used across Agent Teams. Use it as the shared vocabulary for the app, task board, messages, and review flow.
## Team
-A team is a group of agents configured for a project. A team usually has a lead and one or more teammates with specialized roles.
+A team is a named group of agents attached to one project path. It has a lead, optional teammates, runtime/provider settings, prompts, inboxes, tasks, and local launch state.
## Lead
-The lead coordinates work. It should break goals into tasks, assign teammates, track blockers, and ask for review when needed.
+The lead is the coordinator for the team. It turns a user goal into tasks, assigns or redirects teammates, tracks blockers, asks for review, and keeps work moving through the board.
+
+Lead messages use a different delivery path from teammate messages: the app relays lead inbox entries into the lead runtime, while teammates read their own inbox files between turns.
+
+## Teammate
+
+A teammate is a non-lead agent in the team. Teammates usually own focused roles such as builder, reviewer, researcher, or tester. A teammate can receive direct messages, task assignments, task comments, and review requests.
## Task
-A task is the durable unit of work. It has status, description, comments, logs, attachments, and reviewable changes.
+A task is the durable unit of work. It has an id, status, owner, description, comments, logs, attachments, task references, and reviewable changes.
-## Solo mode
+Common task states are `todo`, `in_progress`, `done`, `review`, and `approved`. Internally the task file stores the work state, while review and approval placement can also use kanban overlay state.
-Solo mode runs a one-member team. It is useful for quick work, lower token usage, and validating a prompt before expanding to a full team.
+## Kanban
-## Cross-team communication
+Kanban is the board view for team work. It lets you scan tasks by state, open task details, inspect logs, review diffs, approve finished work, or request changes.
-Agents can message within and across teams. Use this when separate teams own related work and need to coordinate.
+## Inbox
-## Autonomy level
+An inbox is a local message file for a team participant. Agent Teams uses inboxes for user messages, lead messages, teammate messages, runtime delivery metadata, cross-team messages, and some system notifications.
-Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths.
+Messages are durable local records. Delivery still depends on the selected runtime being alive and able to process its next turn.
+
+## Agent Block
+
+An agent block is hidden, agent-only instruction text wrapped with `...`. The UI strips these blocks from normal human-facing display, but agents and runtime delivery can use them for coordination details.
+
+The current canonical marker is `info_for_agent`; older documents may still contain legacy agent block formats.
+
+## Context Phase
+
+A context phase is one segment of a session context timeline. Compaction starts a new phase, so token and context usage can be analyzed before and after the reset.
+
+Context tracking separates categories such as project instructions, mentioned files, tool output, thinking text, team coordination, and user messages. These numbers are diagnostics, not provider billing statements.
## Runtime
-A runtime is the local execution path that connects Agent Teams to a model/provider workflow, such as Claude, Codex, or OpenCode.
+A runtime is the local execution path that runs an agent turn. Supported runtime paths include Claude Code, Codex, and OpenCode.
+The runtime owns model execution behavior, auth details, tool execution semantics, rate limits, model availability, and some transcript/log formats.
+
+## Provider
+
+A provider is the model access path behind a runtime. Current provider ids include Anthropic, Codex, Gemini, and OpenCode. OpenCode can route to many model providers through its own configuration.
+
+Agent Teams orchestrates tasks and messages, but it does not replace provider authentication or provider policy.
+
+## Solo mode
+
+Solo mode runs a one-member team. It is useful for quick work, lower coordination overhead, and validating a prompt before expanding to a full team.
+
+## Cross-team communication
+
+Agents can message within and across teams. Use this when separate teams own related work and need to coordinate without collapsing everything into one large team.
+
+## Autonomy level
+
+Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths, persistence, provider auth, Git operations, and releases.
+
+## Review
+
+Review is the task-scoped acceptance flow. A task can move to review, receive comments or requested changes, and then move to approved when the result is accepted.
+
+Review is tied to local diffs and task history, so it works best when tasks stay narrow and agents mention the task they are working on.
diff --git a/landing/product-docs/reference/faq.md b/landing/product-docs/reference/faq.md
index c32c2722..dd85f710 100644
--- a/landing/product-docs/reference/faq.md
+++ b/landing/product-docs/reference/faq.md
@@ -4,26 +4,62 @@
Yes. The app is free and open source. Provider or runtime access may still cost money depending on what you use.
-## Do I need to install Claude or Codex first?
+## Does Agent Teams include model access?
+
+No. Agent Teams is the local orchestration and UI layer. Model access comes from the selected runtime/provider path, such as Claude Code, Codex, or OpenCode.
+
+## Which runtimes are supported?
+
+The supported runtime paths are Claude Code, Codex, and OpenCode. The app also tracks provider ids such as Anthropic, Codex, Gemini, and OpenCode when the runtime exposes them.
+
+## Do I need to install Claude Code or Codex first?
Not always. The app guides runtime detection and setup from the UI. Some paths still require external runtime auth.
+OpenCode setup is separate from Claude Code and Codex setup. If a launch fails, check runtime status and provider auth before changing the team prompt.
+
## Does it upload my code to Agent Teams servers?
No. Agent Teams is not a cloud code-sync service. Provider-backed model calls may receive prompt context depending on your selected runtime.
+## Where are team files stored?
+
+Team coordination data is stored locally under `~/.claude/teams//`, task files under `~/.claude/tasks//`, and project session data under `~/.claude/projects//` when available.
+
+## What can leave my machine?
+
+Prompt context, selected file contents, tool results, command output, task text, comments, and attachments can leave your machine through the runtime/provider path when an agent uses a provider-backed model. The exact behavior depends on the runtime and provider.
+
## Can agents talk to each other?
-Yes. Agents can message teammates, comment on tasks, and coordinate across teams.
+Yes. Agents can message teammates, comment on tasks, coordinate across teams, and use task references to keep conversations attached to work.
## Can I review code before accepting it?
Yes. The review flow is built around task-scoped diffs and hunk-level decisions.
+## What is an Agent Block?
+
+An Agent Block is hidden agent-only text wrapped in markers such as `...`. The app strips it from normal user-facing display but keeps it available for agent coordination.
+
## What is solo mode?
Solo mode is a one-agent team. It is useful for smaller tasks and lower coordination overhead.
+## Can different teammates use different providers?
+
+Yes, provider/model settings can be carried per team member when the selected runtime path supports them. OpenCode is the main path for broad multi-provider routing.
+
+## Why does a task show review or approved separately from done?
+
+The work state and review state are related but not identical. A task can be done from the agent's perspective, then move through review and approval in the kanban UI.
+
## What should I do when a launch hangs?
-Open troubleshooting, collect runtime logs, and verify provider auth before changing prompts.
+Open troubleshooting, collect launch diagnostics, check `~/.claude/teams//`, and verify runtime/provider auth before changing prompts.
+
+For OpenCode, check lane/session evidence before assuming a teammate is online but ignoring messages.
+
+## Why are logs different across runtimes?
+
+Claude Code, Codex, and OpenCode expose different transcript formats and runtime evidence. Agent Teams normalizes what it can, but log completeness and attribution can differ by runtime.
diff --git a/landing/product-docs/reference/privacy-local-data.md b/landing/product-docs/reference/privacy-local-data.md
index feacac2e..8772a9e4 100644
--- a/landing/product-docs/reference/privacy-local-data.md
+++ b/landing/product-docs/reference/privacy-local-data.md
@@ -1,30 +1,56 @@
# Privacy and Local Data
-Agent Teams is local-first, but the selected provider path still matters.
+Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models.
## What stays local
-The desktop app runs on your machine and reads local project/runtime data to power the UI:
+The desktop app runs on your machine and reads local project/runtime data to power the UI. Typical local data includes:
- project files
-- task metadata
+- team configuration and member metadata
+- task metadata, task comments, and task references
+- inbox messages
- runtime/session logs
+- launch state and bootstrap diagnostics
- review state
- local app settings
+Important local locations include:
+
+| Location | Purpose |
+| --- | --- |
+| `~/.claude/teams//` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state, and review-related team files. |
+| `~/.claude/tasks//` | Durable task JSON files for the team board. |
+| `~/.claude/projects//` | Claude/Codex-style project session files used for session history, context analysis, and transcript-backed UI. |
+
+Exact files can vary by runtime and app version. For launch debugging, the newest evidence is usually under the relevant `~/.claude/teams//` folder.
+
## What can leave your machine
-When an agent asks a provider-backed model to work, prompt context and tool results may be sent through that provider/runtime path. This depends on the runtime and provider you choose.
+Agent Teams itself is not a cloud code-sync service for your repository. It does not need to upload your whole project to an Agent Teams server to show the board, inbox, logs, or review UI.
+
+However, when an agent asks a provider-backed model to work, prompt context, selected file contents, task text, comments, tool results, command output, and other runtime-provided context may be sent through the selected runtime/provider path. What is sent depends on the runtime, model, tool calls, prompt, and provider configuration.
+
+Provider authentication, provider-side retention, training, logging, regional processing, and billing are governed by the provider/runtime you choose. Review those policies for sensitive projects.
+
+## What the app does not guarantee
+
+- It cannot guarantee that provider-backed model calls never receive private code.
+- It cannot override provider retention or billing policies.
+- It cannot make a remote provider behave like a fully local model.
+- It cannot protect secrets that an agent is instructed to paste into prompts, task comments, files, or commands.
+- It cannot make every runtime expose the same transcript or audit detail.
## Practical guidance
-- Do not attach secrets to tasks.
+- Do not attach secrets to tasks, comments, or direct messages.
- Review provider policies for sensitive projects.
- Use lower autonomy for risky repositories.
- Keep task scope narrow when working with private code.
- Prefer local evidence and logs when debugging.
+- Check generated prompts, task descriptions, and attached files before asking agents to work on confidential material.
+- Use provider/model paths that match your privacy requirements.
## Open source model
-The app itself is open source and free. You can inspect how local orchestration, task tracking, and review flows work in the repository.
-
+The app itself is open source and free. You can inspect how local orchestration, task tracking, inboxes, runtime diagnostics, and review flows work in the repository.
diff --git a/landing/product-docs/reference/providers-runtimes.md b/landing/product-docs/reference/providers-runtimes.md
index 5044ccda..c9a38e14 100644
--- a/landing/product-docs/reference/providers-runtimes.md
+++ b/landing/product-docs/reference/providers-runtimes.md
@@ -1,6 +1,6 @@
# Providers and Runtimes
-Agent Teams separates orchestration from model access.
+Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work.
## What the app provides
@@ -12,6 +12,8 @@ Agent Teams provides:
- task logs
- review UI
- local project integration
+- runtime detection and capability checks
+- local logs and diagnostics
## What the runtime provides
@@ -21,20 +23,52 @@ The runtime provides:
- provider authentication
- tool execution behavior
- model-specific rate limits and capabilities
+- runtime-specific transcripts and delivery evidence
-## Common choices
+## Supported runtime paths
-| Runtime | Notes |
+| Runtime path | Provider/model path | Best fit | Notes |
| --- | --- |
-| Claude | Good for Claude Code users and Anthropic access |
-| Codex | Good for Codex-native workflows and OpenAI access |
-| OpenCode | Good for multimodel routing and broad provider coverage |
+| Claude Code | Anthropic / Claude models | Claude Code users and Anthropic-backed workflows | Default local-first path for Claude teams. Requires the runtime and account access to be available locally. |
+| Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. |
+| OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. |
+
+## Provider ids
+
+The app currently recognizes these provider ids in team/runtime configuration:
+
+| Provider id | Display intent |
+| --- | --- |
+| `anthropic` | Anthropic / Claude Code path |
+| `codex` | Codex path |
+| `gemini` | Gemini provider path when exposed by the runtime |
+| `opencode` | OpenCode path, including OpenCode-managed provider routing |
+
+Do not read this table as a guarantee that every provider is authenticated, installed, or available for every model on every machine. The runtime status and capability checks are the source of truth for a given launch.
+
+## Multi-provider strategy
+
+Agent Teams keeps orchestration provider-aware but not provider-owned:
+
+- teams, tasks, inboxes, comments, review state, and launch diagnostics stay in local Agent Teams storage
+- each member can carry provider/model settings through team launch metadata
+- model availability, auth, rate limits, and tool behavior remain runtime/provider responsibilities
+- OpenCode is the broadest routing path when you want one team to use multiple provider/model lanes
## Provider costs
-Agent Teams is free. Provider usage is governed by the runtime/provider you select.
+Agent Teams is free and open source. Provider usage is governed by the runtime/provider you select: subscription limits, API keys, account auth, rate limits, and provider policies all remain external to the app.
## Capability checks
During setup, the app may perform access and capability checks. This helps detect missing runtime auth before a team launch fails halfway through provisioning.
+Capability checks can report that a provider exists but is not authenticated, that a model list is unavailable, that a runtime path is missing, or that a specific extension capability is unsupported. Treat those results as setup diagnostics, not task failures.
+
+## Limits to expect
+
+- Runtime support does not mean equal feature parity across Claude Code, Codex, and OpenCode.
+- Log and transcript coverage differs by runtime.
+- OpenCode lanes need stable lane/session evidence before the app can attribute runtime logs safely.
+- Provider model names and availability can change outside the app.
+- A team prompt cannot fix missing auth, missing PATH entries, provider outages, or exhausted rate limits.
diff --git a/landing/product-docs/ru/guide/agent-workflow.md b/landing/product-docs/ru/guide/agent-workflow.md
index d16c60cf..0f525cd8 100644
--- a/landing/product-docs/ru/guide/agent-workflow.md
+++ b/landing/product-docs/ru/guide/agent-workflow.md
@@ -2,34 +2,84 @@
Agent Teams делает работу агентов видимой через task state, messages, logs и reviewable code changes.
-## Lifecycle
+## Режимы
-| Этап | Что происходит |
-| --- | --- |
-| Provisioning | Приложение запускает команду и проверяет готовность runtime |
-| Planning | Lead создаёт задачи и назначает teammates |
-| In progress | Агенты работают параллельно и обновляют статус задач |
-| Review | Изменения проверяют агенты или вы |
-| Done | Принятая работа остаётся связанной с историей задачи |
+| Режим | Описание |
+|-------|----------|
+| Solo | Один teammate с самостоятельным управлением задачами |
+| Team | Несколько teammates, работающих параллельно и ревьюящих друг друга |
+
+Оба режима используют одну и ту же канбан-доску, логи задач и поверхность код-ревью.
+
+## Жизненный цикл задачи
+
+| Этап | Что происходит | Ответственный |
+|------|---------------|---------------|
+| Provisioning | Приложение запускает runtime, проверяет, что процесс жив, и ждёт подтверждения bootstrap | Приложение |
+| Planning | Lead создаёт задачи, назначает teammates и задаёт зависимости | Lead или пользователь |
+| In progress | Агенты работают параллельно и обновляют статус задач через board MCP tools | Teammates |
+| Review | Изменения проверяют агенты или вы перед финальным принятием | Team lead или пользователь |
+| Done | Принятая работа остаётся связанной с историей задачи и доступна для инспекции | Пользователь |
+
+### Planning → In progress
+
+Когда teammate берёт задачу, статус на доске меняется на `in_progress`. Агент создаёт task comment с планом работы и продолжает. Все нативные инструменты (read, bash, edit, write) попадают в task log.
+
+### In progress → Review
+
+Когда teammate завершает работу, он публикует result comment и помечает задачу `completed`. Lead затем решает — принять сразу или отправить на ревью.
+
+### Review → Done
+
+Если изменения в review surface выглядят приемлемо, approve the review. Задача финализируется и связывается со своим diff.
+
+::: warning Ревью с правками
+Если teammate попросили внести правки во время ревью, он должен добавить follow-up comment с исправлениями, после чего lead может approve.
+:::
## Канбан-доска
-Доска - основной рабочий экран. Через неё удобно смотреть работу, находить blocked tasks, открывать task detail, читать logs и ревьюить changes без ручного чтения session files.
+Доска — основной рабочий экран. Через неё удобно:
-## Messages и comments
+- Смотреть открытые, заблокированные и на ревью задачи
+- Открывать task detail и инспектировать runtime logs
+- Ревьюить изменения без чтения raw session files
+- Назначать или переназначать владельцев
-Direct messages подходят для перенаправления агента. Task comments лучше использовать, когда заметка относится к конкретной работе. Комментарии сохраняют контекст для review.
+::: tip
+Используйте quick action buttons на карточках для старта, завершения или запроса ревью, не открывая detail panel.
+:::
-## Task logs
+## Сообщения и комментарии
+
+| Канал | Когда использовать |
+|-------|-------------------|
+| Direct message | Перенаправить агента, задать быстрый вопрос |
+| Task comment | Заметки, относящиеся к конкретной задаче |
+
+Комментарии сохраняют контекст для последующего ревью и появляются в timeline задачи.
+
+::: tip Предпочитайте task comments
+Если заметка касается конкретной задачи, добавьте её как комментарий к задаче, а не как direct message. Это сохраняет историю, привязанную к работе.
+:::
+
+## Логи задач
Task-specific logs изолируют runtime output, actions и messages по одному assignment. Они помогают понять:
-- что агент запускал?
-- почему он изменил этот файл?
-- просил ли он помощи у teammate?
-- какая задача породила diff?
+- Что агент запускал?
+- Почему он изменил этот файл?
+- Просил ли он помощи у teammate?
+- Какая задача породила diff?
-## Live processes
+## Параллельные паттерны работы
-Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения.
+Teammates могут работать над независимыми задачами одновременно. Вы также можете создавать dependency links (`blocked-by`), чтобы одна задача ждала завершения другой. Следите за blocked lanes на доске и переназначайте владельцев, если один teammate простаивает, а другой перегружен.
+## Процессы в реальном времени
+
+Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения. Процессы остаются зарегистрированными, пока не будут явно остановлены или runtime не завершится.
+
+## Межкомандное взаимодействие
+
+Агенты могут отправлять сообщения другим командам, когда команды связаны. Используйте это для handoffs, shared libraries или проверки статуса между squad.
diff --git a/landing/product-docs/ru/guide/code-review.md b/landing/product-docs/ru/guide/code-review.md
index 0a94d37c..a49485f5 100644
--- a/landing/product-docs/ru/guide/code-review.md
+++ b/landing/product-docs/ru/guide/code-review.md
@@ -1,35 +1,63 @@
# Код-ревью
-Code review в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
+Код-ревью в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
-## Review surface
+## Поверхность ревью
-Через review UI можно:
+Для каждой завершённой задачи, затронувшей файлы, review UI позволяет:
-- смотреть changed files
-- принимать или отклонять отдельные hunks
-- оставлять comments
-- связывать diff с task logs и агентом
+- Смотреть changed files с контекстом до/после
+- Принимать или отклонять отдельные hunks
+- Оставлять inline comments
+- Связывать diff с описанием задачи и логами агента
-## Hunk-level decisions
+## Решения на уровне hunk
Принимайте маленькие правильные изменения и отклоняйте отдельные ошибки без удаления всей работы. Это полезно, когда агент в целом решил задачу, но переборщил в одном файле.
-## Agent review workflow
+::: tip Принимайте по частям
+Если diff в основном верен, сначала примите хорошие hunks и запросите правки только для проблемных частей. Это не даёт доске застопориться.
+:::
+
+## Инициирование ревью
+
+1. Откройте завершённую задачу
+2. Перейдите на вкладку **Changes**
+3. Если diff выглядит разумно, нажмите **Request Review**, чтобы переместить задачу в колонку review
+
+Во время ревью задача ещё не считается завершённой, поэтому другие teammates или lead могут всё ещё комментировать её.
+
+## Состояния ревью
+
+| Состояние | Значение |
+|-----------|---------|
+| `none` | Задача новая, в работе или завершена, но ещё не на ревью |
+| `review` | Задача активно на ревью |
+| `needsFix` | Запрошены правки; владелец должен обновить до повторного approve |
+| `approved` | Ревью принято, задача финализирована |
+
+## Рабочий процесс ревью агентами
Команды могут ревьюить работу друг друга до вашего финального решения. Это ловит очевидные регрессии, но risky areas всё равно стоит проверять вручную.
+## Участники ревью
+
+Team lead — ревьюер по умолчанию. Вы можете настроить дополнительных ревьюеров в настройках Kanban, если хотите, чтобы peers ревьюили работу друг друга.
+
## Что проверять вручную
-Приоритет:
+Приоритетные области при ревью:
-- provider auth и runtime detection
-- IPC, preload и filesystem boundaries
-- Git и worktree behavior
-- parsing и task lifecycle logic
-- persistence и code review flows
+- **Provider auth и runtime detection** — не сломает ли агент настройку runtime для других путей?
+- **IPC, preload и filesystem boundaries** — сохраняйте разделение ответственности Electron
+- **Git и worktree behavior** — проверяйте имена веток, коммиты и push
+- **Parsing и task lifecycle logic** — изменения в task references, chunking или filtering могут сломать доставку сообщений
+- **Persistence и code review flows** — изменения в хранении задач или review state должны оставаться консистентными через IPC layers
-## Verification
+## Верификация
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.
+::: warning Не запускайте автоформатирование по всему проекту
+Если задача не специфически про форматирование, избегайте `pnpm lint:fix` на несвязанных файлах. Это создаёт шум в review surface.
+:::
diff --git a/landing/product-docs/ru/guide/create-team.md b/landing/product-docs/ru/guide/create-team.md
index c99a382e..42448438 100644
--- a/landing/product-docs/ru/guide/create-team.md
+++ b/landing/product-docs/ru/guide/create-team.md
@@ -1,26 +1,40 @@
# Создание команды
-Команда - это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
+Команда — это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
## Первая команда
Начните с малого:
-| Роль | Задача |
-| --- | --- |
-| Lead | Делит работу, создаёт задачи, координирует teammates |
-| Builder | Реализует scoped tasks |
-| Reviewer | Проверяет результат, ловит регрессии, просит fixes |
+| Роль | Задача |
+| -------- | ---------------------------------------------------- |
+| Lead | Делит работу, создаёт задачи, координирует teammates |
+| Builder | Реализует scoped tasks |
+| Reviewer | Проверяет результат, ловит регрессии, просит fixes |
Такая форма даёт достаточно координации, но не создаёт лишний шум на первом запуске.
+::: tip
+Команду можно расширить позже. Начните с малого, проверьте workflow, затем масштабируйте.
+:::
+
+## Назначение провайдеров и моделей
+
+Каждый участник команды работает через провайдер-бэкенд. В редакторе команды выберите провайдер (Claude, Codex или OpenCode) и модель для каждого участника. Приложение показывает только провайдеров, которые вы уже авторизовали.
+
+Микс провайдеров в одной команде поддерживается — например, Claude lead с OpenCode builder-ами.
+
+::: info
+Поддержка Gemini в разработке и появится в списке провайдеров, когда будет готова.
+:::
+
## Хороший team brief
В brief стоит указать:
- нужный outcome
- важные files или feature areas
-- границы риска, например "не refactor unrelated modules"
+- границы риска, например «не refactor unrelated modules»
- ожидания по review
- verification commands, если они известны
@@ -30,9 +44,39 @@
Улучши download flow. Держи изменения внутри landing app, если shared helper явно не нужен. Создай задачи до реализации, проверь diff каждой задачи и запусти landing lint/build checks.
```
+## Изоляция через worktree
+
+Участники на OpenCode могут использовать **изоляцию через worktree** — работать в отдельном Git worktree вместо основного рабочего каталога. Это предотвращает конфликты файлов, когда несколько агентов редактируют один проект.
+
+::: warning
+Изоляция через worktree требует Git-репозиторий и пока доступна только для участников на OpenCode.
+:::
+
+Чтобы включить, переключите опцию **Worktree isolation** при добавлении или редактировании участника на OpenCode.
+
## Уровень автономности
-Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше - для provider auth, IPC, persistence, Git workflows и release tooling.
+Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше — для рискованных областей: provider auth, IPC, персистентность, Git-операции и release tooling.
+
+### Уровень усилия (effort)
+
+У каждого участника есть настройка **effort** — она определяет, сколько reasoning провайдер вкладывает перед ответом. Выше effort — тщательнее результат, но больше времени и токенов.
+
+| Уровень | Когда использовать |
+| ------- | ------------------------------------------------------------------------- |
+| Low | Быстрые запросы, мелкие правки форматирования, рутинные изменения |
+| Medium | По умолчанию для большинства задач по реализации |
+| High | Сложные рефакторинги, кросс-модульные изменения, рискованные участки кода |
+
+Приложение предлагает дополнительные уровни (minimal, xhigh, max) для провайдеров, которые их поддерживают. Если модель не поддерживает настройку effort, селектор отключён и используется значение по умолчанию провайдера.
+
+### Быстрый режим (Fast mode)
+
+Переключите **Fast mode** для отдельного участника, чтобы приоритизировать скорость над глубиной. Это использует нативный быстрый режим провайдера, когда он доступен. Установите **On** для рутинных задач, **Off** для аккуратной работы или **Inherit**, чтобы следовать командному значению по умолчанию.
+
+### Ограничение контекста (Limit context)
+
+Включите **Limit context**, чтобы уменьшить контекстное окно для участника. Это полезно для моделей Claude с расширенным контекстом (например, 1M токенов) — ограничение контекста избегает лишних токенов и улучшает задержку для задач, не требующих большого контекста.
## Контекст
@@ -49,3 +93,8 @@ Agent Teams поддерживает разные уровни контроля.
Если lead создаёт размытые задачи, напишите ему direct message и попросите сделать задачи меньше и проверяемее.
+## Дальше
+
+- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
+- [Код-ревью](/ru/guide/code-review) — принять, отклонить или прокомментировать изменения агентов
+- [Диагностика](/ru/guide/troubleshooting) — частые проблемы и решения
diff --git a/landing/product-docs/ru/guide/installation.md b/landing/product-docs/ru/guide/installation.md
index 5bf577c5..8a63d8b9 100644
--- a/landing/product-docs/ru/guide/installation.md
+++ b/landing/product-docs/ru/guide/installation.md
@@ -4,7 +4,7 @@ Agent Teams распространяется как desktop-приложение
## Готовые сборки
-Берите последний GitHub release:
+Скачайте приложение на странице загрузок или из последнего [GitHub release](https://github.com/777genius/agent-teams-ai/releases):
- macOS Apple Silicon: `.dmg`
- macOS Intel: `.dmg`
@@ -17,29 +17,60 @@ Agent Teams распространяется как desktop-приложение
## Требования
-Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication.
+Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication — ручная настройка CLI не нужна.
-Для запуска из исходников:
+Для работы агентных рантаймов нужен доступ хотя бы к одному провайдеру:
+
+| Провайдер | Способ доступа |
+| ------------------ | ---------------------------------------------------------- |
+| Claude (Anthropic) | Claude Code CLI login или API key |
+| Codex (OpenAI) | Codex CLI login или API key |
+| Gemini (Google) | _В разработке_ |
+| OpenCode | API key для поддерживаемого бэкенда (например, OpenRouter) |
+
+::: info
+Поддержка провайдера Gemini в разработке. Вы можете подготовить доступ сейчас, но он не появится в редакторе команды, пока не будет готов.
+:::
+
+Для запуска из исходников также нужны:
| Инструмент | Версия |
-| --- | --- |
-| Node.js | 20+ |
-| pnpm | 10+ |
+| ---------- | ------ |
+| Node.js | 20+ |
+| pnpm | 10+ |
## Запуск из исходников
-
+
```bash
-git clone https://github.com/777genius/claude_agent_teams_ui.git
-cd claude_agent_teams_ui
+git clone https://github.com/777genius/agent-teams-ai.git
+cd agent-teams-ai
pnpm install
pnpm dev
```
-Если нужна самая свежая локальная версия, используйте ветку репозитория, где сейчас идёт активная разработка.
+Ветка `main` содержит актуальную стабильную разработку. Переключайтесь на feature-ветки, только если нужна конкретная неопубликованная правка.
-## Обновления
+## Автообновления
-Для packaged builds берите последний release. Для запуска из исходников подтяните нужную ветку и повторите install, если поменялись зависимости.
+Пакетная сборка автоматически проверяет обновления при запуске и периодически во время работы. Когда обновление доступно, приложение предложит скачать и установить его. Проверить вручную можно через меню приложения.
+::: tip
+При запуске из исходников автообновления недоступны. Подтягивайте свежие изменения и запускайте `pnpm install`, если зависимости изменились.
+:::
+
+## Обновление из исходников
+
+Подтяните ветку `main` и повторите install, если поменялись зависимости:
+
+```bash
+git pull
+pnpm install
+```
+
+## Дальше
+
+- [Быстрый старт](/ru/guide/quickstart) — от установки до первой запущенной команды
+- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
+- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief
diff --git a/landing/product-docs/ru/guide/quickstart.md b/landing/product-docs/ru/guide/quickstart.md
index d1ac7dd1..6a289824 100644
--- a/landing/product-docs/ru/guide/quickstart.md
+++ b/landing/product-docs/ru/guide/quickstart.md
@@ -1,50 +1,63 @@
# Быстрый старт
-Этот гайд проводит от свежей установки до первой запущенной команды.
+Этот гайд проводит от свежей установки до первой запущенной команды за несколько минут.
## 1. Установите Agent Teams
-Скачайте последний релиз под вашу платформу на лендинге или в GitHub releases.
+Скачайте последний релиз под вашу платформу на странице загрузок или в [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
::: tip
-Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру, например Claude, Codex, OpenCode или API-key based providers.
+Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру — подробности в разделе [Установка](/ru/guide/installation).
:::
## 2. Откройте проект
-Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные project files и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
+Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные файлы проекта и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
-## 3. Выберите runtime path
+::: tip
+Выберите проект под Git — так вы получите лучший опыт. Изоляция через worktree и ревью по diff зависят от Git.
+:::
-Стандартные варианты:
+## 3. Выберите runtime
-| Runtime | Когда подходит |
-| --- | --- |
-| Claude | Если вы уже используете Claude Code или Anthropic access |
-| Codex | Для Codex-native workflows и OpenAI access |
-| OpenCode | Для multimodel teams и большого числа provider backends |
+Мастер настройки автоматически определит установленные рантаймы на вашей машине. Стандартные варианты:
+
+| Runtime | Когда подходит |
+| -------- | ------------------------------------------------------------------- |
+| Claude | Если вы уже используете Claude Code или у вас есть Anthropic access |
+| Codex | Для Codex-native workflows и OpenAI access |
+| OpenCode | Для multi-model команд и большого числа provider backends |
+
+::: info
+Поддержка Gemini в разработке и появится в списке рантаймов, когда будет готова.
+:::
+
+Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup).
## 4. Создайте первую команду
Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума.
+Рекомендованная структура и советы — в разделе [Создание команды](/ru/guide/create-team).
+
## 5. Дайте lead-агенту конкретную цель
Пишите задачу как инженерному лиду:
```text
-Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими refactor.
+Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими рефакторингами.
```
-Lead должен создать задачи, назначить работу и координировать teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages.
+Lead создаёт задачи, назначает работу и координирует teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages в любой момент.
## 6. Проверьте результат
Откройте задачи в review/done, посмотрите diff, примите или отклоните изменения. Если нужно понять мотивацию агента, откройте task logs.
+Полный процесс ревью — в разделе [Код-ревью](/ru/guide/code-review).
+
## Дальше
-- [Создание команды](/ru/guide/create-team)
-- [Настройка рантайма](/ru/guide/runtime-setup)
-- [Код-ревью](/ru/guide/code-review)
-
+- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief
+- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
+- [Код-ревью](/ru/guide/code-review) — ревью, одобрение и запрос правок
diff --git a/landing/product-docs/ru/guide/runtime-setup.md b/landing/product-docs/ru/guide/runtime-setup.md
index 5b3e5fa6..7729cf20 100644
--- a/landing/product-docs/ru/guide/runtime-setup.md
+++ b/landing/product-docs/ru/guide/runtime-setup.md
@@ -1,33 +1,98 @@
# Настройка рантайма
-Agent Teams - coordination layer. Model work выполняется через локальные runtimes и providers.
+Agent Teams — coordination layer. Model work выполняется через локальные runtimes и providers.
+
+## Предварительные требования
+
+Перед запуском команды убедитесь, что:
+
+- Runtime binary установлен и находится в `PATH`.
+- Ваш аккаунт провайдера имеет доступ к выбранной модели.
+- Путь к проекту существует и доступен для чтения.
+
+::: tip
+Начните с одного teammate и одного провайдера. Подтвердите запуск одной команды, прежде чем добавлять multimodel lanes.
+:::
## Поддерживаемые пути
-| Путь | Когда использовать |
-| --- | --- |
-| Claude | Если вы уже используете Claude Code или Anthropic access |
-| Codex | Для Codex-native workflows и OpenAI access |
-| OpenCode | Для multimodel routing и широкой provider coverage |
+| Путь | CLI по умолчанию | Типичные провайдеры | Когда использовать |
+|------|-------------------|---------------------|-------------------|
+| Claude | `claude` | Anthropic | Если вы уже используете Claude Code или Anthropic access |
+| Codex | `codex` | OpenAI | Для Codex-native workflows и OpenAI access |
+| OpenCode | `opencode` | OpenRouter и многие другие | Для multimodel routing и широкой provider coverage |
Приложение по возможности определяет доступные runtimes и ведёт настройку через UI.
-## Provider access
+## Доступ к провайдеру
-У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: subscription, local runtime auth или API keys в зависимости от выбранного пути.
+У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: подписка, локальная авторизация рантайма или API-ключи в зависимости от выбранного пути.
-## Multimodel mode
+- Для **Claude** и **Codex** используется auth соответствующего CLI.
+- Для **OpenCode** требуются provider-specific API keys в файле конфигурации (например, `openrouter`, `openai`, `anthropic`).
-Multimodel mode может направлять работу через разные provider backends в OpenCode-compatible конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
+## Настройка авторизации
-## Практические советы
+### Claude Code
-- Первый runtime setup держите простым.
-- Подтвердите запуск одной команды до добавления многих providers.
-- Auth, model names и PATH issues считайте setup-проблемами, а не проблемами team prompt.
-- Если запуск завис, сначала откройте диагностику.
+Запустите стандартный auth flow в терминале:
+
+```bash
+claude login
+```
+
+Затем проверьте, что CLI доступен:
+
+```bash
+claude --version
+```
+
+### Codex
+
+Установите и авторизуйтесь через CLI OpenAI:
+
+```bash
+codex login
+```
+
+### OpenCode
+
+Создайте или отредактируйте `~/.opencode/config.json` (или эквивалентный путь на вашей платформе):
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "apiKey": "sk-or-..."
+ }
+ }
+}
+```
+
+Используйте точное имя провайдера, которое ожидает OpenCode. Если вы используете кастомное имя, убедитесь, что оно совпадает с provider ID в строке модели (например, `openrouter/moonshotai/kimi-k2.6` использует блок `openrouter`).
+
+## Multimodel-режим
+
+Multimodel-режим может направлять работу через разные provider backends в OpenCode-совместимой конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
+
+::: info Model lanes
+Каждый teammate может использовать свою пару `providerId` + `model`. В UI редактирования команды разверните опции member, чтобы переопределить глобальные значения.
+:::
+
+## Чеклист перед запуском
+
+Перед запуском команды:
+
+1. Выбранный runtime установлен
+2. Binary runtime находится в environment `PATH`
+3. Auth провайдера настроен для выбранного backend
+4. Провайдер имеет доступ к точной строке модели
+5. Путь к проекту существует и доступен для чтения
## Когда менять runtime path
Меняйте путь, когда текущий упирается в availability модели, rate limits, provider capabilities или роли команды. После смены проверьте одну маленькую задачу.
+::: warning Считайте ошибки setup setup-проблемами
+Если auth падает, имя модели отклонено или binary runtime не найден — сначала исправьте настройку. Не меняйте team prompts или код проекта, чтобы обойти проблему конфигурации рантайма.
+:::
diff --git a/landing/product-docs/ru/guide/troubleshooting.md b/landing/product-docs/ru/guide/troubleshooting.md
index b5b3a6e1..61c9262b 100644
--- a/landing/product-docs/ru/guide/troubleshooting.md
+++ b/landing/product-docs/ru/guide/troubleshooting.md
@@ -1,40 +1,158 @@
# Диагностика
-Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits.
+Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing и provider limits.
## Команда не запускается
-Проверьте:
+Проверьте последовательно:
-- выбранный runtime установлен или авторизован
-- runtime доступен в environment PATH
-- у провайдера есть доступ к нужной модели
-- project path существует и читается
+1. **Runtime установлен** — выбранный CLI (`claude`, `codex`, `opencode`) установлен
+2. **Доступен в PATH** — бинарник доступен в переменной окружения `PATH`
+3. **Доступ к модели** — у провайдера есть доступ к запрошенной модели (особенно для OpenCode, важны точные имена провайдера и модели)
+4. **Путь к проекту** — директория проекта существует и доступна для чтения
+5. **Сеть / VPN** — некоторые провайдеры блокируют трафик при активном VPN
-Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала смотрите launch logs.
+::: tip
+Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`.
+:::
+
+### OpenCode: registered, но bootstrap не подтверждён
+
+Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала inspect artifacts, прежде чем менять team prompts.
+
+Посмотрите на последний artifact неудачного запуска:
+
+```bash
+~/.claude/teams//launch-failure-artifacts/latest.json
+```
+
+Манифест внутри включает:
+
+- `classification` — почему запуск считался неудачным
+- `bootstrapTransportBreadcrumb` — использованный путь доставки
+- Статусы старта участников
+- Редактированные логи и трейсы
+
+Также проверьте lane manifest:
+
+```bash
+jq '.lanes' ~/.claude/teams//.opencode-runtime/lanes.json
+jq '.activeRunId, .entries' ~/.claude/teams//.opencode-runtime/lanes//manifest.json
+```
+
+::: tip Не гадайте по UI
+Всегда сопоставляйте UI-диагностику с сохранёнными файлами (`launch-state.json`, `bootstrap-journal.jsonl`) и runtime-специфичными доказательствами.
+:::
## Не видны ответы агента
-Откройте task logs и teammate messages. Пропавшие replies часто связаны с runtime delivery, parsing или task filtering. Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
+Откройте task logs и teammate messages. Пропавшие replies часто связаны с:
+
+- **Runtime delivery retry** — агент мог ответить, но сообщение не доставлено в приложение. Проверьте delivery ledger.
+- **Parsing или filtering** — вывод агента не содержал ожидаемых маркеров или task references.
+- **Task attribution** — работа выполнялась в рамках сессии, но не была привязана к задаче, потому что в выводе отсутствовал корректный task id.
+
+::: warning Не считайте молчание игнорированием
+Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
+:::
## Changes не связаны с tasks
-Используйте task-specific logs и code review links. Если diff выглядит detached, проверьте, был ли task id или task reference в output агента.
+Используйте task-specific logs и code review links. Если diff выглядит detached:
+
+- Проверьте, был ли task id или task reference в output агента.
+- Убедитесь, что агент вызвал `task_add_comment` перед правками.
+- Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы.
+
+Для OpenCode teammates авторитетным доказательством принадлежности сессии к задаче служат `opencode-sessions.json` и запись в lane manifest, а не только UI message stream.
## Rate limits
-Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
+Если провайдер сообщает известное время сброса (reset time), Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
+
+| Поведение провайдера | Рекомендуемое действие |
+| --- | --- |
+| Отображается известное reset time | Дождитесь cooldown и продолжите |
+| Reset time не показан | Смените провайдера или runtime path |
+| Повторяющиеся 429 | Снизьте concurrency или используйте другую model lane |
+
+## Проблемы авторизации CLI
+
+### `claude login` не сохраняется
+
+Если CLI авторизован в одном терминале, но приложение говорит, что нет — проверьте, что auth сохранён по ожидаемому пути конфигурации, и что процесс приложения видит тот же `$HOME`.
+
+### OpenCode: ключ провайдера отклонён
+
+- Убедитесь, что имя провайдера в `config.json` совпадает с префиксом провайдера в строке модели
+- Проверьте, что ключ не просрочен и не отозван в dashboard провайдера
+
+### Диагностический лог авторизации
+
+Каждый вызов `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs//` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
+
+Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
+
+## Lane bootstrap stuck
+
+Для OpenCode secondary lanes:
+
+- Отсутствие `inboxes/.json` автоматически не является багом. OpenCode lanes не обязаны быть созданы через primary inbox перед стартом.
+- Если UI показывает, что команда всё ещё запускается, в то время как primary participants уже работоспособны, ожидание «all teammates joined» связано с secondary lanes.
+- Если зависает `Prepared communication channels for X/Y members`, проверьте, не включает ли `Y` некорректно secondary OpenCode members.
+
+### Lane manifest empty entries
+
+Если bridge сообщает, что bootstrap успешен, но `manifest.json` показывает `entries: []`, проблема в **evidence commit**, а не в поведении модели. Участник не должен считаться deliverable, пока `opencode-sessions.json` и запись в manifest не существуют.
+
+## Распространённые состояния member
+
+| Состояние | Значение |
+|-----------|---------|
+| `confirmed_alive` + `bootstrapConfirmed` | Здоров и готов к работе |
+| `registered` / `runtime_pending_bootstrap` | Процесс или lane существует, но bootstrap proof ещё не закоммичен |
+| `failed_to_start` + `runtime_process` | Процесс есть, но launch gate не прошёл. Смотрите diagnostics |
+| `failed_to_start` + `stale_metadata` | Сохранённый pid/session устарел или мёртв |
+
+::: warning
+`member_briefing` сам по себе НЕ является runtime evidence. Для OpenCode авторитетным доказательством служит committed runtime evidence, такая как `opencode-sessions.json` и запись в manifest.
+:::
+
+## Режим отладки рантайма
+
+Для локальной отладки можно принудительно запускать teammates в tmux-панелях:
+
+```bash
+# Запуск из терминала
+CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
+
+# Или добавьте в custom CLI args
+--teammate-mode tmux
+```
+
+Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend.
+
+## Безопасная очистка
+
+При очистке stale processes:
+
+1. Определите pid и убедитесь, что он принадлежит текущей команде / lane.
+2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch.
+3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut.
## Какие данные собрать
Соберите:
-- task id
+- task id (short или full)
- team name
-- runtime path
-- launch log excerpt
-- provider/model
+- runtime path (`claude`, `codex`, или `opencode`)
+- launch log excerpt (из `latest.json` или `bootstrap-journal.jsonl`)
+- provider / model
- точный time window
Этого обычно хватает для диагностики launch и task lifecycle issues.
+::: tip
+Если проблема не устраняется, откройте persisted files команды под `~/.claude/teams//` и сопоставьте UI diagnostics с live process state, прежде чем менять код.
+:::
diff --git a/landing/product-docs/ru/index.md b/landing/product-docs/ru/index.md
index 2143cde3..8ffa18e9 100644
--- a/landing/product-docs/ru/index.md
+++ b/landing/product-docs/ru/index.md
@@ -31,7 +31,7 @@ features:
link: /ru/guide/code-review
linkText: Ревью изменений
- icon: "04"
- title: Runtime-aware setup
+ title: Настройка рантайма
details: Используйте Claude, Codex, OpenCode или multimodel-провайдеры через доступ, который у вас уже есть.
link: /ru/guide/runtime-setup
linkText: Настроить рантаймы
diff --git a/landing/product-docs/ru/reference/concepts.md b/landing/product-docs/ru/reference/concepts.md
index 0ee1b06c..421cef40 100644
--- a/landing/product-docs/ru/reference/concepts.md
+++ b/landing/product-docs/ru/reference/concepts.md
@@ -1,32 +1,75 @@
# Концепции
-Основные термины Agent Teams.
+Основные термины Agent Teams. Эта страница задаёт общий словарь для приложения, доски задач, сообщений и review flow.
## Team
-Team - группа агентов, настроенная для проекта. Обычно есть lead и один или несколько teammates со специализированными ролями.
+Team - именованная группа агентов, привязанная к одному project path. У команды есть lead, опциональные teammates, настройки runtime/provider, prompts, inboxes, tasks и локальное состояние запуска.
## Lead
-Lead координирует работу: разбивает цель на tasks, назначает teammates, отслеживает blockers и просит review.
+Lead - координатор команды. Он превращает цель пользователя в tasks, назначает или перенаправляет teammates, отслеживает blockers, запрашивает review и двигает работу по board.
+
+Сообщения lead доставляются иначе, чем сообщения teammate: приложение ретранслирует записи inbox в lead runtime, а teammates читают свои inbox-файлы между turns.
+
+## Teammate
+
+Teammate - не-lead агент в команде. Обычно teammate отвечает за сфокусированную роль: builder, reviewer, researcher или tester. Teammate может получать direct messages, task assignments, task comments и review requests.
## Task
-Task - устойчивая единица работы. У неё есть status, description, comments, logs, attachments и reviewable changes.
+Task - долговечная единица работы. У неё есть id, status, owner, description, comments, logs, attachments, task references и reviewable changes.
-## Solo mode
+Типичные состояния task: `todo`, `in_progress`, `done`, `review`, `approved`. Файл task хранит рабочее состояние, а review/approval позиция может дополнительно храниться в kanban overlay state.
-Solo mode запускает команду из одного агента. Полезно для маленьких задач, меньшего token usage и проверки prompt перед расширением до команды.
+## Kanban
-## Cross-team communication
+Kanban - board view для командной работы. Он помогает смотреть tasks по состояниям, открывать детали, читать logs, ревьюить diffs, approve finished work или request changes.
-Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы.
+## Inbox
-## Autonomy level
+Inbox - локальный message-файл участника команды. Agent Teams использует inboxes для user messages, lead messages, teammate messages, runtime delivery metadata, cross-team messages и части system notifications.
-Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше автономности быстрее, меньше - безопаснее для sensitive code paths.
+Messages - долговечные локальные записи. Но доставка всё равно зависит от того, жив ли выбранный runtime и сможет ли он обработать следующий turn.
+
+## Agent Block
+
+Agent Block - скрытый agent-only instruction text, обёрнутый в `...`. UI убирает такие блоки из обычного human-facing display, но agents и runtime delivery могут использовать их для coordination details.
+
+Текущий canonical marker - `info_for_agent`; в старых документах могут встречаться legacy agent block formats.
+
+## Context Phase
+
+Context Phase - сегмент session context timeline. Compaction начинает новую phase, поэтому token/context usage можно анализировать до и после reset.
+
+Context tracking разделяет категории: project instructions, mentioned files, tool output, thinking text, team coordination и user messages. Эти числа нужны для диагностики, а не как provider billing statement.
## Runtime
-Runtime - локальный execution path, который соединяет Agent Teams с model/provider workflow, например Claude, Codex или OpenCode.
+Runtime - локальный execution path, который выполняет agent turn. Поддерживаемые runtime paths: Claude Code, Codex и OpenCode.
+Runtime отвечает за model execution behavior, auth details, tool execution semantics, rate limits, model availability и часть transcript/log formats.
+
+## Provider
+
+Provider - путь доступа к модели за runtime. Текущие provider ids: Anthropic, Codex, Gemini и OpenCode. OpenCode может маршрутизировать к множеству model providers через собственную конфигурацию.
+
+Agent Teams orchestrates tasks and messages, но не заменяет provider authentication или provider policy.
+
+## Solo mode
+
+Solo mode запускает команду из одного агента. Полезно для небольших задач, меньшего coordination overhead и проверки prompt перед расширением до команды.
+
+## Cross-team communication
+
+Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы, но их не хочется объединять в одну большую команду.
+
+## Autonomy level
+
+Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше autonomy быстрее, меньше - безопаснее для sensitive code paths, persistence, provider auth, Git operations и releases.
+
+## Review
+
+Review - task-scoped acceptance flow. Task может перейти в review, получить comments или requested changes, а затем перейти в approved, когда результат принят.
+
+Review привязан к local diffs и task history, поэтому лучше работает с узкими tasks и явным упоминанием task, над которой агент работает.
diff --git a/landing/product-docs/ru/reference/faq.md b/landing/product-docs/ru/reference/faq.md
index dcebc8b8..087ff69a 100644
--- a/landing/product-docs/ru/reference/faq.md
+++ b/landing/product-docs/ru/reference/faq.md
@@ -4,26 +4,62 @@
Да. Приложение бесплатное и open source. Provider или runtime access может стоить денег в зависимости от выбранного пути.
-## Нужно ли заранее ставить Claude или Codex?
+## Agent Teams включает доступ к моделям?
+
+Нет. Agent Teams - локальный orchestration и UI layer. Model access приходит через выбранный runtime/provider path, например Claude Code, Codex или OpenCode.
+
+## Какие runtimes поддерживаются?
+
+Поддерживаемые runtime paths: Claude Code, Codex и OpenCode. App также отслеживает provider ids вроде Anthropic, Codex, Gemini и OpenCode, когда runtime их отдаёт.
+
+## Нужно ли заранее ставить Claude Code или Codex?
Не всегда. Приложение ведёт runtime detection и setup через UI. Некоторые пути всё равно требуют внешнюю авторизацию runtime.
+OpenCode setup отделён от Claude Code и Codex setup. Если launch fails, сначала проверьте runtime status и provider auth, а не меняйте team prompt.
+
## Приложение загружает мой код на серверы Agent Teams?
Нет. Agent Teams не является cloud code-sync сервисом. Но provider-backed model calls могут получать prompt context в зависимости от выбранного runtime.
+## Где хранятся team files?
+
+Team coordination data хранится локально в `~/.claude/teams//`, task files - в `~/.claude/tasks//`, а project session data - в `~/.claude/projects//`, когда она доступна.
+
+## Что может выйти с моей машины?
+
+Prompt context, selected file contents, tool results, command output, task text, comments и attachments могут уйти через runtime/provider path, когда агент использует provider-backed model. Точное поведение зависит от runtime и provider.
+
## Агенты могут общаться друг с другом?
-Да. Агенты могут писать teammates, комментировать tasks и координироваться между teams.
+Да. Агенты могут писать teammates, комментировать tasks, координироваться между teams и использовать task references, чтобы разговор оставался привязанным к работе.
## Можно ревьюить код перед принятием?
Да. Review flow построен вокруг task-scoped diffs и hunk-level decisions.
+## Что такое Agent Block?
+
+Agent Block - скрытый agent-only text в маркерах вроде `...`. App убирает его из обычного user-facing display, но сохраняет для agent coordination.
+
## Что такое solo mode?
Solo mode - команда из одного агента. Подходит для небольших задач и меньшего coordination overhead.
+## Могут ли разные teammates использовать разных providers?
+
+Да, provider/model settings могут задаваться per team member, если выбранный runtime path это поддерживает. OpenCode - основной путь для широкой multi-provider routing.
+
+## Почему task может быть review или approved отдельно от done?
+
+Work state и review state связаны, но не идентичны. Task может быть done с точки зрения агента, а затем пройти review и approval в kanban UI.
+
## Что делать, если launch завис?
-Откройте диагностику, соберите runtime logs и проверьте provider auth до изменения prompts.
+Откройте troubleshooting, соберите launch diagnostics, проверьте `~/.claude/teams//` и runtime/provider auth до изменения prompts.
+
+Для OpenCode проверьте lane/session evidence, прежде чем считать, что teammate online, но игнорирует messages.
+
+## Почему logs отличаются между runtimes?
+
+Claude Code, Codex и OpenCode отдают разные transcript formats и runtime evidence. Agent Teams нормализует то, что может, но log completeness и attribution могут отличаться по runtime.
diff --git a/landing/product-docs/ru/reference/privacy-local-data.md b/landing/product-docs/ru/reference/privacy-local-data.md
index 52f0b9f5..a709d85b 100644
--- a/landing/product-docs/ru/reference/privacy-local-data.md
+++ b/landing/product-docs/ru/reference/privacy-local-data.md
@@ -1,30 +1,56 @@
# Приватность и локальные данные
-Agent Teams local-first, но выбранный provider path всё равно важен.
+Agent Teams local-first, но выбранный runtime/provider path всё равно важен. Эта страница описывает, что desktop app хранит локально и что может покинуть машину, когда agents вызывают provider-backed models.
## Что остаётся локально
-Desktop app работает на вашей машине и читает локальные project/runtime data для UI:
+Desktop app работает на вашей машине и читает local project/runtime data для UI. Обычно локально есть:
- project files
-- task metadata
+- team configuration и member metadata
+- task metadata, task comments и task references
+- inbox messages
- runtime/session logs
+- launch state и bootstrap diagnostics
- review state
- local app settings
+Важные local locations:
+
+| Location | Purpose |
+| --- | --- |
+| `~/.claude/teams//` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state и review-related team files. |
+| `~/.claude/tasks//` | Durable task JSON files для team board. |
+| `~/.claude/projects//` | Claude/Codex-style project session files для session history, context analysis и transcript-backed UI. |
+
+Точные файлы зависят от runtime и версии app. Для launch debugging самые свежие evidence обычно лежат в соответствующей папке `~/.claude/teams//`.
+
## Что может выйти с машины
-Когда агент обращается к provider-backed model, prompt context и tool results могут отправляться через выбранный provider/runtime path. Это зависит от runtime и provider.
+Agent Teams сам по себе не является cloud code-sync сервисом для репозитория. Ему не нужно загружать весь project на Agent Teams server, чтобы показывать board, inbox, logs или review UI.
+
+Но когда агент обращается к provider-backed model, prompt context, selected file contents, task text, comments, tool results, command output и другой runtime-provided context могут отправляться через выбранный runtime/provider path. Что именно отправится, зависит от runtime, model, tool calls, prompt и provider configuration.
+
+Provider authentication, provider-side retention, training, logging, regional processing и billing регулируются выбранным provider/runtime. Для sensitive projects проверяйте их policies.
+
+## Чего app не гарантирует
+
+- App не может гарантировать, что provider-backed model calls никогда не получат private code.
+- App не может переопределить provider retention или billing policies.
+- App не может сделать remote provider полностью local model.
+- App не защитит secrets, если агенту поручили вставить их в prompts, task comments, files или commands.
+- App не может заставить все runtimes отдавать одинаковый transcript или audit detail.
## Практические правила
-- Не прикладывайте secrets к tasks.
+- Не прикладывайте secrets к tasks, comments или direct messages.
- Проверяйте provider policies для sensitive projects.
- Используйте меньшую autonomy для risky repositories.
- Держите task scope узким при работе с private code.
- Для диагностики опирайтесь на local evidence и logs.
+- Проверяйте generated prompts, task descriptions и attached files перед работой с confidential material.
+- Выбирайте provider/model paths, которые соответствуют вашим privacy requirements.
## Open source
-Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking и review flows.
-
+Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking, inboxes, runtime diagnostics и review flows.
diff --git a/landing/product-docs/ru/reference/providers-runtimes.md b/landing/product-docs/ru/reference/providers-runtimes.md
index 31783d3e..c32f0f85 100644
--- a/landing/product-docs/ru/reference/providers-runtimes.md
+++ b/landing/product-docs/ru/reference/providers-runtimes.md
@@ -1,6 +1,6 @@
# Провайдеры и рантаймы
-Agent Teams отделяет orchestration от model access.
+Agent Teams отделяет orchestration от model access. Приложение управляет teams, tasks, messages, launch state и review UI; выбранный runtime/provider path выполняет реальную model work.
## Что даёт приложение
@@ -12,6 +12,8 @@ Agent Teams даёт:
- task logs
- review UI
- local project integration
+- runtime detection и capability checks
+- local logs и diagnostics
## Что даёт runtime
@@ -21,20 +23,52 @@ Runtime отвечает за:
- provider authentication
- tool execution behavior
- rate limits и capabilities конкретной модели
+- runtime-specific transcripts и delivery evidence
-## Частые варианты
+## Поддерживаемые runtime paths
-| Runtime | Заметки |
+| Runtime path | Provider/model path | Когда подходит | Заметки |
| --- | --- |
-| Claude | Хорошо для Claude Code users и Anthropic access |
-| Codex | Хорошо для Codex-native workflows и OpenAI access |
-| OpenCode | Хорошо для multimodel routing и широкой provider coverage |
+| Claude Code | Anthropic / Claude models | Для Claude Code users и Anthropic-backed workflows | Базовый local-first путь для Claude teams. Нужен локально доступный runtime и account access. |
+| Codex | Codex / OpenAI-backed models | Для Codex-native workflows | Использует Codex runtime integration и Codex auth/account state, когда они доступны. Часть diagnostics отличается от Claude transcripts. |
+| OpenCode | OpenCode-managed model routing | Для multi-provider teams и широкой model coverage | OpenCode может маршрутизировать через множество model providers. Agent Teams считает OpenCode lanes runtime-specific evidence и не угадывает attribution при ambiguous lane identity. |
+
+## Provider ids
+
+В team/runtime configuration приложение сейчас распознаёт такие provider ids:
+
+| Provider id | Смысл |
+| --- | --- |
+| `anthropic` | Anthropic / Claude Code path |
+| `codex` | Codex path |
+| `gemini` | Gemini provider path, когда его отдаёт runtime |
+| `opencode` | OpenCode path, включая OpenCode-managed provider routing |
+
+Эта таблица не гарантирует, что каждый provider authenticated, installed или доступен для каждой модели на каждой машине. Runtime status и capability checks - source of truth для конкретного launch.
+
+## Multi-provider strategy
+
+Agent Teams остаётся provider-aware, но не provider-owned:
+
+- teams, tasks, inboxes, comments, review state и launch diagnostics хранятся в local Agent Teams storage
+- каждый member может нести provider/model settings через team launch metadata
+- model availability, auth, rate limits и tool behavior остаются ответственностью runtime/provider
+- OpenCode - основной путь, когда одной team нужны разные provider/model lanes
## Стоимость providers
-Agent Teams бесплатен. Стоимость provider usage зависит от выбранного runtime/provider.
+Agent Teams бесплатен и open source. Provider usage зависит от выбранного runtime/provider: subscription limits, API keys, account auth, rate limits и provider policies остаются внешними для приложения.
## Capability checks
Во время setup приложение может выполнять access и capability checks. Это помогает найти отсутствующую авторизацию до того, как team launch застрянет в provisioning.
+Capability checks могут показать, что provider существует, но не authenticated; model list недоступен; runtime path отсутствует; или конкретная extension capability unsupported. Считайте это setup diagnostics, а не task failures.
+
+## Ожидаемые ограничения
+
+- Runtime support не означает одинаковый feature parity для Claude Code, Codex и OpenCode.
+- Log и transcript coverage отличаются по runtime.
+- Для OpenCode lanes нужна стабильная lane/session evidence, прежде чем app сможет безопасно attribute runtime logs.
+- Provider model names и availability могут меняться вне приложения.
+- Team prompt не исправит missing auth, missing PATH entries, provider outages или exhausted rate limits.
diff --git a/landing/server/routes/robots.txt.ts b/landing/server/routes/robots.txt.ts
index 35860311..1a36226b 100644
--- a/landing/server/routes/robots.txt.ts
+++ b/landing/server/routes/robots.txt.ts
@@ -1,6 +1,6 @@
export default defineEventHandler((event) => {
const config = useRuntimeConfig();
- const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
+ const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
setHeader(event, "content-type", "text/plain; charset=utf-8");
diff --git a/landing/server/routes/sitemap.xml.ts b/landing/server/routes/sitemap.xml.ts
index c359fe7c..1108094e 100644
--- a/landing/server/routes/sitemap.xml.ts
+++ b/landing/server/routes/sitemap.xml.ts
@@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0];
export default defineEventHandler((event) => {
const config = useRuntimeConfig();
- const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
+ const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
setHeader(event, "content-type", "application/xml; charset=utf-8");
diff --git a/package.json b/package.json
index 531038d2..213bc3dc 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "claude-agent-teams-ui",
+ "name": "agent-teams-ai",
"type": "module",
"version": "1.3.0",
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
@@ -8,13 +8,13 @@
"name": "Илия (777genius)",
"email": "quantjumppro@gmail.com"
},
- "homepage": "https://github.com/777genius/claude_agent_teams_ui",
+ "homepage": "https://github.com/777genius/agent-teams-ai",
"repository": {
"type": "git",
- "url": "https://github.com/777genius/claude_agent_teams_ui.git"
+ "url": "https://github.com/777genius/agent-teams-ai.git"
},
"bugs": {
- "url": "https://github.com/777genius/claude_agent_teams_ui/issues"
+ "url": "https://github.com/777genius/agent-teams-ai/issues"
},
"main": "dist-electron/main/index.cjs",
"scripts": {
@@ -27,6 +27,7 @@
"opencode:prove-semantic-model-matrix": "node ./scripts/prove-opencode-semantic-model-matrix.mjs",
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
"team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs",
+ "team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs",
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
@@ -155,6 +156,7 @@
"motion": "12.38.0",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
+ "pica": "9.0.1",
"pidusage": "4.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -270,7 +272,7 @@
"main": "dist-electron/main/index.cjs"
},
"mac": {
- "artifactName": "Claude.Agent.Teams.UI-${version}-${arch}-mac.${ext}",
+ "artifactName": "Agent.Teams.AI-${version}-${arch}-mac.${ext}",
"category": "public.app-category.developer-tools",
"minimumSystemVersion": "12.0",
"target": [
@@ -286,7 +288,7 @@
},
"dmg": {
"sign": false,
- "artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}"
+ "artifactName": "Agent.Teams.AI-${version}-${arch}.${ext}"
},
"win": {
"target": [
@@ -305,13 +307,13 @@
"category": "Development"
},
"appImage": {
- "artifactName": "Claude.Agent.Teams.UI-${version}.${ext}"
+ "artifactName": "Agent.Teams.AI-${version}.${ext}"
},
"deb": {
"afterInstall": "resources/afterInstall.sh"
},
"nsis": {
- "artifactName": "Claude.Agent.Teams.UI.Setup.${version}.${ext}",
+ "artifactName": "Agent.Teams.AI.Setup.${version}.${ext}",
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true
@@ -320,7 +322,7 @@
{
"provider": "github",
"owner": "777genius",
- "repo": "claude_agent_teams_ui",
+ "repo": "agent-teams-ai",
"releaseType": "release"
}
]
diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts
index b4fc758e..419c9158 100644
--- a/packages/agent-graph/src/hooks/useGraphSimulation.ts
+++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts
@@ -68,6 +68,7 @@ export interface UseGraphSimulationResult {
} | null;
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
getActivityWorldRect: (nodeId: string) => StableRect | null;
+ getLogWorldRect: (nodeId: string) => StableRect | null;
getExtraWorldBounds: () => WorldBounds[];
}
@@ -86,6 +87,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
const dragOwnerPositionsRef = useRef(new Map());
const launchAnchorPositionsRef = useRef(new Map());
const activityRectByNodeIdRef = useRef(new Map());
+ const logRectByNodeIdRef = useRef(new Map());
const extraWorldBoundsRef = useRef([]);
const prevNodeIdsRef = useRef(new Set());
@@ -112,6 +114,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
dragOwnerPositionsRef,
launchAnchorPositionsRef,
activityRectByNodeIdRef,
+ logRectByNodeIdRef,
extraWorldBoundsRef,
});
return;
@@ -132,6 +135,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
dragOwnerPositionsRef,
launchAnchorPositionsRef,
activityRectByNodeIdRef,
+ logRectByNodeIdRef,
extraWorldBoundsRef,
fillMissingFallbackPositions: true,
});
@@ -144,6 +148,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
layoutSnapshotRef,
launchAnchorPositionsRef,
activityRectByNodeIdRef,
+ logRectByNodeIdRef,
extraWorldBoundsRef,
});
}, []);
@@ -264,6 +269,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
dragOwnerPositionsRef.current.clear();
launchAnchorPositionsRef.current.clear();
activityRectByNodeIdRef.current.clear();
+ logRectByNodeIdRef.current.clear();
extraWorldBoundsRef.current = [];
layoutSnapshotRef.current = null;
lastValidSnapshotByTeamRef.current.clear();
@@ -283,6 +289,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
+ getLogWorldRect: (nodeId: string) => logRectByNodeIdRef.current.get(nodeId) ?? null,
getExtraWorldBounds: () => extraWorldBoundsRef.current,
}),
[
@@ -352,6 +359,7 @@ function commitSnapshotGeometry(args: {
dragOwnerPositionsRef: { current: ReadonlyMap };
launchAnchorPositionsRef: { current: Map };
activityRectByNodeIdRef: { current: Map };
+ logRectByNodeIdRef: { current: Map };
extraWorldBoundsRef: { current: WorldBounds[] };
fillMissingFallbackPositions?: boolean;
}): void {
@@ -364,6 +372,7 @@ function commitSnapshotGeometry(args: {
dragOwnerPositionsRef,
launchAnchorPositionsRef,
activityRectByNodeIdRef,
+ logRectByNodeIdRef,
extraWorldBoundsRef,
fillMissingFallbackPositions = false,
} = args;
@@ -377,10 +386,12 @@ function commitSnapshotGeometry(args: {
launchAnchorPositionsRef.current.clear();
activityRectByNodeIdRef.current.clear();
+ logRectByNodeIdRef.current.clear();
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
+ logRectByNodeIdRef.current.set(frame.ownerId, frame.logColumnRect);
}
if (snapshot.leadNodeId) {
@@ -388,6 +399,7 @@ function commitSnapshotGeometry(args: {
snapshot.leadNodeId,
snapshot.leadSlotFrame.activityColumnRect
);
+ logRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadSlotFrame.logColumnRect);
}
}
@@ -396,6 +408,7 @@ function resetToFallbackLayout(args: {
layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null };
launchAnchorPositionsRef: { current: Map };
activityRectByNodeIdRef: { current: Map };
+ logRectByNodeIdRef: { current: Map };
extraWorldBoundsRef: { current: WorldBounds[] };
}): void {
const {
@@ -403,12 +416,14 @@ function resetToFallbackLayout(args: {
layoutSnapshotRef,
launchAnchorPositionsRef,
activityRectByNodeIdRef,
+ logRectByNodeIdRef,
extraWorldBoundsRef,
} = args;
layoutSnapshotRef.current = null;
launchAnchorPositionsRef.current.clear();
activityRectByNodeIdRef.current.clear();
+ logRectByNodeIdRef.current.clear();
extraWorldBoundsRef.current = [];
fallbackPositionNodes(nodes);
KanbanLayoutEngine.layout(nodes);
diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts
index 0e7aa03f..2f93be0e 100644
--- a/packages/agent-graph/src/layout/stableSlots.ts
+++ b/packages/agent-graph/src/layout/stableSlots.ts
@@ -23,6 +23,8 @@ export interface OwnerFootprint {
radialDepth: number;
activityColumnWidth: number;
activityColumnHeight: number;
+ logColumnWidth: number;
+ logColumnHeight: number;
processBandWidth: number;
kanbanBandWidth: number;
kanbanBandHeight: number;
@@ -42,11 +44,14 @@ export interface SlotFrame {
ownerY: number;
boardBandRect: StableRect;
activityColumnRect: StableRect;
+ logColumnRect: StableRect;
processBandRect: StableRect;
kanbanBandRect: StableRect;
taskColumnCount: number;
}
+type OwnerSlotLayoutKind = 'radial-sector' | 'row-orbit' | 'grid-under-lead';
+
export interface StableSlotLayoutSnapshot {
version: GraphLayoutPort['version'];
teamName: string;
@@ -58,6 +63,7 @@ export interface StableSlotLayoutSnapshot {
launchAnchor: { x: number; y: number } | null;
leadCentralReservedBlock: StableRect;
runtimeCentralExclusion: StableRect;
+ ownerSlotLayoutKind: OwnerSlotLayoutKind;
centralCollisionRects: StableRect[];
memberSlotFrames: SlotFrame[];
memberSlotFrameByOwnerId: Map;
@@ -101,6 +107,20 @@ interface RingLayoutState {
type RingLayoutStateMap = ReadonlyMap;
+interface PlannedMemberSlotLayout {
+ frames: SlotFrame[];
+ kind: OwnerSlotLayoutKind;
+}
+
+interface RowOrbitSlotConfig {
+ footprint: OwnerFootprint;
+ assignment: GraphOwnerSlotAssignment;
+ rowIndex: number;
+ columnIndex: number;
+ columnCount: number;
+ band: 'top' | 'middle' | 'bottom';
+}
+
const SLOT_GEOMETRY = {
...STABLE_SLOT_GEOMETRY,
activityColumnHeight:
@@ -108,6 +128,11 @@ const SLOT_GEOMETRY = {
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight,
activityColumnWidth: ACTIVITY_LANE.width,
+ logColumnHeight:
+ ACTIVITY_LANE.headerHeight +
+ ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
+ ACTIVITY_LANE.overflowHeight,
+ logColumnWidth: 260,
ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap,
processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap,
boardColumnGap: 24,
@@ -120,11 +145,20 @@ const SLOT_GEOMETRY = {
const PROCESS_RAIL_NODE_GAP = 42;
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
const GEOMETRY_EPSILON = 0.001;
-const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
-const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
-const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
+const FEED_HEADER_BOTTOM_GAP = 4;
+const STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS = 96;
+const STRICT_SMALL_TEAM_RADIUS_EPSILON = 0.5;
+const STRICT_SMALL_TEAM_RADIUS_STEP = 24;
+const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2;
+const GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT = 3;
+const GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT = 6;
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
+const ROW_ORBIT_MIN_OWNER_COUNT = 6;
+const ROW_ORBIT_MAX_OWNER_COUNT = 12;
+const ROW_ORBIT_HORIZONTAL_GAP = Math.max(112, STABLE_SLOT_GEOMETRY.slotHorizontalGap);
+const ROW_ORBIT_VERTICAL_GAP = Math.max(144, GRID_UNDER_LEAD_ROW_GAP);
+const ROW_ORBIT_CENTRAL_GAP = 160;
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
@@ -150,14 +184,48 @@ const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } },
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } },
],
+ [
+ { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] },
+ { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] },
+ { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] },
+ { assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] },
+ { assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] },
+ ],
+ [
+ { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] },
+ { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] },
+ { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] },
+ { assignment: { ringIndex: 0, sectorIndex: 3 }, vector: SECTOR_VECTORS[3] },
+ { assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] },
+ { assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] },
+ ],
];
const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray> =
SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment));
-const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map(
- SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) =>
- layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
- )
+
+const ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT: Readonly> = {
+ 6: [3, 2, 3],
+ 7: [3, 2, 2],
+ 8: [3, 2, 3],
+ 9: [3, 2, 2, 2],
+ 10: [3, 2, 2, 3],
+ 11: [3, 3, 2, 3],
+ 12: [3, 3, 3, 3],
+};
+
+const ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT: Readonly<
+ Record
+> = Object.fromEntries(
+ Object.entries(ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT).map(([ownerCount, rowCounts]) => [
+ Number(ownerCount),
+ rowCounts.flatMap((columnCount, rowIndex) =>
+ Array.from({ length: columnCount }, (_, columnIndex) => ({
+ ringIndex: rowIndex,
+ sectorIndex: columnIndex,
+ }))
+ ),
+ ])
);
export function buildStableSlotLayoutSnapshot({
@@ -192,10 +260,14 @@ export function buildStableSlotLayoutSnapshot({
SLOT_GEOMETRY.centralPadding
);
- const memberSlotFrames =
+ const memberSlotLayout =
(layout?.mode ?? 'radial') === 'grid-under-lead'
- ? planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects)
+ ? {
+ frames: planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects),
+ kind: 'grid-under-lead' as const,
+ }
: planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout);
+ const memberSlotFrames = memberSlotLayout.frames;
const memberSlotFrameByOwnerId = new Map(
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
);
@@ -214,6 +286,7 @@ export function buildStableSlotLayoutSnapshot({
launchAnchor: null,
leadCentralReservedBlock,
runtimeCentralExclusion,
+ ownerSlotLayoutKind: memberSlotLayout.kind,
centralCollisionRects,
memberSlotFrames,
memberSlotFrameByOwnerId,
@@ -231,6 +304,7 @@ function buildCentralCollisionRects(args: {
args.leadCoreRect,
args.leadSlotFrame.processBandRect,
args.leadSlotFrame.activityColumnRect,
+ args.leadSlotFrame.logColumnRect,
args.leadSlotFrame.kanbanBandRect,
];
if (args.unassignedTaskRect) {
@@ -247,6 +321,7 @@ function buildLeadCentralReservedBlock(args: {
args.leadCoreRect,
args.leadSlotFrame.processBandRect,
args.leadSlotFrame.activityColumnRect,
+ args.leadSlotFrame.logColumnRect,
args.leadSlotFrame.kanbanBandRect,
]);
}
@@ -270,6 +345,7 @@ export function computeOwnerFootprints(
): OwnerFootprint[] {
const ownerNodes = nodes.filter((node) => node.kind === 'member');
const showActivity = layout?.showActivity ?? true;
+ const showLogs = layout?.showLogs ?? showActivity;
const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const));
const taskColumnsByOwnerId = new Map>();
const processCountByOwnerId = new Map();
@@ -304,6 +380,7 @@ export function computeOwnerFootprints(
taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0,
processCount: processCountByOwnerId.get(ownerId) ?? 0,
showActivity,
+ showLogs,
}),
];
});
@@ -331,6 +408,7 @@ function computeOwnerFootprintForOwnerId(
taskColumnCount: taskColumns.size,
processCount,
showActivity: layout?.showActivity ?? true,
+ showLogs: layout?.showLogs ?? layout?.showActivity ?? true,
});
}
@@ -339,17 +417,28 @@ function buildOwnerFootprint(args: {
taskColumnCount: number;
processCount: number;
showActivity: boolean;
+ showLogs: boolean;
}): OwnerFootprint {
const activityColumnWidth = args.showActivity ? SLOT_GEOMETRY.activityColumnWidth : 0;
const activityColumnHeight = args.showActivity ? SLOT_GEOMETRY.activityColumnHeight : 0;
- const activityToKanbanGap = args.showActivity ? SLOT_GEOMETRY.boardColumnGap : 0;
+ const logColumnWidth = args.showLogs ? SLOT_GEOMETRY.logColumnWidth : 0;
+ const logColumnHeight = args.showLogs ? SLOT_GEOMETRY.logColumnHeight : 0;
+ const activityToLogGap =
+ activityColumnWidth > 0 && logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
+ const feedToKanbanGap =
+ activityColumnWidth > 0 || logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
const kanbanBandWidth =
args.taskColumnCount <= 1
? TASK_PILL.width
: TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
const processBandWidth = computeProcessBandWidth(args.processCount);
- const boardBandWidth = activityColumnWidth + activityToKanbanGap + kanbanBandWidth;
- const boardBandHeight = Math.max(activityColumnHeight, SLOT_GEOMETRY.kanbanBandHeight);
+ const boardBandWidth =
+ activityColumnWidth + activityToLogGap + logColumnWidth + feedToKanbanGap + kanbanBandWidth;
+ const boardBandHeight = Math.max(
+ activityColumnHeight,
+ logColumnHeight,
+ SLOT_GEOMETRY.kanbanBandHeight + getKanbanBandTopInset({ activityColumnWidth, logColumnWidth })
+ );
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
const slotHeight =
@@ -377,6 +466,8 @@ function buildOwnerFootprint(args: {
radialDepth,
activityColumnWidth,
activityColumnHeight,
+ logColumnWidth,
+ logColumnHeight,
processBandWidth,
kanbanBandWidth,
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
@@ -430,6 +521,21 @@ export function resolveNearestSlotAssignment(args: {
return null;
}
+ if (args.snapshot.ownerSlotLayoutKind === 'row-orbit') {
+ const rowOrbitCandidate = resolveNearestRowOrbitSlotAssignment({
+ ownerId: args.ownerId,
+ ownerX: args.ownerX,
+ ownerY: args.ownerY,
+ currentFrame,
+ ownerFootprints: allFootprints,
+ snapshot: args.snapshot,
+ layout: args.layout,
+ });
+ if (rowOrbitCandidate) {
+ return rowOrbitCandidate;
+ }
+ }
+
const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({
ownerId: args.ownerId,
ownerX: args.ownerX,
@@ -541,11 +647,119 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
return null;
}
+ return resolveNearestExistingFrameSlotAssignment({
+ ownerId: args.ownerId,
+ ownerX: args.ownerX,
+ ownerY: args.ownerY,
+ currentFrame: args.currentFrame,
+ frames: strictFrames,
+ });
+}
+
+function resolveNearestRowOrbitSlotAssignment(args: {
+ ownerId: string;
+ ownerX: number;
+ ownerY: number;
+ currentFrame: SlotFrame;
+ ownerFootprints: readonly OwnerFootprint[];
+ snapshot: StableSlotLayoutSnapshot;
+ layout?: GraphLayoutPort;
+}): NearestSlotAssignmentResult | null {
+ const allowedAssignments = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[args.ownerFootprints.length];
+ if (!allowedAssignments || allowedAssignments.length < args.ownerFootprints.length) {
+ return null;
+ }
+
+ const baseAssignments = Object.fromEntries(
+ args.snapshot.memberSlotFrames.map((frame) => [
+ frame.ownerId,
+ {
+ ringIndex: frame.ringIndex,
+ sectorIndex: frame.sectorIndex,
+ },
+ ])
+ );
+ let best: RankedNearestSlotAssignmentResult | null = null;
+
+ for (const assignment of allowedAssignments) {
+ const occupiedFrame = args.snapshot.memberSlotFrames.find(
+ (frame) =>
+ frame.ownerId !== args.ownerId &&
+ frame.ringIndex === assignment.ringIndex &&
+ frame.sectorIndex === assignment.sectorIndex
+ );
+ const simulatedAssignments: Record = {
+ ...baseAssignments,
+ [args.ownerId]: assignment,
+ };
+ if (occupiedFrame) {
+ simulatedAssignments[occupiedFrame.ownerId] = {
+ ringIndex: args.currentFrame.ringIndex,
+ sectorIndex: args.currentFrame.sectorIndex,
+ };
+ }
+
+ const frames = planRowOrbitOwnerSlots(
+ args.ownerFootprints,
+ args.snapshot.centralCollisionRects,
+ args.snapshot.runtimeCentralExclusion,
+ {
+ version: args.layout?.version ?? 'stable-slots-v1',
+ mode: args.layout?.mode ?? 'radial',
+ ownerOrder:
+ args.layout?.ownerOrder ?? args.ownerFootprints.map((footprint) => footprint.ownerId),
+ slotAssignments: simulatedAssignments,
+ }
+ );
+ const previewFrame = frames?.find((frame) => frame.ownerId === args.ownerId);
+ if (!previewFrame) {
+ continue;
+ }
+
+ const dx = previewFrame.ownerX - args.ownerX;
+ const dy = previewFrame.ownerY - args.ownerY;
+ const candidate: RankedNearestSlotAssignmentResult = {
+ assignment,
+ displacedOwnerId: occupiedFrame?.ownerId,
+ displacedAssignment: occupiedFrame
+ ? {
+ ringIndex: args.currentFrame.ringIndex,
+ sectorIndex: args.currentFrame.sectorIndex,
+ }
+ : undefined,
+ previewOwnerX: previewFrame.ownerX,
+ previewOwnerY: previewFrame.ownerY,
+ distanceSquared: dx * dx + dy * dy,
+ };
+
+ if (!best || candidate.distanceSquared < best.distanceSquared) {
+ best = candidate;
+ }
+ }
+
+ return best
+ ? {
+ assignment: best.assignment,
+ displacedOwnerId: best.displacedOwnerId,
+ displacedAssignment: best.displacedAssignment,
+ previewOwnerX: best.previewOwnerX,
+ previewOwnerY: best.previewOwnerY,
+ }
+ : null;
+}
+
+function resolveNearestExistingFrameSlotAssignment(args: {
+ ownerId: string;
+ ownerX: number;
+ ownerY: number;
+ currentFrame: SlotFrame;
+ frames: readonly SlotFrame[];
+}): NearestSlotAssignmentResult | null {
let best: {
frame: SlotFrame;
distanceSquared: number;
} | null = null;
- for (const frame of strictFrames) {
+ for (const frame of args.frames) {
const dx = frame.ownerX - args.ownerX;
const dy = frame.ownerY - args.ownerY;
const distanceSquared = dx * dx + dy * dy;
@@ -586,7 +800,7 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
}
function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null {
- if (frames.length === 0 || frames.length > 4) {
+ if (frames.length === 0 || frames.length > 6) {
return null;
}
const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length];
@@ -651,6 +865,7 @@ function validateStaticSnapshotRects(
['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds],
['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect],
['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect],
+ ['leadSlotFrame.logColumnRect', snapshot.leadSlotFrame.logColumnRect],
['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect],
['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect],
['leadActivityRect', snapshot.leadActivityRect],
@@ -697,6 +912,9 @@ function validateLeadSnapshotRects(
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
}
+ if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.logColumnRect)) {
+ return { valid: false, reason: 'lead logColumnRect must fit inside leadCentralReservedBlock' };
+ }
if (
!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)
) {
@@ -795,6 +1013,9 @@ function validateSlotFrameGeometry(
if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) {
return { valid: false, reason: `activityColumnRect escapes ${label}` };
}
+ if (!rectContainsRect(frame.bounds, frame.logColumnRect)) {
+ return { valid: false, reason: `logColumnRect escapes ${label}` };
+ }
if (!rectContainsRect(frame.bounds, frame.processBandRect)) {
return { valid: false, reason: `processBandRect escapes ${label}` };
}
@@ -807,6 +1028,12 @@ function validateSlotFrameGeometry(
reason: `activityColumnRect escapes boardBandRect in ${label}`,
};
}
+ if (!rectContainsRect(frame.boardBandRect, frame.logColumnRect)) {
+ return {
+ valid: false,
+ reason: `logColumnRect escapes boardBandRect in ${label}`,
+ };
+ }
if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) {
return {
valid: false,
@@ -819,6 +1046,18 @@ function validateSlotFrameGeometry(
reason: `activityColumnRect overlaps kanbanBandRect in ${label}`,
};
}
+ if (rectsOverlap(frame.activityColumnRect, frame.logColumnRect)) {
+ return {
+ valid: false,
+ reason: `activityColumnRect overlaps logColumnRect in ${label}`,
+ };
+ }
+ if (rectsOverlap(frame.logColumnRect, frame.kanbanBandRect)) {
+ return {
+ valid: false,
+ reason: `logColumnRect overlaps kanbanBandRect in ${label}`,
+ };
+ }
if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) {
return { valid: false, reason: `owner anchor escapes ${label}` };
}
@@ -853,6 +1092,7 @@ export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): Sl
ownerY: frame.ownerY + dy,
boardBandRect: translateRect(frame.boardBandRect, dx, dy),
activityColumnRect: translateRect(frame.activityColumnRect, dx, dy),
+ logColumnRect: translateRect(frame.logColumnRect, dx, dy),
processBandRect: translateRect(frame.processBandRect, dx, dy),
kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy),
};
@@ -915,7 +1155,22 @@ function planOwnerSlots(
centralCollisionRects: readonly StableRect[],
runtimeCentralExclusion: StableRect,
layout?: GraphLayoutPort
-): SlotFrame[] {
+): PlannedMemberSlotLayout {
+ const rowOrbitFrames = shouldUseRowOrbitLayout(ownerFootprints, layout)
+ ? planRowOrbitOwnerSlots(
+ ownerFootprints,
+ centralCollisionRects,
+ runtimeCentralExclusion,
+ layout
+ )
+ : null;
+ if (rowOrbitFrames) {
+ return {
+ frames: rowOrbitFrames,
+ kind: 'row-orbit',
+ };
+ }
+
const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout)
? planStrictSmallTeamOwnerSlots(
ownerFootprints,
@@ -925,7 +1180,10 @@ function planOwnerSlots(
)
: null;
if (strictSmallTeamFrames) {
- return strictSmallTeamFrames;
+ return {
+ frames: strictSmallTeamFrames,
+ kind: 'radial-sector',
+ };
}
const placedFrames: SlotFrame[] = [];
@@ -949,7 +1207,354 @@ function planOwnerSlots(
commitRingPlacement(ringStates, resolvedFrame, footprint);
}
- return placedFrames;
+ return {
+ frames: placedFrames,
+ kind: 'radial-sector',
+ };
+}
+
+function shouldUseRowOrbitLayout(
+ ownerFootprints: readonly OwnerFootprint[],
+ layout?: GraphLayoutPort
+): boolean {
+ if (
+ ownerFootprints.length < ROW_ORBIT_MIN_OWNER_COUNT ||
+ ownerFootprints.length > ROW_ORBIT_MAX_OWNER_COUNT
+ ) {
+ return false;
+ }
+
+ const preset = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[ownerFootprints.length];
+ if (!preset || preset.length < ownerFootprints.length) {
+ return false;
+ }
+ const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length];
+ if (!rowCounts) {
+ return false;
+ }
+ const actualAssignments = ownerFootprints
+ .map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
+ .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null);
+ const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments(
+ ownerFootprints.length,
+ actualAssignments
+ );
+
+ const actualAssignmentKeys = actualAssignments
+ .map((assignment) =>
+ normalizeRowOrbitAssignment(assignment, ownerFootprints.length, rowCounts, {
+ useLegacySixTwoRowAssignments,
+ })
+ )
+ .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null)
+ .map((assignment) => buildAssignmentKey(assignment))
+ .sort();
+ const allowedAssignmentKeys = new Set(preset.map((assignment) => buildAssignmentKey(assignment)));
+
+ if (actualAssignmentKeys.length !== ownerFootprints.length) {
+ return false;
+ }
+
+ const uniqueAssignmentKeys = new Set(actualAssignmentKeys);
+ if (uniqueAssignmentKeys.size !== actualAssignmentKeys.length) {
+ return false;
+ }
+
+ for (const assignmentKey of actualAssignmentKeys) {
+ if (!allowedAssignmentKeys.has(assignmentKey)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function planRowOrbitOwnerSlots(
+ ownerFootprints: readonly OwnerFootprint[],
+ centralCollisionRects: readonly StableRect[],
+ runtimeCentralExclusion: StableRect,
+ layout?: GraphLayoutPort
+): SlotFrame[] | null {
+ const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length];
+ if (!rowCounts) {
+ return null;
+ }
+
+ const slotConfigs = buildRowOrbitSlotConfigs(ownerFootprints, rowCounts, layout);
+ if (!slotConfigs) {
+ return null;
+ }
+
+ const frames = buildRowOrbitSlotFrames(slotConfigs, rowCounts, runtimeCentralExclusion);
+ const allValid = frames.every((frame, frameIndex) =>
+ isSlotFramePlacementValid(
+ frame,
+ frames.filter((_, index) => index !== frameIndex),
+ centralCollisionRects
+ )
+ );
+ return allValid ? frames : null;
+}
+
+function buildRowOrbitSlotConfigs(
+ ownerFootprints: readonly OwnerFootprint[],
+ rowCounts: readonly number[],
+ layout?: GraphLayoutPort
+): RowOrbitSlotConfig[] | null {
+ const rowCount = rowCounts.length;
+ const middleRowIndex = rowCount === 3 ? 1 : -1;
+ const configs: RowOrbitSlotConfig[] = [];
+ const actualAssignments = ownerFootprints
+ .map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
+ .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null);
+ const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments(
+ ownerFootprints.length,
+ actualAssignments
+ );
+
+ for (const footprint of ownerFootprints) {
+ const assignment = layout?.slotAssignments?.[footprint.ownerId];
+ if (!assignment) {
+ return null;
+ }
+
+ const rowOrbitAssignment = normalizeRowOrbitAssignment(
+ assignment,
+ ownerFootprints.length,
+ rowCounts,
+ {
+ useLegacySixTwoRowAssignments,
+ }
+ );
+ if (!rowOrbitAssignment) {
+ return null;
+ }
+
+ const columnCount = rowCounts[rowOrbitAssignment.ringIndex];
+ if (
+ columnCount == null ||
+ rowOrbitAssignment.sectorIndex < 0 ||
+ rowOrbitAssignment.sectorIndex >= columnCount
+ ) {
+ return null;
+ }
+
+ configs.push({
+ footprint,
+ assignment: rowOrbitAssignment,
+ rowIndex: rowOrbitAssignment.ringIndex,
+ columnIndex: rowOrbitAssignment.sectorIndex,
+ columnCount,
+ band: resolveRowOrbitBand(rowOrbitAssignment.ringIndex, rowCount, middleRowIndex),
+ });
+ }
+
+ return configs;
+}
+
+function normalizeRowOrbitAssignment(
+ assignment: GraphOwnerSlotAssignment,
+ ownerCount: number,
+ rowCounts: readonly number[],
+ options: { useLegacySixTwoRowAssignments?: boolean } = {}
+): GraphOwnerSlotAssignment | null {
+ if (
+ options.useLegacySixTwoRowAssignments === true &&
+ ownerCount === 6 &&
+ assignment.ringIndex === 1 &&
+ assignment.sectorIndex >= 0 &&
+ assignment.sectorIndex < 3
+ ) {
+ return {
+ ringIndex: 2,
+ sectorIndex: assignment.sectorIndex,
+ };
+ }
+
+ const directColumnCount = rowCounts[assignment.ringIndex];
+ if (
+ directColumnCount != null &&
+ assignment.sectorIndex >= 0 &&
+ assignment.sectorIndex < directColumnCount
+ ) {
+ return assignment;
+ }
+
+ if (
+ ownerCount === 6 &&
+ assignment.ringIndex === 0 &&
+ assignment.sectorIndex >= 0 &&
+ assignment.sectorIndex < 6
+ ) {
+ return {
+ ringIndex: assignment.sectorIndex < 3 ? 0 : 2,
+ sectorIndex: assignment.sectorIndex % 3,
+ };
+ }
+
+ return null;
+}
+
+function shouldNormalizeLegacySixTwoRowAssignments(
+ ownerCount: number,
+ assignments: readonly GraphOwnerSlotAssignment[]
+): boolean {
+ if (ownerCount !== 6 || assignments.length !== ownerCount) {
+ return false;
+ }
+
+ return assignments.some(
+ (assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
+ );
+}
+
+function resolveRowOrbitBand(
+ rowIndex: number,
+ rowCount: number,
+ middleRowIndex: number
+): RowOrbitSlotConfig['band'] {
+ if (middleRowIndex >= 0) {
+ if (rowIndex < middleRowIndex) {
+ return 'top';
+ }
+ return rowIndex === middleRowIndex ? 'middle' : 'bottom';
+ }
+ return rowIndex < rowCount / 2 ? 'top' : 'bottom';
+}
+
+function buildRowOrbitSlotFrames(
+ slotConfigs: readonly RowOrbitSlotConfig[],
+ rowCounts: readonly number[],
+ runtimeCentralExclusion: StableRect
+): SlotFrame[] {
+ const rowConfigs = groupRowOrbitSlotConfigs(slotConfigs, rowCounts.length);
+ const middleRowIndex = rowCounts.length === 3 ? 1 : -1;
+ const rowTopByIndex = resolveRowOrbitRowTops(rowConfigs, middleRowIndex, runtimeCentralExclusion);
+ const framesByOwnerId = new Map