Merge remote-tracking branch 'origin/dev' into merge/dev-into-main-20260515

# Conflicts:
#	package.json
This commit is contained in:
777genius 2026-05-15 00:13:00 +03:00
commit 1376017aa9
253 changed files with 38100 additions and 1845 deletions

View file

@ -10,6 +10,7 @@ on:
- 'mcp-server/**'
- 'packages/**'
- 'resources/runtime/**'
- '.runtime-download/**'
- 'runtime.lock.json'
- 'test/**'
- '.github/workflows/**'
@ -29,6 +30,7 @@ on:
- 'mcp-server/**'
- 'packages/**'
- 'resources/runtime/**'
- '.runtime-download/**'
- 'runtime.lock.json'
- 'test/**'
- '.github/workflows/**'
@ -49,8 +51,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Guard runtime artifacts
run: node ./scripts/ci/forbid-runtime-artifacts.cjs
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v6
@ -59,7 +66,7 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Restore ESLint cache
uses: actions/cache@v5
@ -67,9 +74,6 @@ jobs:
path: .eslintcache
key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*', 'src/**/*.ts', 'src/**/*.tsx') }}
- name: Auto-fix import sort (Node version parity)
run: npx eslint src/ --fix --no-cache || true
- name: Validate workspace truth gate
run: pnpm check:ci
@ -81,7 +85,9 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v6
@ -90,7 +96,7 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Test
run: pnpm test:workspace:ci
@ -108,7 +114,9 @@ jobs:
run: git config --global core.longpaths true
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v6
@ -117,7 +125,7 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Test task change ledger
run: pnpm test:task-change-ledger

View file

@ -0,0 +1,68 @@
name: Codex Runtime Smoke
on:
workflow_dispatch:
pull_request:
paths:
- '.github/workflows/codex-runtime-smoke.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'scripts/smoke/codex-runtime-install.ts'
- 'src/features/codex-runtime-installer/**'
- 'src/main/services/infrastructure/codexAppServer/**'
- 'src/main/services/runtime/providerAwareCliEnv.ts'
- 'src/main/utils/childProcess.ts'
- 'src/main/utils/pathDecoder.ts'
- 'tsconfig*.json'
push:
branches: [main, dev]
paths:
- '.github/workflows/codex-runtime-smoke.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'scripts/smoke/codex-runtime-install.ts'
- 'src/features/codex-runtime-installer/**'
- 'src/main/services/infrastructure/codexAppServer/**'
- 'src/main/services/runtime/providerAwareCliEnv.ts'
- 'src/main/utils/childProcess.ts'
- 'src/main/utils/pathDecoder.ts'
- 'tsconfig*.json'
jobs:
install:
name: Install Codex runtime (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Enable Windows long paths
if: runner.os == 'Windows'
shell: pwsh
run: git config --global core.longpaths true
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Smoke Codex app-managed runtime install
run: pnpm smoke:codex-runtime-install

View file

@ -4,15 +4,15 @@ on:
push:
branches: [main]
paths: [landing/**]
pull_request:
paths: [landing/**]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
group: landing-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@ -24,6 +24,8 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: landing/package-lock.json
- name: Install dependencies
working-directory: landing
@ -37,18 +39,24 @@ jobs:
NUXT_PUBLIC_GITHUB_REPO: 777genius/agent-teams-ai
run: npm run generate:all
- uses: actions/configure-pages@v5
- uses: actions/configure-pages@v6
if: github.event_name != 'pull_request'
- uses: actions/upload-pages-artifact@v3
- uses: actions/upload-pages-artifact@v5
if: github.event_name != 'pull_request'
with:
path: landing/.output/public
deploy:
needs: build
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5

View file

@ -18,7 +18,9 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v6
@ -27,7 +29,7 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
@ -57,7 +59,7 @@ jobs:
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
- name: Upload dist artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: dist
path: |
@ -67,13 +69,13 @@ jobs:
prepare-runtime:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@ -84,7 +86,12 @@ jobs:
--generate-notes \
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
- name: Skip runtime asset preparation for manual builds
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "Runtime asset preparation is only needed for tagged releases."
- name: Check runtime assets
if: startsWith(github.ref, 'refs/tags/v')
id: runtime-assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -116,10 +123,10 @@ jobs:
--method POST \
"repos/${SOURCE_REPO}/actions/workflows/release-runtime.yml/dispatches" \
-f ref=main \
-f inputs[source_ref]="$SOURCE_REF" \
-f inputs[runtime_version]="$RUNTIME_VERSION" \
-f inputs[target_release_repo]="$GITHUB_REPOSITORY" \
-f inputs[target_release_tag]="$TARGET_TAG"
-f "inputs[source_ref]=$SOURCE_REF" \
-f "inputs[runtime_version]=$RUNTIME_VERSION" \
-f "inputs[target_release_repo]=$GITHUB_REPOSITORY" \
-f "inputs[target_release_tag]=$TARGET_TAG"
- name: Wait for runtime assets
if: steps.runtime-assets.outputs.missing == '1'
@ -175,7 +182,9 @@ jobs:
name: dist
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v6
@ -184,12 +193,12 @@ jobs:
cache: pnpm
- name: Setup Python for node-gyp
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
@ -256,6 +265,9 @@ jobs:
- name: Validate packaged bundle (macOS ${{ matrix.arch }})
run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin ${{ matrix.arch }}
- name: Smoke packaged app (macOS ${{ matrix.arch }})
run: node ./scripts/electron-builder/smokePackagedApp.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin
- name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v')
env:
@ -284,7 +296,9 @@ jobs:
name: dist
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v6
@ -293,12 +307,12 @@ jobs:
cache: pnpm
- name: Setup Python for node-gyp
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
@ -359,6 +373,10 @@ jobs:
shell: bash
run: node ./scripts/electron-builder/verifyBundle.cjs "release/win-unpacked" win32 x64
- name: Smoke packaged app (Windows)
shell: bash
run: node ./scripts/electron-builder/smokePackagedApp.cjs "release/win-unpacked" win32
- name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
@ -388,7 +406,9 @@ jobs:
name: dist
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v6
@ -397,17 +417,17 @@ jobs:
cache: pnpm
- name: Setup Python for node-gyp
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Linux packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y libarchive-tools rpm
sudo apt-get install -y libarchive-tools rpm xvfb
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
@ -463,6 +483,9 @@ jobs:
- name: Validate packaged bundle (Linux)
run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64
- name: Smoke packaged app (Linux)
run: xvfb-run -a node ./scripts/electron-builder/smokePackagedApp.cjs "release/linux-unpacked" linux
- name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v')
env:
@ -542,8 +565,8 @@ jobs:
# Canonical Windows feed
download_asset "Claude-Agent-Teams-UI-Setup.exe"
WIN_SHA="$(sha512_base64 Claude-Agent-Teams-UI-Setup.exe)"
WIN_SIZE="$(file_size Claude-Agent-Teams-UI-Setup.exe)"
WIN_SHA="$(sha512_base64 "Claude-Agent-Teams-UI-Setup.exe")"
WIN_SIZE="$(file_size "Claude-Agent-Teams-UI-Setup.exe")"
cat > latest.yml <<EOF
version: ${VERSION}
files:
@ -557,8 +580,8 @@ jobs:
# Canonical Linux feed
download_asset "Claude-Agent-Teams-UI.AppImage"
LINUX_SHA="$(sha512_base64 Claude-Agent-Teams-UI.AppImage)"
LINUX_SIZE="$(file_size Claude-Agent-Teams-UI.AppImage)"
LINUX_SHA="$(sha512_base64 "Claude-Agent-Teams-UI.AppImage")"
LINUX_SIZE="$(file_size "Claude-Agent-Teams-UI.AppImage")"
cat > latest-linux.yml <<EOF
version: ${VERSION}
files:
@ -576,10 +599,10 @@ jobs:
# until we switch to universal packaging or an arch-aware provider.
download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip"
download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg"
MAC_ZIP_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
MAC_ZIP_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
MAC_DMG_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64.dmg)"
MAC_DMG_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64.dmg)"
MAC_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
MAC_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
MAC_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64.dmg")"
MAC_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64.dmg")"
cat > latest-mac.yml <<EOF
version: ${VERSION}
files:

6
.gitignore vendored
View file

@ -9,6 +9,12 @@ out/
release/
coverage/
# Runtime release artifacts are downloaded during dev/release builds.
# Keep only the placeholder directory in git.
.runtime-download/
resources/runtime/*
!resources/runtime/.gitkeep
# IDE
.vscode/
.idea/

View file

@ -5,9 +5,15 @@ This file is a navigation layer for architecture and implementation guidance.
Start here:
- Repo overview and commands: [README.md](README.md)
- Working instructions and project conventions: [CLAUDE.md](CLAUDE.md)
- Hard guardrails: [AGENT_CRITICAL_GUARDRAILS.md](AGENT_CRITICAL_GUARDRAILS.md)
- Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
- Agent team launch/runtime debugging runbook: [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md)
Default local run target:
- Use the desktop Electron app: `pnpm dev`
- Do not start the browser/web dev mode for normal development or smoke checks. The browser path is limited and lacks the full desktop runtime, IPC, terminal, provider auth, and team lifecycle behavior.
- When documenting or recommending startup commands, point contributors to the desktop app unless a task explicitly asks for browser-mode internals.
For new features:
- Default home for medium and large features: `src/features/<feature-name>/`
- Reference implementation: `src/features/recent-projects`

View file

@ -4,6 +4,7 @@ These are the hard rules to keep agent work predictable and safe in this repo.
- Read `CLAUDE.md` first, then follow `docs/FEATURE_ARCHITECTURE_STANDARD.md` for new medium and large features.
- Use `pnpm` for project commands. Do not switch to `npm` or `yarn`.
- Use the desktop Electron app (`pnpm dev`) for normal local development and smoke checks unless browser-mode internals are explicitly requested.
- Do not run `pnpm lint:fix` unless the user explicitly asks for broad formatting changes.
- Keep main, preload, renderer, and shared responsibilities separate.
- Use `wrapAgentBlock(text)` instead of manually concatenating agent block markers.

View file

@ -16,9 +16,9 @@ Key capabilities:
- **MCP integration** — built-in mcp-server for external tools and agent plugins
- **Post-compact context recovery** — restores team-management instructions after context compaction
- **Notification system** — alerts on task completion, agent attention needed, errors
- **Zero-setup onboarding** — built-in Claude Code installation and authentication
- **Zero-setup onboarding** — built-in runtime detection and provider authentication for Claude, Codex, and OpenCode
100% free, open source. No API keys. No configuration. Runs entirely locally.
100% free, open source, and local-first. The app uses available Claude/Codex/OpenCode provider access instead of forcing a single app-level API-key setup.
## Tech Stack
Electron 40.x, React 19.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
@ -31,7 +31,7 @@ When running build/typecheck/test commands, pipe through `tail -20` to avoid flo
- Hard guardrails: [`AGENT_CRITICAL_GUARDRAILS.md`](AGENT_CRITICAL_GUARDRAILS.md)
- `pnpm install` - Install dependencies
- `pnpm dev` - Dev server with hot reload
- `pnpm dev` - Desktop Electron app with hot reload
- `pnpm build` - Production build
- `pnpm typecheck` - Type checking
- `pnpm lint:fix` - Lint and auto-fix
@ -108,8 +108,8 @@ Task tool_use blocks are filtered when subagent exists
Keep orphaned Task calls (no matching subagent) for visibility.
### Agent Teams
Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a team.
Official docs: https://code.claude.com/docs/en/agent-teams
Agent Teams is this app's orchestration layer across Claude, Codex, and OpenCode runtimes.
For Claude runtime behavior, also track Anthropic's upstream agent-team docs: https://code.claude.com/docs/en/agent-teams
#### Debugging Team Launches And Teammates
- Use [`docs/team-management/debugging-agent-teams.md`](docs/team-management/debugging-agent-teams.md) when a team launch hangs, a teammate remains `registered`, OpenCode shows `bootstrap unconfirmed`, messages are missing, or Task Log Stream looks wrong.
@ -119,12 +119,13 @@ Official docs: https://code.claude.com/docs/en/agent-teams
#### Message Delivery Architecture
- **Lead** reads ONLY stdin (stream-json). Messages to lead must go through `relayLeadInboxMessages()` which converts inbox entries to stdin.
- **Teammates** are independent CLI processes. Claude Code runtime monitors each teammate's inbox file and delivers messages between turns. No relay through lead needed.
- **User → Teammate DM**: UI writes to `inboxes/{member}.json` with `from: "user"`. Teammate reads it directly.
- **Teammate → User response**: Teammate writes to `inboxes/user.json`. UI reads all inbox files including `user.json` via `TeamInboxReader`.
- **`relayMemberInboxMessages` is DISABLED** for teammate DMs (commented out in `teams.ts` and `index.ts`). It caused bugs: lead responding instead of teammate, duplicate messages, relay loops. Code preserved but not called.
- **`relayLeadInboxMessages` is ACTIVE** — lead needs it because lead reads stdin, not inbox files.
- Messages in `user.json` may lack `messageId``TeamInboxReader` generates deterministic IDs via sha256(from+timestamp+text).
- **Native teammates** are independent CLI/process teammates. Claude/Codex-style teammates read their own inbox files between turns; no relay through the lead is needed.
- **OpenCode secondary lanes** do not watch teammate inbox files. User DMs are persisted to `inboxes/{member}.json`, then delivered through the OpenCode runtime bridge with explicit delivery proof.
- **User → Teammate DM**: UI writes to `inboxes/{member}.json` with `from: "user"`. Native teammates read it directly; OpenCode teammates receive it through runtime delivery.
- **Teammate → User response**: Teammate writes to `inboxes/user.json` or uses the runtime-specific Agent Teams message tool that persists there. UI reads all inbox files including `user.json` via `TeamInboxReader`.
- **`relayMemberInboxMessages` is legacy fallback code, not the normal teammate-DM path.** The UI caller in `src/main/ipc/teams.ts` is disabled because lead-mediated relay caused lead replies, duplicate messages, and relay loops.
- **`relayLeadInboxMessages` is ACTIVE** - lead needs it because lead reads stdin, not inbox files.
- Messages in `user.json` may lack `messageId` - `TeamInboxReader` generates deterministic IDs via sha256(from+timestamp+text).
- See `docs/team-management/research-messaging.md` for full architecture details.
#### Team Protocol Details

View file

@ -5,7 +5,7 @@
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>&nbsp;
<img src="resources/icons/png/1024x1024.png" alt="Agent Teams" width="80" />&nbsp;
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>&nbsp;
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.png" width="75" alt="Agent Comments" /></a>&nbsp;
<a href="docs/screenshots/3.png"><img src="docs/screenshots/3.png" width="75" alt="Agent Comments" /></a>&nbsp;
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>&nbsp;
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
</p>
@ -201,10 +201,12 @@ Fact sources checked on May 5, 2026: [detailed research notes](docs/research/gas
## Quick start
1. **Download** the app for your platform (see [Installation](#installation))
2. **Launch** — On first run, the setup wizard will detect the runtime and guide provider authentication
2. **Launch the desktop app** - On first run, the setup wizard will detect the runtime and guide provider authentication
3. **Create a team** — Pick a project, define roles, write a provisioning prompt
4. **Watch** — Agents spawn, create tasks, and work. You see it all on the kanban board
Use the desktop app as the primary product. The browser/web path is not needed for normal use and does not provide the full desktop runtime, IPC, terminal, provider auth, or team lifecycle behavior.
---
@ -274,7 +276,9 @@ pnpm install
pnpm dev
```
The app auto-discovers Claude Code projects from `~/.claude/`.
`pnpm dev` starts the desktop Electron app. Do not start a browser/web dev server for normal development; that path is limited and is not the supported way to run agent teams locally.
The desktop app auto-discovers Claude Code projects from `~/.claude/`.
### Debug teammate runtimes
@ -303,7 +307,7 @@ pnpm dist # macOS + Windows + Linux
| Command | Description |
|---------|-------------|
| `pnpm dev` | Development with hot reload |
| `pnpm dev` | Desktop app development with hot reload |
| `pnpm build` | Production build |
| `pnpm typecheck` | TypeScript type checking |
| `pnpm lint` | Lint (no auto-fix) |

View file

@ -1,134 +1,135 @@
# Team Management Feature
Интерфейс для управления командами тиммейтов Claude Code внутри Agent Teams (Electron).
UI for managing AI teammate teams inside Agent Teams (Electron), including Claude, Codex, and OpenCode runtime paths.
## Что делает
## What It Does
- Видеть состав команды и роли участников
- Kanban-доска с 5 колонками: TODO, IN PROGRESS, REVIEW, DONE, APPROVED
- Отправка сообщений тиммейтам через inbox-файлы
- Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE
- Live updates через file watcher
- Shows team members and their roles.
- Provides a Kanban board with 5 columns: TODO, IN PROGRESS, REVIEW, DONE, APPROVED.
- Sends messages to teammates through inbox files and runtime-aware delivery for OpenCode.
- Supports review flow: review requests, manual review, and direct manual approval from DONE.
- Provides live updates through the file watcher.
## Документация
## Documentation
| Файл | Содержание |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений |
| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ |
| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox |
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json |
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification |
| [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 |
| File | Contents |
| ---- | -------- |
| [research-inbox.md](./research-inbox.md) | Inbox file format, race conditions, atomic writes, message delivery |
| [research-tasks.md](./research-tasks.md) | Task file format, .lock, .highwatermark, concurrent access |
| [research-messaging.md](./research-messaging.md) | Comparison of inbox, SDK, and CLI approaches, and why inbox was chosen |
| [kanban-design.md](./kanban-design.md) | Kanban flow, columns, review mechanism, kanban-state.json |
| [implementation.md](./implementation.md) | Technical plan: files, steps, verification |
| [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, launching Claude processes from the UI (Phase 2) |
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Detailed rollout plan for queue/inventory split, derived actionOwner, and phased agenda/delta sync |
| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, including `CLAUDE_TEAM_TEAMMATE_MODE=tmux` for pane-backed teammate debug |
| [adaptive-task-graphs-research-note.md](./adaptive-task-graphs-research-note.md) | Research note on LATTE/AgentConductor: dynamic task graphs, frontier scheduling, selective verify, release stragglers |
## Ключевые решения
## Key Decisions
⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md).
Warning: `docs/iterations/*` contains historical planning notes. These files are useful for context, but they are not the source of truth for current product behavior. The current review-flow contract is documented here and in [kanban-design.md](./kanban-design.md).
⚠️ `agent-attachments-*.md` (architecture plan + phase 1-5 plans) - это исторические дизайн-документы для feature attachments. Фактическая реализация в `src/features/agent-attachments/` может отличаться от описанной архитектуры. Для актуального состояния см. код в `src/features/agent-attachments/core/domain/` и тесты.
Warning: `agent-attachments-*.md` files (architecture plan + phase 1-5 plans) are historical design documents for feature attachments. The actual implementation in `src/features/agent-attachments/` may differ from that architecture. For current behavior, see the code in `src/features/agent-attachments/core/domain/` and the tests.
### 1. Messaging: Inbox-файлы
### 1. Messaging: Inbox + Runtime Delivery
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
For native Claude/Codex-style teammates, the primary path is durable inbox files. Lead inbox delivery uses `relayLeadInboxMessages()` because the lead reads stdin. OpenCode secondary lanes do not read `inboxes/{member}.json` directly, so the UI first persists the message to the inbox and then delivers it through the runtime bridge with delivery proof. Details: [research-messaging.md](./research-messaging.md) and [debugging-agent-teams.md](./debugging-agent-teams.md).
### 1.1 Roster source: members.meta.json + inboxes
### 1.1 Roster Source: members.meta.json + inboxes
- `config.json` не используется как полный реестр участников (он может содержать только team-lead и служебные поля CLI).
- Источник метаданных участников (role/color/agentType): `members.meta.json`.
- Источник runtime-состава и адресации сообщений: `inboxes/{member}.json`.
- `config.json` is not used as the complete member registry. It may contain only the team lead and CLI service fields.
- Member metadata source (role/color/agentType): `members.meta.json`.
- Runtime membership and message-addressing source: `inboxes/{member}.json`.
### 2. Kanban Storage: Собственный файл
### 2. Kanban Storage: Dedicated File
Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`, а не в task metadata. Причина: metadata может быть перезаписан агентом при TaskUpdate. Подробности: [kanban-design.md](./kanban-design.md)
Kanban position (REVIEW, APPROVED) is stored in `kanban-state.json`, not task metadata. Reason: task metadata may be overwritten by an agent during TaskUpdate. Details: [kanban-design.md](./kanban-design.md).
### 3. Review Flow: Approve / Request Changes
- Есть ревьюверы в команде → автоматическое назначение через inbox
- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW`
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix`
- Reviewers exist in the team -> automatic assignment through inbox.
- The user can also manually approve a task directly from `DONE` without entering `REVIEW`.
- No reviewers -> manual user review (Approve / Request Changes in the UI).
- Request Changes -> the user optionally describes the issue -> the task returns to its owner in `pending` with `needsFix`.
### 4. Atomic Write
Все записи через tmp + rename для предотвращения corrupted JSON.
All writes use tmp + rename to prevent corrupted JSON.
### 5. Sender Identity
Отправляем `from: "user"`. Fallback на `from: "team-lead"` если не работает.
Messages are sent with `from: "user"`. Fallback to `from: "team-lead"` exists only if needed.
## Финальные решения после ревью
## Final Decisions After Review
По итогам 3 раундов ревью (13 экспертов) приняты следующие решения:
After 3 review rounds with 13 experts, the following decisions were accepted.
### Inbox: Atomic write + messageId verify
### Inbox: Atomic Write + messageId Verify
- Atomic write (tmp + rename) предотвращает corrupted JSON
- После записи читаем файл обратно и проверяем наличие нашего `messageId`
- Полный CAS/retry-цикл — не нужен на MVP: проверка при следующем read достаточна
- Риск race condition с агентом реален, но вероятность низкая
- Atomic write (tmp + rename) prevents corrupted JSON.
- After writing, read the file back and verify that our `messageId` is present.
- A full CAS/retry loop is not needed for MVP. Verification on the next read is enough.
- Race condition risk with an agent is real, but probability is low.
### Kanban: kanban-state.json с безопасным GC
### Kanban: kanban-state.json With Safe GC
- GC устаревших записей kanban-state выполняется ТОЛЬКО ПОСЛЕ полной загрузки tasks
- Иначе при startup возможна race condition: GC удаляет запись до того как task-файл прочитан
- Stale `kanban-state` entries are garbage-collected only after all tasks are fully loaded.
- Otherwise, startup can race: GC may delete an entry before the task file has been read.
### Review Flow: Approve / Request Changes
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
- Комментарий при Request Changes — опционален
- Manual UI допускает два valid path:
- Buttons were renamed: **Approve** instead of OK, and **Request Changes** instead of Error.
- Request Changes comment is optional.
- Manual UI allows two valid paths:
- `DONE -> REVIEW -> APPROVED`
- `DONE -> APPROVED` как быстрый manual approval
- `Request Changes` снимает kanban-state запись и возвращает задачу в `pending` с `needsFix`
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP
- `DONE -> APPROVED` as fast manual approval
- `Request Changes` removes the kanban-state entry and returns the task to `pending` with `needsFix`.
- `reviewHistory` and round-robin balancing are Phase 2, not MVP.
### Members: полный список через union
### Members: Complete List Through Union
- `union(config members + inbox filenames + task owners)` — единственный способ получить полный список
- `owner` в task-файлах — опционален (агент может не иметь owner до назначения)
- `union(members.meta.json + config members + inbox filenames + task owners)` is the only way to get the complete member list.
- `owner` in task files is optional. An agent may not have an owner before assignment.
### Graceful Degradation
- `try/catch` везде в TeamDataService — при ошибке чтения возвращаем безопасные дефолты
- 3 состояния участника: `ACTIVE` / `IDLE` / `TERMINATED`
- `ACTIVE`: idle < 5 минут
- `IDLE`: idle > 5 минут
- `TERMINATED`: получен `shutdown_response` с `approve: true`
- `try/catch` is used throughout `TeamDataService`; read errors return safe defaults.
- Member has 3 states: `ACTIVE` / `IDLE` / `TERMINATED`.
- `ACTIVE`: idle < 5 minutes
- `IDLE`: idle > 5 minutes
- `TERMINATED`: received `shutdown_response` with `approve: true`
### @dnd-kit and review transitions
### @dnd-kit and Review Transitions
- Переходы между review-колонками делаются через card actions в UI
- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки
- Phase 2: полноценный D&D через `@dnd-kit`
- Transitions between review columns happen through card actions in the UI.
- `@dnd-kit` is currently used primarily for reordering tasks inside a column.
- Phase 2: full drag-and-drop through `@dnd-kit`.
---
## Открытые вопросы
## Open Questions
- **FileWatcher расширение**: FileWatcher.ts уже 1243 строк — добавление teams/tasks watchers нетривиально, требует отдельного спайка
- **Windows atomic rename**: `fs.renameSync` на Windows бросает `EXDEV`/`EBUSY` при кросс-устройственном rename — нужна обёртка
- **leadSessionId интеграция**: config.json содержит `leadSessionId`, но интеграция с session viewer (переход к сессии лида) — открытый вопрос
- **Hard Interrupt**: сообщения доставляются между turns (1-30с задержка). В будущем нужен способ прервать mid-turn
- **Архивация**: inbox не чистится автоматически, нужна кнопка "Архивировать"
- **FileWatcher extension**: FileWatcher.ts is already 1243 lines. Adding teams/tasks watchers is non-trivial and needs a separate spike.
- **Windows atomic rename**: `fs.renameSync` on Windows can throw `EXDEV`/`EBUSY` for cross-device rename. A wrapper is needed.
- **leadSessionId integration**: config.json contains `leadSessionId`, but integration with the session viewer (navigating to the lead session) remains open.
- **Hard Interrupt**: messages are delivered between turns with a 1-30 second delay. A future mechanism is needed to interrupt mid-turn.
- **Archival**: inbox is not cleaned automatically. An "Archive" button is needed.
## Файловая структура Claude Code
## Claude Code File Structure
```
```text
~/.claude/
├── teams/{teamName}/
│ ├── config.json # Конфиг команды (lead + служебные поля)
│ ├── members.meta.json # Роли/цвета/типы участников (teammates)
│ └── inboxes/{memberName}.json # Inbox каждого участника
│ ├── config.json # Team config (lead + service fields)
│ ├── members.meta.json # Member roles/colors/types (teammates)
│ └── inboxes/{memberName}.json # Inbox for each member
└── tasks/{teamName}/
├── {id}.json # Файл задачи
├── .lock # Lock-файл (0 байт)
└── .highwatermark # Последний ID задачи
├── {id}.json # Task file
├── .lock # Lock file (0 bytes)
└── .highwatermark # Latest task ID
```
**ВАЖНО**:
**Important**:
- `config.json` не является source-of-truth для полного roster.
- Полный roster для UI формируется как `members.meta.json + inbox filenames (+ lead из config)`.
- `config.json` is not the source of truth for the complete roster.
- The UI builds the complete roster from `members.meta.json + inbox filenames (+ lead from config)`.

View file

@ -0,0 +1,181 @@
# Adaptive Task Graphs For Agent Teams
**Date:** 2026-05-14
**Status:** Research note, not an approved implementation plan
**Scope:** Team Management, task graph scheduling, lead/member coordination, token and conflict reduction
## Sources
- [AgentConductor: Topology Evolution for Multi-Agent Competition-Level Code Generation](https://arxiv.org/abs/2602.17100)
- [Improving the Efficiency of Language Agent Teams with Adaptive Task Graphs](https://arxiv.org/html/2605.06320v1)
## Why This Is Interesting
These papers point at the same product problem we already see in Agent Teams: multi-agent performance is limited less by raw model capability and more by coordination overhead.
The useful idea is not "replace our orchestrator with a research framework". The useful idea is to make the task board itself a more explicit coordination graph:
- tasks are graph nodes
- `blockedBy` / `blocks` are dependency edges
- ready work is the graph frontier
- workers should receive scoped local context, not full team history
- stalled work should be released or reassigned explicitly
- risky or high-impact work should get selective verification
- coordination quality should be measured, not inferred from vibes
This fits our existing direction because the product already has task dependencies, review workflow, stall monitoring, task logs, context tracking, and lead/member briefing surfaces.
## Most Valuable Ideas To Preserve
### 1. LATTE-style dynamic task graph
LATTE is the more directly useful paper for us.
Core idea:
- the lead owns global graph consistency
- workers can propose or claim local work
- structural updates are serialized through the lead or controller
- execution stays parallel where dependencies allow it
- the graph remains inspectable, so coordination decisions are visible in the UI
Relevant operators to consider:
- `Discover` - create a newly discovered task when implementation reveals missing work
- `Assign` - set an owner for a ready task
- `Claim` - allow an idle member to take an unowned ready task
- `Complete` - mark task completion
- `Release` - clear owner or return stalled work to the ready queue
- `Close` - close stale/completed tasks when tests or evidence prove completion
- `Verify` - insert a lightweight review/check task before downstream work proceeds
🎯 Product value: 9/10
🛡️ Reliability if implemented incrementally: 8/10
🧠 Complexity: 6/10
Expected change size for a first useful version: about 700-1400 LOC.
### 2. Frontier-based scheduling
The board should be able to derive "what is actionable now" from graph state:
- a task is ready when all `blockedBy` tasks are completed or approved
- blocked tasks should not be started automatically
- ready unowned tasks can be offered to idle members
- ready owned tasks belong in the owner's operational queue
- lead briefing should show graph bottlenecks and unassigned frontier work
This connects directly to `task-queue-derived-agenda-plan.md`. The key addition is to treat the queue as a graph frontier, not just a filtered task list.
🎯 Product value: 9/10
🛡️ Reliability: 8/10
🧠 Complexity: 5/10
Expected change size: about 500-1000 LOC if built on the current derived agenda work.
### 3. Selective verification instead of review everything
LATTE's `Verify` is useful because it scales review cost with risk:
- verify upstream tasks that many other tasks depend on
- verify work touching shared files or public contracts
- verify tasks whose owner reported uncertainty
- skip extra verification for small isolated changes unless policy requires it
This maps well to our existing review UI and task comments. A future implementation could create a verification task or request review based on graph impact.
🎯 Product value: 8/10
🛡️ Reliability: 7/10
🧠 Complexity: 5/10
Expected change size: about 350-800 LOC.
### 4. Straggler release as first-class behavior
LATTE explicitly models stalled workers and `Release`. We already have task-stall monitoring, but the next step is to make release/reassign a structured board action, not only a message nudge.
Useful behavior:
- detect a task with weak or stale progress evidence
- notify or nudge the current owner first
- if still stalled, clear owner or reassign with context
- preserve evidence and avoid duplicate nudges
- never auto-start new runtime lanes as a side effect
This must stay compatible with existing OpenCode delivery watchdog and stall-monitor semantics.
🎯 Product value: 8/10
🛡️ Reliability: 7/10
🧠 Complexity: 6/10
Expected change size: about 600-1200 LOC.
### 5. Coordination metrics as a product surface
LATTE is especially useful because it externalizes coordination and measures failures:
- idle rounds
- straggler tail latency
- inter-agent messages
- file conflicts or concurrent writes
- redundant output
- wasted tokens
- task graph growth and bottlenecks
For Agent Teams, this could become a "team efficiency" diagnostic panel and a safer prerequisite before changing scheduling behavior.
🎯 Product value: 8/10
🛡️ Reliability: 9/10
🧠 Complexity: 4/10
Expected change size: about 350-800 LOC.
## AgentConductor Ideas Worth Keeping
AgentConductor is less directly implementable because it depends on an RL/SFT-trained orchestrator and competition-code benchmarks. Still, one product idea is valuable:
**Task difficulty should control graph density.**
Possible lightweight version for Agent Teams:
- easy task - solo or small graph, minimal messaging, no extra verification by default
- medium task - split by independent deliverables, use dependencies only where real ordering exists
- hard task - more explicit roles, denser review/checkpoints, stronger integration pass
- failed execution feedback - adapt the graph instead of repeating the same topology
Do not adopt the paper's full GRPO/SFT training path for now. It is too heavy for the app and not necessary to get product value.
🎯 Product value: 7/10
🛡️ Reliability: 6/10
🧠 Complexity: 7/10
Expected change size for a heuristic MVP: about 600-1300 LOC.
## Objectivity And Risk Notes
The LATTE paper is directionally credible but should not be treated as production proof.
Strong points:
- the core claim matches practical distributed-systems intuition
- the paper compares against several coordination styles, not only one weak baseline
- it evaluates multiple collaborative task types
- it emphasizes metrics we can independently measure
- the mechanism is simple enough to port incrementally
Limitations:
- it is an arXiv preprint, not final production validation
- benchmark tasks are controlled research tasks, not our full Electron plus runtime matrix
- baseline implementations may not match best possible production implementations
- reported improvements should be validated against our own teams, logs, and providers
Practical conclusion:
⚠️ Treat LATTE as a strong design signal, not a dependency or spec. Implement the ideas gradually behind our existing task board, lead/member briefings, and runtime-specific guardrails.
## Recommended Internal Path
1. Add coordination metrics first.
2. Derive a graph frontier from current task state.
3. Make lead and member briefings use the frontier as the operational queue.
4. Add structured release/reassign for stalled work.
5. Add selective verification for high-risk graph nodes.
6. Only after that, consider difficulty-aware graph density hints.
This ordering gives us evidence before automation. It also keeps the rollout compatible with existing `blockedBy`, review flow, task-stall monitor, OpenCode delivery watchdog, and context tracking.

View file

@ -17,17 +17,18 @@ Team root:
```bash
TEAM="<team-name>"
TEAM_DIR="$HOME/.claude/teams/$TEAM"
TASKS_DIR="$HOME/.claude/tasks/$TEAM"
```
Important files and folders:
- `config.json` - configured members, provider/model selection, project path
- `members-meta.json` - member metadata, removed members, worktree settings if present
- `members.meta.json` - member metadata, removed members, worktree settings if present
- `launch-state.json` - current app-side truth for member launch/liveness
- `bootstrap-state.json` - bootstrap phase summary when present
- `bootstrap-journal.jsonl` - ordered bootstrap events from the CLI/runtime
- `inboxes/*.json` - durable inbox messages for user, lead, and native teammates
- `sentMessages.json` - app-side sent-message records
- `tasks/*.json` - task board state
- `$TASKS_DIR/*.json` - task board state
- `.opencode-runtime/lanes.json` - OpenCode lane index
- `.opencode-runtime/lanes/<encoded-lane-id>/manifest.json` - lane-scoped runtime store manifest
- `.opencode-runtime/lanes/<encoded-lane-id>/opencode-sessions.json` - committed OpenCode session evidence
@ -155,7 +156,7 @@ For task stalls:
```bash
TASK="<short-or-full-task-id>"
rg -n "$TASK" "$TEAM_DIR/tasks" "$TEAM_DIR/inboxes" "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
rg -n "$TASK" "$TASKS_DIR" "$TEAM_DIR/inboxes" "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
```
Important distinctions:
@ -195,9 +196,9 @@ Before changing launch or runtime logic:
Recommended verification:
```bash
pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts
pnpm vitest run test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts
pnpm typecheck --pretty false
pnpm test -- test/main/services/team/TeamProvisioningService.test.ts
pnpm test -- test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts
pnpm typecheck
git diff --check
```

File diff suppressed because it is too large Load diff

View file

@ -1,43 +1,43 @@
# Research: Подходы к отправке сообщений тиммейтам
# Research: Teammate Message Delivery Approaches
## Сравнение 3 подходов
## Comparison of 3 Approaches
| Критерий | Inbox-файлы | Agent SDK | CLI subprocess |
|----------|:-----------:|:---------:|:--------------:|
| Скорость | ~5ms | ~12с | 10-15с |
| Стоимость | $0 | $0.01-0.08/msg | токены |
| Работает с запущенными | **YES** | NO | NO |
| Прерывает mid-turn | NO | NO | NO |
| Требует API ключ | NO | YES | NO |
| Расход памяти | 0 | 0 | 100-320MB |
| Criterion | Inbox files | Agent SDK | CLI subprocess |
| --------- | :---------: | :-------: | :------------: |
| Speed | ~5ms | ~12s | 10-15s |
| Cost | $0 | $0.01-0.08/msg | tokens |
| Works with running teammates | **YES** | NO | NO |
| Interrupts mid-turn | NO | NO | NO |
| Requires API key | NO | YES | NO |
| Memory usage | 0 | 0 | 100-320MB |
---
## 1. Inbox-файлы (ВЫБРАНО)
## 1. Inbox Files (Chosen)
### Как работает
### How It Works
Прямая запись JSON в файл `~/.claude/teams/{team}/inboxes/{member}.json`. Claude Code мониторит эти файлы через fs.watch и доставляет сообщения агентам между turns.
The app writes JSON directly to `~/.claude/teams/{team}/inboxes/{member}.json`. Claude Code watches these files through fs.watch and delivers messages to agents between turns.
### Плюсы
### Pros
- **Мгновенная запись** (~5ms)
- **$0** — никаких API вызовов
- **Единственный** способ общаться с запущенными тиммейтами
- Работает с idle и active агентами (но доставка между turns)
- **Instant write** (~5ms)
- **$0** - no API calls
- **Only** way to communicate with already-running teammates
- Works with idle and active agents, although delivery still happens between turns
### Минусы
### Cons
- Race condition при одновременной записи (см. [research-inbox.md](./research-inbox.md))
- Формат недокументирован (internal API)
- Доставка между turns, не real-time
- Race condition during concurrent writes (see [research-inbox.md](./research-inbox.md))
- Undocumented format (internal API)
- Delivery happens between turns, not in real time
### Формат сообщения
### Message Format
```json
{
"from": "user",
"text": "Не трогай файл auth.ts, я его сам изменю",
"text": "Do not touch auth.ts, I will change it myself",
"timestamp": "2026-02-17T15:30:00.000Z",
"read": false,
"summary": "Do not modify auth.ts",
@ -47,9 +47,9 @@
---
## 2. Agent SDK (ОТВЕРГНУТ)
## 2. Agent SDK (Rejected)
### Как работает
### How It Works
```typescript
import Anthropic from '@anthropic-ai/sdk';
@ -57,156 +57,159 @@ const client = new Anthropic();
const response = await client.messages.create({
model: 'claude-opus-4-7',
messages: [{ role: 'user', content: 'Send message to teammate...' }],
tools: [/* SendMessage, TaskUpdate, etc. */]
tools: [/* SendMessage, TaskUpdate, etc. */],
});
```
### Почему отвергнут
### Why It Was Rejected
1. **Создаёт НОВУЮ сессию** — не подключается к работающему тиммейту. SendMessage и TaskCreate — это инструменты модели, не программные вызовы
2. **~12 секунд** на каждый вызов (полный API round-trip)
3. **Стоит токены** — $0.01-0.08 за сообщение
4. **Нужен API ключ** — отдельная оплата, а не подписка Claude
1. **Creates a new session** - does not attach to a running teammate. SendMessage and TaskCreate are model tools, not programmatic calls.
2. **~12 seconds** per call because of the full API round trip.
3. **Costs tokens** - $0.01-0.08 per message.
4. **Requires an API key** - separate billing, not a Claude subscription.
### Когда может пригодиться
### When It May Be Useful
- Создание новых команд программно
- Автоматизация workflow (вне real-time UI)
- Creating new teams programmatically.
- Workflow automation outside the real-time UI path.
---
## 3. CLI subprocess (ОТВЕРГНУТ)
## 3. CLI Subprocess (Rejected)
### Как работает
### How It Works
```bash
claude --message "Send message to teammate-1: stop working on X"
```
### Почему отвергнут
### Why It Was Rejected
1. **Новый процесс** — не инжектится в работающего тиммейта
2. **10-15 секунд** холодный старт
3. **100-320MB памяти** на процесс
4. Каждый вызов стоит токены
1. **New process** - does not inject into a running teammate.
2. **10-15 second** cold start.
3. **100-320MB** of memory per process.
4. Each call costs tokens.
---
## Архитектура доставки (обновлено 2026-03-23)
## Delivery Architecture (Updated 2026-03-23)
### Два разных механизма: лид vs тиммейты
### Two Different Mechanisms: Lead vs Teammates
**Лид** читает ТОЛЬКО stdin (stream-json). Для доставки сообщений лиду используется `relayLeadInboxMessages()` — конвертирует inbox-записи в stream-json на stdin. Без relay лид не видит inbox.
**Lead** reads ONLY stdin (stream-json). Messages to the lead are delivered with `relayLeadInboxMessages()`, which converts inbox entries into stream-json on stdin. Without relay, the lead does not see inbox messages.
**Тиммейты** — полноценные независимые Claude Code процессы. Каждый мониторит свой inbox файл через fs.watch и читает сообщения напрямую. Relay через лида НЕ нужен.
**Teammates** are fully independent Claude Code processes. Each teammate watches its own inbox file through fs.watch and reads messages directly. Relay through the lead is not needed.
### Поток сообщений: Юзер → Тиммейт
### Message Flow: User -> Teammate
```
User → [UI] → TeamInboxWriter → inboxes/{member}.json (read: false)
Teammate CLI (fs.watch) → читает → обрабатывает
Teammate → inboxes/user.json (ответ)
[UI] ← TeamInboxReader ← читает user.json
```text
User -> [UI] -> TeamInboxWriter -> inboxes/{member}.json (read: false)
|
Teammate CLI (fs.watch) -> reads -> handles
|
Teammate -> inboxes/user.json (response)
|
[UI] <- TeamInboxReader <- reads user.json
```
Лид в этой цепочке НЕ участвует. Сообщение доставляется напрямую.
The lead is not part of this path. The message is delivered directly.
### Поток сообщений: Юзер → Лид
### Message Flow: User -> Lead
```
User → [UI] → stdin (stream-json) → Lead CLI
Lead sentMessages.json / liveLeadProcessMessages
[UI] ← читает и отображает
```text
User -> [UI] -> stdin (stream-json) -> Lead CLI
|
Lead -> sentMessages.json / liveLeadProcessMessages
|
[UI] <- reads and renders
```
Для лида дополнительно работает `relayLeadInboxMessages()` при изменении `inboxes/{lead}.json`.
For the lead, `relayLeadInboxMessages()` additionally runs when `inboxes/{lead}.json` changes.
### Ответы тиммейтов
### Teammate Responses
Тиммейт отвечает юзеру через `SendMessage(to="user")`, что записывается в `inboxes/user.json`. UI читает этот файл через `TeamInboxReader.getMessages()` (читает ВСЕ inbox файлы в директории).
A teammate responds to the user through `SendMessage(to="user")`, which writes to `inboxes/user.json`. The UI reads this file through `TeamInboxReader.getMessages()`, which reads all inbox files in the directory.
Сообщения в `user.json` могут не содержать `messageId``TeamInboxReader` генерирует детерминированный ID из sha256(from + timestamp + text).
Messages in `user.json` may not contain `messageId`; `TeamInboxReader` generates a deterministic ID from sha256(from + timestamp + text).
### from: "user" — подтверждено работает
### from: "user" Is Confirmed To Work
`from: "user"` работает корректно (подтверждено эмпирически 2026-03-23):
- Тиммейт получает сообщение
- Тиммейт корректно определяет что это от юзера
- Тиммейт отвечает в `inboxes/user.json`
- Fallback на `from: "team-lead"` не нужен
`from: "user"` works correctly, confirmed empirically on 2026-03-23:
### Почему relay через лида был ОТКЛЮЧЁН (2026-03-23)
- Teammate receives the message.
- Teammate correctly identifies that it came from the user.
- Teammate responds in `inboxes/user.json`.
- Fallback to `from: "team-lead"` is not needed.
Ранее при отправке DM тиммейту, помимо записи в inbox, вызывался `relayMemberInboxMessages()` — инструкция лиду переслать сообщение через `SendMessage(to=member)`. Это вызывало 3 бага:
### Why Relay Through the Lead Was Disabled (2026-03-23)
1. **Лид отвечал вместо тиммейта** — LLM интерпретировал relay-инструкцию как обращение к себе и отвечал юзеру напрямую
2. **Дубликаты сообщений**`markInboxMessagesRead()` записывал в файл → FileWatcher срабатывал → relay запускался повторно → цикл
3. **Тиммейт не отвечал юзеру** — relay-промпт содержал "Do NOT send to user", что тиммейт тоже видел через лида
Previously, when sending a DM to a teammate, the app called `relayMemberInboxMessages()` in addition to writing to the inbox. This instructed the lead to forward the message through `SendMessage(to=member)`. It caused 3 bugs:
Relay отключён в `teams.ts` (handleSendMessage) и `index.ts` (FileWatcher). Код закомментирован, не удалён. Relay для лида (`relayLeadInboxMessages`) не затронут.
1. **Lead replied instead of the teammate** - the LLM interpreted the relay instruction as addressed to itself and answered the user directly.
2. **Duplicate messages** - `markInboxMessagesRead()` wrote to the file, triggering FileWatcher, which re-ran relay and created a loop.
3. **Teammate did not reply to the user** - the relay prompt contained "Do NOT send to user", which the teammate also saw through the lead.
Relay is disabled in `teams.ts` (`handleSendMessage`) and `index.ts` (FileWatcher). The code is commented out, not deleted. Lead relay (`relayLeadInboxMessages`) is unaffected.
---
## Доставка: Timing и ограничения
## Delivery: Timing and Constraints
### Цикл тиммейта
### Teammate Turn Cycle
```
```text
Turn N:
1. Читает inbox → видит новые (read: false)
2. Обрабатывает сообщения/задачи
3. Вызывает инструменты
1. Reads inbox -> sees new messages with read: false
2. Handles messages/tasks
3. Calls tools
4. Reasoning
5. Output
→ idle_notification → IDLE
-> idle_notification -> IDLE
... ожидание ...
... wait ...
Turn N+1:
1. Пробуждение (новое сообщение в inbox / назначение задачи)
2. Читает inbox → видит новые
1. Wake-up (new inbox message / assigned task)
2. Reads inbox -> sees new messages
...
```
### Задержка
### Delay
- **Idle agent**: получит при следующем пробуждении (доли секунды если inbox-change triggers)
- **Active agent (mid-turn)**: получит только после завершения текущего turn (1-30 секунд)
- **Idle agent**: receives the message on the next wake-up, usually a fraction of a second if inbox-change triggers.
- **Active agent (mid-turn)**: receives the message only after the current turn completes, usually 1-30 seconds.
### Нельзя прервать
### No Mid-Turn Interrupt
Если агент уже вызвал Edit/Bash — инструмент выполнится. Наше сообщение придёт ПОСЛЕ.
If an agent has already called Edit/Bash, the tool will complete. Our message arrives after that.
**Пример**:
```
17:12:30 — Agent начинает Edit на auth.ts
17:12:31 — Мы шлём "Не трогай auth.ts"
17:12:32 — Agent завершает Edit (auth.ts изменён)
17:12:33 — Agent читает inbox, видит наше сообщение
→ Поздно, файл уже изменён
**Example**:
```text
17:12:30 - Agent starts Edit on auth.ts
17:12:31 - We send "Do not touch auth.ts"
17:12:32 - Agent completes Edit (auth.ts changed)
17:12:33 - Agent reads inbox and sees our message
-> Too late, the file was already changed
```
### Hard Interrupt (будущее)
### Hard Interrupt (Future)
Возможные подходы:
1. **kill -SIGINT** процесса тиммейта (жёсткое прерывание, потеря контекста)
2. **Файловый flag** `.interrupt-{member}` (нужна поддержка в Claude Code)
3. **API от Anthropic** (если появится)
Possible approaches:
Текущее решение: задержка приемлема, hard interrupt — в будущем.
1. **kill -SIGINT** the teammate process: hard interrupt, context loss.
2. **File flag** `.interrupt-{member}`: needs Claude Code support.
3. **Anthropic API**: if it becomes available.
Current decision: the delay is acceptable; hard interrupt is future work.
---
## Финальное решение
## Final Decision
### messageId — обязателен в каждом исходящем сообщении
### messageId Is Required In Every Outgoing Message
Каждое исходящее сообщение включает `messageId: crypto.randomUUID()`:
Every outgoing message includes `messageId: crypto.randomUUID()`:
```json
{
@ -219,18 +222,18 @@ Turn N+1:
}
```
### Verify: проверка сразу после записи
### Verify Immediately After Write
- После atomic write читаем inbox и ищем наш `messageId`
- Если не найден — потеря обнаружена → warning в UI (не silent fail)
- Не автоматический retry на MVP
- After atomic write, read the inbox and look for our `messageId`.
- If missing, message loss was detected -> show a warning in the UI instead of failing silently.
- No automatic retry in MVP.
### 3 состояния offline-участника
### 3 States For Offline Members
| Состояние | Условие | Отображение |
|-----------|---------|-------------|
| `ACTIVE` | idle < 5 минут | Зелёный dot |
| `IDLE` | idle > 5 минут | Жёлтый dot |
| `TERMINATED` | Получен `shutdown_response` с `approve: true` | Серый dot, "Завершён" |
| State | Condition | Display |
| ----- | --------- | ------- |
| `ACTIVE` | idle < 5 minutes | Green dot |
| `IDLE` | idle > 5 minutes | Yellow dot |
| `TERMINATED` | Received `shutdown_response` with `approve: true` | Gray dot, "Terminated" |
Определение состояния по timestamp последнего события в inbox (idle_notification, любое сообщение). TERMINATED — исключительно по явному `shutdown_response`.
State is determined from the timestamp of the latest event in the inbox (`idle_notification` or any message). `TERMINATED` is based only on an explicit `shutdown_response`.

View file

@ -0,0 +1,237 @@
# Runtime backend rationale: process by default, tmux as debug/manual mode
Date: 2026-05-13
Status: informational note, not a normative architecture spec.
This document captures the reasoning discussed during launch-runtime stabilization work. It may contain small inaccuracies or outdated external-project details, especially about third-party projects. Treat it as context and rationale, not as the source of truth. Current implementation, tests, and upstream project docs remain authoritative.
## Short version
We intentionally moved the desktop app toward **process backend by default** for app-launched teammates, while keeping **tmux as an explicit debug/manual mode**.
The reason is not that tmux is bad. The reason is that our product is not primarily a terminal multiplexer. It is an app-owned team runtime with UI state, launch diagnostics, restart/retry controls, provider auth handling, bootstrap proofs, notifications, and artifact packs.
For that product shape, the default runtime should be controlled by the app, not by a human attaching to panes.
## What tmux gives
tmux is useful when the product expects live terminal sessions:
- A human can attach to a pane and see exactly what the CLI sees.
- If the CLI asks for input, the user can manually press Enter or answer prompts.
- Panes can survive some app restarts.
- TTY behavior is closer to running the CLI manually.
- Debugging auth/login/TTY problems is easier because the terminal is visible.
This is why tmux is a natural default for terminal-first systems.
## Why not tmux like gastown/gascity
Based on the external-project research snapshot from this thread, `gastown` and `gascity` appear to be more terminal/session-oriented. This is an interpretation of their public docs/issues at the time of research, not a maintained compatibility claim:
- Their interaction model leans heavily on attachable sessions.
- Their session layer historically expects pane-like targets and terminal observation.
- In `gascity`, tmux appears as a default provider in session configuration.
- They use tmux because their flow values live interactive sessions, attach/revive/nudge, and human terminal control.
That is a valid design for a terminal-first product.
It is not automatically the best default for us because our desktop app has different ownership boundaries:
- We need reliable UI state for each member.
- We need deterministic launch success/failure state.
- We need structured diagnostics, not only "look at the pane".
- We need restart/retry/cleanup to be owned by the app.
- We need provider auth and tool approval to be modeled explicitly.
- We need headless teammate behavior to work without a terminal being open.
tmux also has known operational costs in this class of products:
- zombie sessions;
- broken pane targets;
- socket/version split-brain after upgrades;
- platform limitations, especially Windows;
- ambiguity between "pane exists" and "agent is actually ready";
- harder cleanup when app state and terminal state diverge.
So the difference is product shape:
- `gastown/gascity`: terminal/session-first, so tmux default is understandable.
- `claude_team`: desktop/app-owned lifecycle-first, so process default is more aligned.
## What process backend gives us
The process backend lets the app own the lifecycle:
- Runtime identity is represented as process metadata, not only pane id.
- `backendType: process` and `tmuxPaneId: process:<pid>` preserve compatibility with older shapes while making the backend explicit.
- Launch state can distinguish `spawned`, `bootstrap_submitted`, `bootstrap_confirmed`, `failed_to_start`, `bootstrap_stalled`, and provider failures.
- Diagnostics can be surfaced in member cards, notifications, launch summaries, and artifact packs.
- Restart and cleanup can target launch-owned processes instead of broad terminal state.
- App-managed bootstrap can avoid relying on the model to manually discover and call setup tools.
This is a better foundation for stable desktop launches than treating a pane as the primary runtime truth.
## Interactive prompts are still real
The main argument for tmux is valid: real CLIs sometimes ask interactive questions.
Examples:
- "Press Enter to continue"
- "Do you want to proceed? [y/N]"
- "Enter API key"
- "Please login"
- OAuth token expired
- provider quota or key limit prompt
- tool approval prompt
Our answer should not be "ignore all interaction". The correct answer is to split interaction into categories.
## How our architecture should handle interaction
### Structured approvals
Tool approvals should use structured protocol:
- CLI emits a `control_request`;
- app shows an approval UI or notification;
- app sends `control_response` through the owned channel;
- decision is persisted in runtime state.
This is better than asking the user to attach to tmux and press a key manually.
### Auth and login prompts
Auth/login prompts should usually be handled before launch:
- preflight provider auth;
- validate subscription/API-key mode;
- validate required settings/env;
- fail fast with actionable UI if auth is missing or expired.
Hidden teammate processes should not block waiting for a browser login or secret input.
### Safe known prompts
Some prompts can be handled through an allowlisted interactive prompt gate:
- exact "Press Enter to continue" style prompt;
- exact yes/no confirmation where the action is known and safe;
- one prompt at a time per process;
- timeout if user does not respond;
- event recorded in diagnostics/artifact pack.
For a lead process, the desktop app already owns `child.stdin`, so writing a newline is technically possible.
For teammate process backend, the desktop app may not directly own the child handle. The robust design is:
- detect prompt in process backend/orchestrator;
- surface structured prompt state to desktop;
- user chooses action in UI;
- the runtime owner writes to the teammate stdin;
- event is persisted.
Do not blindly write to arbitrary process stdin by PID.
### Unknown prompts
Unknown prompts should not be answered automatically.
Correct behavior:
- mark the member as waiting/blocked with a diagnostic;
- show the relevant output excerpt;
- suggest fixing auth/settings or using tmux debug mode;
- avoid sending random newline/yes/no input.
This prevents dangerous accidental confirmation and avoids hiding provider setup bugs.
## Why tmux remains useful
tmux should stay available as an explicit mode:
```bash
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
```
or via extra CLI args:
```bash
--teammate-mode tmux
```
Use it for:
- debugging unknown TTY behavior;
- reproducing provider CLI prompts manually;
- investigating strange live CLI output;
- cases where human terminal control matters more than app-owned lifecycle.
tmux is an escape hatch, not the production default.
## Why not full arbitrary terminal emulation
Trying to support all possible interactive terminal behavior inside process backend would be risky.
Problems:
- prompts are provider-specific and change over time;
- pressing Enter may be safe in one context and dangerous in another;
- stdin might be structured JSON, not text;
- a newline can land during an active model turn;
- secrets should not be requested through generic stdin;
- the app can accidentally mask auth or provider integration failures.
The safer contract is:
- app-managed launch should be non-interactive by default;
- known safe prompts may be handled through structured UI;
- auth/setup should be preflighted;
- unknown TTY needs tmux/manual debug mode.
## Current strategic choice
Recommended runtime policy:
1. Production default: process backend.
2. Provider setup: preflight and actionable diagnostics.
3. Tool approvals: structured app UI.
4. Known safe prompts: bounded interactive prompt gate.
5. Unknown prompts: fail/block visibly with diagnostics.
6. Debug/manual: explicit tmux mode.
This keeps the app in control of lifecycle state while preserving tmux where it is genuinely useful.
## Tradeoff summary
### Process default + tmux debug mode
Confidence: 9.3/10
Reliability: 9/10
Complexity: 6/10
Best fit for desktop/app-owned agent teams. Requires strong diagnostics and provider preflight.
### tmux default + process fallback
Confidence: 6.5/10
Reliability: 6.5/10
Complexity: 4/10
Good for terminal-first workflows. Less aligned with deterministic app-owned launch state.
### Fully abstract runtime providers
Confidence: 7/10
Reliability: 7.5/10
Complexity: 9/10
Potentially useful later, but too broad as a launch-stability fix.
## Bottom line
We did not reject tmux entirely. We rejected tmux as the default runtime truth for app-launched teams.
The desktop product should make teammate launch reliable through app-owned process lifecycle, structured evidence, diagnostics, and controlled recovery. tmux remains valuable for debug/manual sessions, especially when an unknown CLI prompt requires a real terminal.

File diff suppressed because it is too large Load diff

View file

@ -39,8 +39,7 @@ const rootGuide: DefaultTheme.SidebarItem[] = [
text: "Start",
items: [
{ text: "Quickstart", link: "/guide/quickstart" },
{ text: "Installation", link: "/guide/installation" },
{ text: "Create a team", link: "/guide/create-team" }
{ text: "Installation", link: "/guide/installation" }
]
},
{
@ -48,15 +47,30 @@ const rootGuide: DefaultTheme.SidebarItem[] = [
items: [
{ text: "Runtime setup", link: "/guide/runtime-setup" },
{ text: "Agent workflow", link: "/guide/agent-workflow" },
{ text: "Code review", link: "/guide/code-review" },
{ text: "MCP integration", link: "/guide/mcp-integration" },
{ text: "Code review", link: "/guide/code-review" }
]
},
{
text: "Team Management",
items: [
{ text: "Create a team", link: "/guide/create-team" },
{ text: "Team brief examples", link: "/guide/team-brief-examples" },
{ text: "Git and worktree strategy", link: "/guide/git-worktree-strategy" },
{ text: "Troubleshooting", link: "/guide/troubleshooting" }
]
},
{
text: "Developers",
items: [{ text: "Developer hub", link: "/developers/" }]
},
{
text: "Reference",
items: [
{ text: "Concepts", link: "/reference/concepts" },
{ text: "Providers and runtimes", link: "/reference/providers-runtimes" },
{ text: "Contributor architecture", link: "/reference/contributor-architecture" },
{ text: "Release notes", link: "/reference/release-notes" },
{ text: "Privacy and local data", link: "/reference/privacy-local-data" },
{ text: "FAQ", link: "/reference/faq" }
]
@ -68,8 +82,7 @@ const ruGuide: DefaultTheme.SidebarItem[] = [
text: "Старт",
items: [
{ text: "Быстрый старт", link: "/ru/guide/quickstart" },
{ text: "Установка", link: "/ru/guide/installation" },
{ text: "Создание команды", link: "/ru/guide/create-team" }
{ text: "Установка", link: "/ru/guide/installation" }
]
},
{
@ -77,15 +90,30 @@ const ruGuide: DefaultTheme.SidebarItem[] = [
items: [
{ text: "Настройка рантайма", link: "/ru/guide/runtime-setup" },
{ text: "Работа агентов", link: "/ru/guide/agent-workflow" },
{ text: "Код-ревью", link: "/ru/guide/code-review" },
{ text: "MCP integration", link: "/ru/guide/mcp-integration" },
{ text: "Код-ревью", link: "/ru/guide/code-review" }
]
},
{
text: "Управление командами",
items: [
{ text: "Создание команды", link: "/ru/guide/create-team" },
{ text: "Team brief examples", link: "/ru/guide/team-brief-examples" },
{ text: "Git and worktree strategy", link: "/ru/guide/git-worktree-strategy" },
{ text: "Диагностика", link: "/ru/guide/troubleshooting" }
]
},
{
text: "Разработчикам",
items: [{ text: "Хаб разработчика", link: "/ru/developers/" }]
},
{
text: "Справочник",
items: [
{ text: "Концепции", link: "/ru/reference/concepts" },
{ text: "Провайдеры и рантаймы", link: "/ru/reference/providers-runtimes" },
{ text: "Архитектура для контрибьюторов", link: "/ru/reference/contributor-architecture" },
{ text: "Релизы", link: "/ru/reference/release-notes" },
{ text: "Приватность и локальные данные", link: "/ru/reference/privacy-local-data" },
{ text: "FAQ", link: "/ru/reference/faq" }
]
@ -94,6 +122,7 @@ const ruGuide: DefaultTheme.SidebarItem[] = [
const rootNav: DefaultTheme.NavItem[] = [
{ text: "Guide", link: "/guide/quickstart", activeMatch: "^/guide/(?!troubleshooting(?:/|$))" },
{ text: "Developers", link: "/developers/", activeMatch: "^/developers/" },
{ text: "Reference", link: "/reference/concepts", activeMatch: "^/reference/" },
{
text: "Troubleshooting",
@ -109,6 +138,7 @@ const ruNav: DefaultTheme.NavItem[] = [
link: "/ru/guide/quickstart",
activeMatch: "^/ru/guide/(?!troubleshooting(?:/|$))"
},
{ text: "Разработчикам", link: "/ru/developers/", activeMatch: "^/ru/developers/" },
{ text: "Справочник", link: "/ru/reference/concepts", activeMatch: "^/ru/reference/" },
{
text: "Диагностика",

View file

@ -15,6 +15,7 @@ const cards = computed(() => {
? [
{ icon: "◈", title: "Концепции", desc: "Команды, задачи, роли и уровни автономности.", link: "/ru/reference/concepts" },
{ icon: "⌁", title: "Рантаймы", desc: "Claude, Codex, OpenCode и multimodel-режим.", link: "/ru/reference/providers-runtimes" },
{ icon: "▦", title: "Архитектура", desc: "Feature layout, guardrails и границы runtime/provider.", link: "/ru/reference/contributor-architecture" },
{ icon: "⌘", title: "Локальные данные", desc: "Что хранится на машине и что уходит провайдерам.", link: "/ru/reference/privacy-local-data" },
{ icon: "?", title: "FAQ", desc: "Короткие ответы на частые вопросы.", link: "/ru/reference/faq" }
]
@ -30,6 +31,7 @@ const cards = computed(() => {
? [
{ icon: "◈", title: "Concepts", desc: "Teams, tasks, roles, and autonomy levels.", link: "/reference/concepts" },
{ icon: "⌁", title: "Runtimes", desc: "Claude, Codex, OpenCode, and multimodel mode.", link: "/reference/providers-runtimes" },
{ icon: "▦", title: "Architecture", desc: "Feature layout, guardrails, and runtime/provider boundaries.", link: "/reference/contributor-architecture" },
{ icon: "⌘", title: "Local data", desc: "What stays on disk and what providers receive.", link: "/reference/privacy-local-data" },
{ icon: "?", title: "FAQ", desc: "Short answers to common questions.", link: "/reference/faq" }
]

View file

@ -0,0 +1,67 @@
---
title: Developers Agent Teams Docs
description: Contributor and developer entry point for Agent Teams architecture, guardrails, debugging, and MCP extension paths.
---
# Developers
Use this page when you want to change Agent Teams itself, debug a team launch, or extend a runtime with MCP tools. The links below point to the canonical repo documents so implementation rules stay in one place.
## Start here
| Need | Go to |
| --- | --- |
| Repo overview, scripts, and source setup | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
| Working conventions for agents and contributors | [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) |
| Hard implementation guardrails | [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) |
| Medium and large feature structure | [Feature architecture standard](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) |
| Launch, bootstrap, and teammate messaging debugging | [Agent team debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) |
| Contribution process | [Contributing guide](https://github.com/777genius/agent-teams-ai/blob/main/.github/CONTRIBUTING.md) |
| Release notes / Changelog | [RELEASE.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) — [CHANGELOG.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md) |
## Local development path
Run the desktop Electron app for normal development:
```bash
pnpm install
pnpm dev
```
The browser/web path is not a replacement for the desktop runtime. Desktop mode is the supported local path because it includes IPC, terminals, provider auth, team lifecycle handling, launch diagnostics, and the runtime bridges used by real teams.
## Architecture checkpoints
Before changing a feature, identify its boundary:
| Area | Expected home |
| --- | --- |
| Medium or large product feature | `src/features/<feature-name>/` |
| Electron main process orchestration | `src/main/` |
| Preload-safe API surface | `src/preload/` |
| Renderer UI and app state | `src/renderer/` |
| Shared types and pure helpers | `src/shared/` |
| Agent Teams board MCP server | `mcp-server/` |
| Board data controller | `agent-teams-controller/` |
Use `src/features/recent-projects` as the reference slice for feature organization. Keep cross-process contracts explicit, and avoid deep imports across feature boundaries.
## Debugging path
For launch hangs, OpenCode `registered` / bootstrap-unconfirmed states, missing teammate replies, or suspicious task logs:
1. Start with the [debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md).
2. Inspect the newest artifact pack under `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`.
3. Open the artifact `manifest.json` and check `classification`, bootstrap breadcrumbs, launch diagnostics, member spawn statuses, and redacted log tails.
4. Clean up only the team, run, pane, or process you can identify as owned by the smoke test or failed launch.
## MCP development path
Agent Teams uses a built-in MCP server named `agent-teams` for board operations. User and project MCP servers can add external capabilities for runtimes. See [MCP integration](/guide/mcp-integration) for setup examples, `.mcp.json` structure, and tool registration guidance.
## Related docs
- [Contributor architecture](/reference/contributor-architecture)
- [Runtime setup](/guide/runtime-setup)
- [MCP integration](/guide/mcp-integration)
- [Troubleshooting](/guide/troubleshooting)

View file

@ -18,23 +18,41 @@ Both modes share the same kanban, task logs, and code review surfaces.
## Task lifecycle
Agent Teams tracks each task along two independent dimensions: work status and review state.
| Dimension | States | Description |
| --- | --- | --- |
| Work status | `pending`, `in_progress`, `completed` | Tracks whether the task is waiting, actively being worked on, or finished by the owner |
| Review state | `none`, `review`, `needsFix`, `approved` | Tracks where the task is in the post-completion review flow |
The kanban board shows the combined view, but the two dimensions move independently.
### Work status flow
| 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 |
| Pending | Task is created and ready but no one has started work yet | Lead or user |
| In progress | Agents work and update task state via board MCP tools | Teammates |
| Completed | The owner posts a result comment and marks the task done | Teammate |
### Review state flow
| Stage | What happens | Owner |
| --- | --- | --- |
| None | Task is not yet in review (may be pending, in progress, or newly completed) | — |
| Review | Review has been requested; a reviewer inspects the diff and result | Reviewer |
| Needs fix | Changes were requested during review; the owner must update | Teammate (owner) |
| Approved | Review passed; the task is finalized | Reviewer |
### 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.
When a teammate starts a task, the work 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
### Completed → 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.
When the teammate finishes work, it posts a result comment and marks the work status `completed`. The lead or reviewer can then request a review to start the review flow.
### Review → Done
### Review → Approved
If the review surface shows acceptable changes, approve the review. The task is finalized and linked to its diff.

View file

@ -54,7 +54,7 @@ A healthy review loop looks like this:
Example request-changes comment:
```text
Please keep the copy improvements, but revert the unrelated runtime wording in the provider table. Add a docs build result before resubmitting.
Please keep the copy improvements, but revert the unrelated runtime wording in the provider table. Add the `pnpm --dir landing docs:build` result before resubmitting.
```
## Review states
@ -72,6 +72,15 @@ Teams can review each other's work before you make the final call. This catches
Agent review is most useful when the reviewer has a clear rubric. For example, tell a reviewer to check only docs clarity, only IPC safety, or only test coverage. Broad "review everything" requests tend to produce weaker feedback.
### MCP-driven review state
Review state changes (request review, request changes, approve) are tool-driven. Leaving a "request changes" comment on a task does **not** move the kanban column to `needsFix` — a lead or agent must call the appropriate MCP tool:
- `review_request_changes` — moves the task to `needsFix` and notifies the owner
- `review_approve` — moves the task to `approved` and finalizes the review
Comments alone are insufficient for state transitions. For the full list of review MCP tools and their parameters, see [MCP Integration](/guide/mcp-integration).
## 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.
@ -82,10 +91,12 @@ Prioritize these areas when reviewing:
- **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
- **Git and worktree behavior** - verify branch naming, commits, and pushes; see [Git and worktree strategy](/guide/git-worktree-strategy) for isolation patterns.
- **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
For the canonical feature layout and hard guardrail links, use [Contributor Architecture](/reference/contributor-architecture).
## Verification
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.

View file

@ -30,7 +30,7 @@ Each team member runs on a provider backend. In the team editor, pick a provider
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.
Gemini is available as a supported provider path. See [Providers and runtimes](/reference/providers-runtimes) for auth options and current provider status.
:::
## Write a good team brief

View file

@ -0,0 +1,101 @@
---
title: Git and Worktree Strategy Agent Teams Docs
description: Decide when to use the main worktree, feature branches, or OpenCode worktree isolation for parallel agent work.
---
# Git and Worktree Strategy
Git gives Agent Teams the strongest review path: narrow diffs, branch visibility, task-scoped changes, and safer parallel work.
## Choose a strategy
| Strategy | Use when | Tradeoff |
| --- | --- | --- |
| Main worktree | Solo work, docs-only edits, or one teammate at a time | Simple, but parallel edits can collide |
| Feature branch | One team is working on one coherent change | Clean review target, but teammates still share files |
| Worktree isolation | Multiple OpenCode teammates may edit the same repo in parallel | Better isolation, but merge/review needs more discipline |
Start simple. Add worktree isolation when parallel edits are likely, not because every task needs a separate checkout.
## When to enable worktree isolation
Enable it for OpenCode teammates when:
- two or more teammates may edit the same repository at once
- a task may run formatters, code generators, or broad tests
- you want each teammate's branch and diff to stay separate
- the lead workspace is dirty and should not receive direct edits
Keep it off when:
- the task is read-only
- one teammate owns all edits
- the repo is not Git-tracked
- you need a runtime path that does not support this isolation mode
::: warning
Worktree isolation currently applies to OpenCode members and requires a Git-tracked project.
:::
## Branch hygiene
Before starting parallel work:
```bash
git status --short
git branch --show-current
```
Use a clean branch when possible. If the main worktree already has user changes, tell agents not to revert unrelated files and keep task scope narrow.
Recommended branch style:
```text
agent/<team-or-task>/<short-purpose>
```
Examples:
```text
agent/docs/mcp-guide
agent/review/task-log-filtering
agent/ui/code-review-polish
```
## Review flow
For isolated worktrees, review the teammate's diff before merging or applying changes back to the main workspace.
1. Confirm the task result comment names changed scope and verification.
2. Inspect the task diff in the review UI.
3. Ask for changes on the task if the diff touches unrelated files.
4. Approve only after tests or manual checks match the task risk.
5. Merge or apply changes deliberately.
Do not auto-merge worktree output just because the task is complete. Completion means the agent believes the work is ready for review.
## Conflict policy
Use this policy for parallel teams:
| Situation | Action |
| --- | --- |
| Two teammates edit the same file | Pause one task or make one owner responsible for integration |
| Generated files changed broadly | Require a comment explaining the generator and command |
| Main worktree has unrelated changes | Preserve them and review only task-owned changes |
| Worktree branch diverges | Rebase or merge manually after review, not inside a vague agent task |
## Task prompt example
```text
Implement the settings validation fix in your assigned worktree. Keep edits inside src/features/settings and focused tests. Do not touch provider auth or task storage. Post the test command and result before completing the task.
```
This prompt works because it names the allowed area, sensitive boundaries, and completion evidence.
## Related guides
- [Create a team](/guide/create-team)
- [Code review](/guide/code-review)
- [Team brief examples](/guide/team-brief-examples)
- [Runtime setup](/guide/runtime-setup)

View file

@ -9,7 +9,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
## Download builds
Use the <a href="https://github.com/777genius/agent-teams-ai/releases" target="_self">download page</a> or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app:
Use the <a href="/download/" target="_self">download page</a> 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`
@ -30,11 +30,11 @@ To use agent runtimes, you need access to at least one provider:
| ------------------ | ------------------------------------------------- |
| Claude (Anthropic) | Claude Code CLI login or API key |
| Codex (OpenAI) | Codex CLI login or API key |
| Gemini (Google) | _In development_ |
| Gemini (Google) | Google ADC, Gemini CLI, or API key |
| 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.
Gemini is available as a supported provider path. See [Providers and runtimes](/reference/providers-runtimes) for auth options and current status across all providers.
:::
For source development, you also need:

View file

@ -0,0 +1,224 @@
---
title: MCP Integration Agent Teams Docs
description: Configure MCP in Agent Teams for board operations, teammate coordination, external tool servers, and custom tool development.
---
# MCP Integration
Agent Teams uses MCP in two practical layers:
| Layer | What it does | Who uses it |
| --- | --- | --- |
| Built-in board server | Exposes Agent Teams task, message, review, process, runtime, and cross-team tools | Leads and teammates launched by the app |
| External MCP servers | Add optional tools such as browser automation, design context, docs search, or company systems | Users and configured runtimes |
Keep those layers separate. The built-in `agent-teams` MCP server is how agents coordinate inside Agent Teams. External MCP servers are optional runtime tools.
## How Agent Teams injects MCP
When the desktop app launches Claude-based team members, it writes a temporary `--mcp-config` JSON file containing the built-in `agent-teams` server:
```json
{
"mcpServers": {
"agent-teams": {
"command": "node",
"args": ["/path/to/agent-teams-mcp/index.js"],
"env": {
"AGENT_TEAMS_MCP_CLAUDE_DIR": "/Users/you/.claude"
}
}
}
}
```
In development, the command may point at `mcp-server/src/index.ts` through `tsx`. In packaged builds, the app copies the bundled MCP server to a stable app-data path and runs it with Node. The generated file is app-owned and cleaned up best effort.
User and project MCP servers remain separate. The app reads installed servers from:
| Scope | Location |
| --- | --- |
| User | `~/.claude.json` under `mcpServers` |
| Local project entry in Claude config | `~/.claude.json` under `projects[projectPath].mcpServers` |
| Project | `<project>/.mcp.json` under `mcpServers` |
Prefer project scope for tools that belong to one repository. Prefer user scope for tools you reuse across unrelated projects.
## Project `.mcp.json` example
Place this file at the project root when a team should see the same project-scoped server:
```json
{
"mcpServers": {
"docs-search": {
"command": "npx",
"args": ["-y", "@acme/docs-search-mcp"],
"env": {
"DOCS_INDEX_PATH": "./docs-index"
}
},
"local-browser": {
"command": "node",
"args": ["./tools/mcp/browser-server.js"]
}
}
}
```
Keep secrets out of committed `.mcp.json` files. Put credentials in your shell, a user-scoped config, or the app's custom MCP install flow if the value must stay local.
## Board MCP workflow
Agents should use board MCP tools when the work belongs to a task:
1. Read the latest task context.
2. Start the task only when actually beginning work.
3. Add task comments for blockers, plans, and final results.
4. Mark the task complete after the result comment is posted.
5. Send a short message when a lead or teammate needs to know the result.
Example agent flow:
```text
task_get -> task_start -> edit/test -> task_add_comment -> task_complete -> message_send
```
Use a direct message for coordination. Use a task comment for durable task history.
::: tip
If the note affects review, verification, changed scope, or a blocker, put it on the task.
:::
## Built-in Agent Teams tools
The MCP server registers tools from `agent-teams-controller/src/mcpToolCatalog.js`. The registration loop lives in `mcp-server/src/tools/index.ts`, and each group has its own file under `mcp-server/src/tools/`.
Common operational tools:
| Tool | Use |
| --- | --- |
| `task_get` | Read the latest task context, comments, attachments, status, and relations |
| `task_start` | Mark a task in progress when work actually begins |
| `task_add_comment` | Add blocker notes, verification notes, plans, and final result summaries |
| `task_complete` | Complete a task after the final result comment is posted |
| `message_send` | Send a visible inbox message to a lead, teammate, or user |
| `review_request`, `review_start`, `review_approve`, `review_request_changes` | Move task-scoped review workflows |
| `process_register`, `process_list`, `process_stop`, `process_unregister` | Track teammate-owned dev servers, watchers, and other background services |
Tool names may appear to runtimes with MCP namespace prefixes, for example `mcp__agent-teams__task_get`. The canonical tool name inside the MCP server remains `task_get`.
## Register a new built-in tool
For Agent Teams repository work, add built-in board tools through the existing FastMCP structure:
1. Add the tool implementation to the matching file in `mcp-server/src/tools/`, or create a new group file if the domain is genuinely new.
2. Add the tool name to the appropriate group in `agent-teams-controller/src/mcpToolCatalog.js`.
3. Wire a new group through `mcp-server/src/tools/index.ts` only when a new domain group is needed.
4. Validate input with `zod` and call the controller API instead of reading board files directly.
5. Add focused tests in `mcp-server/test/tools.test.ts` or an e2e case when the transport matters.
Minimal shape:
```ts
server.addTool({
name: 'task_example',
description: 'Explain what this tool does for agents.',
parameters: z.object({
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
taskId: z.string().min(1)
}),
execute: async ({ teamName, claudeDir, taskId }) => {
assertConfiguredTeam(teamName, claudeDir);
const controller = getController(teamName, claudeDir);
return jsonTextContent(controller.tasks.getTask(taskId));
}
});
```
Do not create a tool that bypasses controller validation, mutates unrelated team files, or exposes broad filesystem/process access without a narrow task need.
## External MCP servers
Use external MCP servers when a teammate needs a durable tool connection, not just one prompt with pasted context.
Good fits:
- browser or website testing tools
- design or product data tools
- internal docs and search systems
- issue tracker or support systems
- database inspection tools with read-only credentials
Poor fits:
- secrets pasted into prompts
- one-off files that can be attached directly
- tools that mutate production systems without review
- broad local filesystem access when a narrower project scope is enough
## Scopes
Agent Teams recognizes shared and project-oriented MCP scopes.
| Scope | Use when |
| --- | --- |
| User or Global | The same server should be available across projects |
| Project or Local | The server belongs to one repository, workspace, or team context |
Prefer the narrowest scope that still makes the workflow usable. Project-scoped servers are easier to reason about during review because the tool belongs to the project being changed.
## Setup checklist
Before assigning a task that depends on an MCP server:
1. Install or configure the server.
2. Confirm it appears in the app's installed MCP list for the intended scope.
3. Run diagnostics from the MCP registry or extensions UI when available.
4. Start with a low-risk read-only task.
5. Mention the expected MCP tool use in the task description or team brief.
If a server fails diagnostics, fix that first. A better task prompt will not repair a missing command, wrong config path, or rejected credentials.
## Install a custom server from the app
The desktop app exposes MCP registry APIs through Electron IPC for search, browse, install, custom install, uninstall, installed-state reading, and diagnostics. Custom installs validate the server name, scope, project path, env var names, and HTTP headers before calling the runtime install path.
Use custom install when you have an MCP package that is not in the registry yet:
| Field | Example |
| --- | --- |
| Server name | `docs-search` |
| Scope | `project` for this repository, `user` for all projects |
| Type | `stdio` for local commands, `http` or `sse` for remote servers |
| Package | `@acme/docs-search-mcp` |
| Env | `DOCS_INDEX_PATH=./docs-index` |
After install, run diagnostics and create a small read-only task to prove the tool surface before assigning larger work.
## Task example
```text
Audit the docs home page with the browser MCP. Check desktop and mobile widths, capture any layout issue as a task comment, and only edit landing/product-docs files. Run `pnpm --dir landing docs:build` before completion.
```
This works because it names the tool, the surface, the write boundary, and the verification step.
## Safety rules
- Do not give every teammate every MCP server by default.
- Keep write-capable tools out of broad teams unless review requires them.
- Prefer read-only credentials for inspection tasks.
- Put production-impacting tool use behind explicit task comments and review.
- Treat MCP diagnostic failures as setup failures, not agent failures.
- Avoid committing secrets in `.mcp.json` or prompts.
- Use absolute `projectPath` values when installing project-scoped servers through the app.
- Do not edit the app-generated `agent-teams-mcp-*.json` files; they are temporary launch artifacts.
## Related guides
- [Runtime setup](/guide/runtime-setup)
- [Team brief examples](/guide/team-brief-examples)
- [Agent workflow](/guide/agent-workflow)
- [Developers](/developers/)

View file

@ -9,12 +9,16 @@ 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 <a href="https://github.com/777genius/agent-teams-ai/releases" target="_self">download page</a> or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
Download the latest release for your platform from the <a href="/download/" target="_self">download page</a> 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 — see [Installation](/guide/installation) for details.
:::
::: info
The desktop app is the primary product. Agent Teams also runs in a browser for development, but the browser path lacks the full desktop IPC, terminal, provider auth, and team lifecycle behavior. Use `pnpm dev` (Electron) for normal development, not the browser/web dev mode.
:::
## 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.
@ -42,7 +46,7 @@ The setup flow auto-detects installed runtimes on your machine. A common first s
| OpenCode | Multi-model teams and many provider backends |
::: info
Gemini support is in development and will appear in the runtime list when available.
Gemini is available as a supported provider path. See [Providers and runtimes](/reference/providers-runtimes) for auth options and current provider status.
:::
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
@ -84,7 +88,7 @@ Improve the onboarding flow. Split the work into tasks, keep changes small, and
Good first prompts include concrete scope, safety boundaries, and verification:
```text
Improve the docs quickstart. Keep edits inside landing/product-docs. Add practical examples, preserve existing VitePress syntax, and run the docs build before marking tasks done.
Improve the docs quickstart. Keep edits inside landing/product-docs. Add practical examples, preserve existing VitePress syntax, and run `pnpm --dir landing docs:build` before marking tasks done.
```
Avoid vague prompts such as "make the app better" for the first run. The lead can break down large goals, but better input produces smaller tasks and cleaner review.

View file

@ -7,6 +7,17 @@ description: Configure Claude Code, Codex, or OpenCode runtimes. Covers auth, pr
Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers.
::: tip Quick start - choosing your first runtime
| If you ... | Start with |
| --- | --- |
| Already use Claude Code or have Anthropic access | **Claude** - familiar auth, minimal setup |
| Use Codex or OpenAI-based workflows | **Codex** - native integration |
| Want multi-model routing or broad provider coverage | **OpenCode** - most flexible, one config for many backends |
| Are not sure which runtime fits | **OpenCode** - covers the most provider options and lets you switch later |
Start with one runtime and one teammate. Confirm one launch works before expanding to multimodel.
:::
## Prerequisites
Before launching a team, make sure:
@ -40,7 +51,7 @@ Run the command for the runtime you plan to use. If it prints nothing, install t
The app detects supported runtimes and guides setup from the UI when possible.
Gemini appears in some internal provider lists but is currently hidden from the main team creation UI while its launch experience is still marked in development.
Gemini is available as a supported provider path with Google ADC (`gcloud auth`), Gemini CLI OAuth, and API key authentication. Configure it from the runtime setup UI when the Gemini backend is detected.
## Provider access
@ -109,6 +120,16 @@ Example model strings:
If OpenCode launches but a teammate never becomes deliverable, inspect lane evidence before assuming the model ignored the prompt. See [Troubleshooting](/guide/troubleshooting#opencode-registered-but-bootstrap-unconfirmed).
### Gemini
Gemini supports three authentication methods:
- **Google ADC** — run `gcloud auth application-default login` to authenticate via Google Application Default Credentials.
- **Gemini CLI** — run `gemini login` if the Gemini CLI is installed.
- **API key** — set `GEMINI_API_KEY` in your environment or configure it through the app's Manage Providers UI.
The app auto-detects which auth method is available and shows the Gemini provider in the runtime setup and team creation UI when the backend is reachable.
## 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.

View file

@ -0,0 +1,130 @@
---
title: Team Brief Examples Agent Teams Docs
description: Practical team brief templates for small fixes, docs work, implementation tasks, reviews, and high-risk areas.
---
# Team Brief Examples
A good team brief gives the lead enough structure to create small tasks without forcing every implementation detail upfront.
Use this shape:
```text
Outcome:
Scope:
Boundaries:
Coordination:
Verification:
Review:
```
## Minimal brief
Use for small, low-risk work.
```text
Outcome: Improve the quickstart so a new user can launch one team successfully.
Scope: Keep edits inside landing/product-docs.
Boundaries: Do not rewrite the whole docs structure.
Coordination: Create one or two tasks, keep comments on the task.
Verification: Run `pnpm --dir landing docs:build`.
Review: Summarize changed pages and any remaining gaps.
```
## Implementation brief
Use when code changes touch one feature area.
```text
Outcome: Add a focused improvement to task comment filtering.
Scope: Work inside the task/comment feature files unless a shared helper is clearly needed.
Boundaries: Do not change task storage format or review state semantics.
Coordination: Split parser, UI, and tests into separate tasks if they can be reviewed independently.
Verification: Run the focused unit tests first, then the feature typecheck if touched.
Review: Call out parsing edge cases and any behavior that affects existing task comments.
```
## Docs brief
Use for documentation and guide work.
```text
Outcome: Draft practical workflow guides from the docs audit.
Scope: Add concise VitePress pages under landing/product-docs/guide.
Boundaries: Avoid moving existing navigation hubs owned by other tasks.
Coordination: Check related docs tasks before editing nav.
Verification: Run `pnpm --dir landing docs:build`.
Review: Include links added to sidebar and any pages intentionally left as drafts.
```
## Review-heavy brief
Use for risky areas such as IPC, provider auth, persistence, Git, or task lifecycle logic.
```text
Outcome: Fix the launch failure without changing successful launch behavior.
Scope: Start from the newest launch-failure artifact and the affected runtime adapter.
Boundaries: Do not change provider prompts until setup and runtime evidence are inspected.
Coordination: Make one diagnostic task and one fix task if the cause is confirmed.
Verification: Run focused tests and one desktop smoke check when practical.
Review: Lead must inspect the diff before approval.
```
## Mixed provider brief
Use when teammates run different provider/model lanes.
```text
Outcome: Implement and review a small feature using separate builder and reviewer lanes.
Scope: Builder edits the feature. Reviewer inspects only the task diff and tests.
Boundaries: Do not switch model ids mid-task unless launch fails before work begins.
Coordination: Builder posts result comment first. Reviewer posts findings as task comments.
Verification: Builder runs focused tests. Reviewer checks failure output and changed scope.
Review: Lead approves only after reviewer comments are resolved.
```
## Agent blocks in briefs
Agent blocks are hidden agent-only text wrapped in markers such as `<info_for_agent>...</info_for_agent>`. The app strips them from normal display but keeps them available for agent coordination. Use them when the brief needs to say something to agents that would be noise for a human reader.
Example - a brief that tells the lead how to split work without exposing coordination instructions to the user:
```text
Outcome: Add a dark mode toggle to the application settings.
Scope: Settings UI, theme context, and CSS variables.
Boundaries: Do not change existing light theme values or provider auth screens.
<info_for_agent>
Split this into three tasks: (1) theme context and CSS vars, (2) toggle component and settings wiring, (3) dark mode preview in existing docs screenshots if practical.
</info_for_agent>
```
The block keeps the human-facing brief clean while giving the lead structured task-splitting guidance.
## What to avoid
| Weak brief | Better replacement |
| --- | --- |
| "Improve the app" | Name the workflow, files, and success check |
| "Fix all docs" | Pick one guide group and one build command |
| "Use the best model" | Name provider/model choices or let the app defaults stand |
| "Refactor as needed" | State which modules are allowed to change |
| "Make it production ready" | Define review, tests, and rollout checks |
## Before launch
Check these points before starting the team:
1. The brief names a concrete outcome.
2. Risk boundaries are explicit.
3. The lead can split the work into reviewable tasks.
4. Verification commands are included when known.
5. Sensitive areas require review before approval.
If the brief is still broad, launch a solo or small team first and ask it to produce a task plan rather than implementation.
## Related guides
- [Create a team](/guide/create-team)
- [MCP integration](/guide/mcp-integration)
- [Git and worktree strategy](/guide/git-worktree-strategy)

View file

@ -25,6 +25,8 @@ Run the runtime binary in a terminal to verify `PATH` and auth. Example: `claude
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
Contributor/debugging details live in [Contributor Architecture](/reference/contributor-architecture), which links to the canonical agent team debugging runbook.
Look at the newest launch failure artifact:
```bash
@ -49,6 +51,57 @@ jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane
Always correlate UI diagnostics with persisted files (`launch-state.json`, `bootstrap-journal.jsonl`) and runtime-specific evidence.
:::
## General diagnostics
Start with persisted files on disk rather than the UI alone.
### Team root
```bash
~/.claude/teams/<team>/
```
Key files and what they tell you:
- `launch-state.json` — member launch/liveness state (`.teamLaunchState`, `.summary`, `.members`)
- `bootstrap-journal.jsonl` — ordered bootstrap events from CLI/runtime (`tail -80`)
- `bootstrap-state.json` — bootstrap phase summary
- `config.json` — provider, model, and project configuration
- `inboxes/*.json` and `sentMessages.json` — message delivery state
```bash
jq '.teamLaunchState, .summary, .members' ~/.claude/teams/<team>/launch-state.json
tail -80 ~/.claude/teams/<team>/bootstrap-journal.jsonl 2>/dev/null
```
### OpenCode runtime evidence
For OpenCode teammates, session proof is in the lane runtime store:
- `.opencode-runtime/lanes.json` — lane index with state
- `.opencode-runtime/lanes/<lane>/manifest.json``activeRunId` and evidence entries
- `.opencode-runtime/lanes/<lane>/opencode-sessions.json` — committed session records
Expected healthy state: lane state `active`, manifest has `activeRunId` with at least one evidence entry, member has `bootstrapConfirmed: true`.
```bash
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json 2>/dev/null
find ~/.claude/teams/<team>/.opencode-runtime -maxdepth 3 -type f | sort
```
### Launch failure artifacts
When a launch is marked as a failure, inspect `latest.json`:
```bash
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
```
The manifest includes:
- `classification` — why the launch was considered a failure
- `bootstrapTransportBreadcrumb` — delivery path used
- Member spawn statuses and redacted logs/traces
## Agent replies are missing
Open task logs and teammate messages. Missing replies often come from:

View file

@ -57,13 +57,24 @@ Agent Teams is a free desktop app for orchestrating AI agent teams. You are not
<DocsCardGrid />
## Next steps after launch
After creating your first team, explore these guides to go further:
- **Runtime setup** - configure Claude, Codex, OpenCode, or multimodel providers: [Configure runtimes](/guide/runtime-setup)
- **Agent workflow** - understand how agents coordinate through the task board: [Understand workflow](/guide/agent-workflow)
- **Team brief examples** - learn prompt patterns from real-world briefs: [See examples](/guide/team-brief-examples)
- **Code review** - inspect diffs, accept or reject changes: [Review changes](/guide/code-review)
- **Troubleshooting** - diagnose stuck launches, missing teammates, and task failures: [Troubleshoot](/guide/troubleshooting)
- **Git worktree strategy** - use worktree isolation when multiple teammates edit the same repo in parallel: [Learn about worktrees](/guide/git-worktree-strategy)
- **Release notes** - see what's new in each version: [View releases](/reference/release-notes)
## Reference
Use the reference pages when you need exact terminology, provider behavior, or privacy boundaries.
Use the reference pages when you need exact terminology, provider behavior, contributor architecture, or privacy boundaries.
<DocsCardGrid type="reference" />
## Product preview
<ZoomImage src="/screenshots/1.jpg" alt="Agent Teams kanban board" caption="Task status, teammate activity, and review workflow stay visible in one workspace." />

View file

@ -1,5 +1,5 @@
---
title: Concepts
title: Concepts Agent Teams Docs
description: Core vocabulary for Agent Teams — teams, leads, teammates, tasks, kanban, inboxes, runtimes, and review.
---
@ -45,7 +45,7 @@ Messages are durable local records. Delivery still depends on the selected runti
An agent block is hidden, agent-only instruction text wrapped with `<info_for_agent>...</info_for_agent>`. 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.
The current canonical marker is `info_for_agent`. Older documents may use fenced code blocks with an `info_for_agent` marker, or XML-style `<agent_block>` tags — these are legacy patterns and should be migrated to `info_for_agent` when encountered. (The original tag name was `agent-block`; the underscore form `<agent_block>` is used in VitePress source to avoid HTML parsing.)
## Context Phase

View file

@ -0,0 +1,54 @@
---
title: Contributor Architecture Agent Teams Docs
description: Contributor guide to feature layout, runtime/provider boundaries, hard guardrails, and canonical architecture documents.
---
# Contributor Architecture
This page is a map for contributors. It points to the canonical repo guidance instead of restating every implementation rule.
## Canonical sources
Use these files as the source of truth when changing the app:
| Need | Canonical source |
| --- | --- |
| Repo overview and commands | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
| Local working conventions | [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) |
| Hard guardrails | [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) |
| Medium and large feature layout | [docs/FEATURE_ARCHITECTURE_STANDARD.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) |
| Agent team launch debugging | [docs/team-management/debugging-agent-teams.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) |
## Feature layout
Medium and large features should live under `src/features/<feature-name>/` and follow the feature architecture standard. Keep feature internals behind public entrypoints, and avoid deep imports across feature boundaries.
For new work, start with the existing `src/features/recent-projects` slice as the local reference implementation. Small fixes can stay close to the existing code path when creating a feature slice would add more structure than value.
## Runtime and provider boundaries
Agent Teams owns orchestration: teams, tasks, messages, launch state, review UI, diagnostics, and local persistence.
The selected runtime/provider path owns model execution, auth, model availability, rate limits, tool semantics, and runtime-specific transcript evidence. Do not make prompts or UI state compensate for missing auth, missing binaries, rejected model ids, or provider outages. For user-facing setup details, see [Providers and Runtimes](/reference/providers-runtimes).
## Agent team debugging
For launch hangs, OpenCode `registered` / bootstrap-unconfirmed states, missing teammate replies, or suspicious task logs, start from the dedicated debugging runbook. Inspect the newest launch failure artifact under `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`, then correlate UI state with persisted files and runtime-specific evidence.
Avoid broad cleanup while debugging. Stop only the process, lane, team, or smoke run you can identify as belonging to the issue.
## Contributor conventions
- Use `pnpm dev` for the desktop Electron app during normal development.
- Do not use browser dev mode as a substitute for desktop runtime, IPC, terminal, provider auth, or team lifecycle behavior.
- Keep Electron main, preload, renderer, shared, and feature responsibilities separate.
- Use `wrapAgentBlock(text)` for agent-only blocks instead of manually concatenating markers.
- Prefer focused verification. Avoid broad `lint:fix` or formatting churn unless the task is explicitly about formatting.
- Treat parsing, task lifecycle, provider/runtime detection, persistence, IPC, Git, and review flows as high-risk areas that need targeted tests or a clear verification path.
## Related pages
- [Runtime setup](/guide/runtime-setup)
- [Troubleshooting](/guide/troubleshooting)
- [Code review](/guide/code-review)
- [Privacy and local data](/reference/privacy-local-data)

View file

@ -41,7 +41,7 @@ No. Agent Teams is not a cloud code-sync service. Provider-backed model calls ma
## Where are team files stored?
Team coordination data is stored locally under `~/.claude/teams/<team>/`, task files under `~/.claude/tasks/<team>/`, and project session data under `~/.claude/projects/<encoded-project>/` when available.
Team coordination data is stored locally under `~/.claude/teams/<team>/` (macOS/Linux) or `%APPDATA%\Claude\teams\<team>\` (Windows), task files under `~/.claude/tasks/<team>/` or `%APPDATA%\Claude\tasks\<team>\`, and project session data under `~/.claude/projects/<encoded-project>/` when available.
## What can leave my machine?
@ -56,7 +56,7 @@ Yes. Agents can message teammates, comment on tasks, coordinate across teams, an
Give the lead a concrete outcome, file or feature boundaries, risk limits, and verification expectations. For example:
```text
Improve the docs quickstart. Keep edits inside landing/product-docs, add practical examples, and run the docs build before marking work done.
Improve the docs quickstart. Keep edits inside landing/product-docs, add practical examples, and run `pnpm --dir landing docs:build` before marking work done.
```
## Can I review code before accepting it?

View file

@ -22,13 +22,16 @@ The desktop app runs on your machine and reads local project/runtime data to pow
Important local locations include:
| Location | Purpose |
| --- | --- |
| `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state, and review-related team files. |
| `~/.claude/tasks/<team>/` | Durable task JSON files for the team board. |
| `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files used for session history, context analysis, and transcript-backed UI. |
| Platform | Location | Purpose |
| --- | --- | --- |
| macOS/Linux | `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state, and review-related team files. |
| Windows | `%APPDATA%\Claude\teams\<team>\` | Same — team config, member metadata, inboxes, launch state, and diagnostics. |
| macOS/Linux | `~/.claude/tasks/<team>/` | Durable task JSON files for the team board. |
| Windows | `%APPDATA%\Claude\tasks\<team>\` | Same — durable task JSON files. |
| macOS/Linux | `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files used for session history, context analysis, and transcript-backed UI. |
| Windows | `%APPDATA%\Claude\projects\<encoded-project>\` | Same — project session files. |
Exact files can vary by runtime and app version. For launch debugging, the newest evidence is usually under the relevant `~/.claude/teams/<team>/` folder.
Exact files can vary by runtime and app version. For launch debugging, the newest evidence is usually under the relevant `~/.claude/teams/<team>/` (or `%APPDATA%\Claude\teams\<team>\`) folder.
## What can leave your machine

View file

@ -38,7 +38,7 @@ The runtime provides:
| 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. |
Gemini provider ids exist in internal configuration paths, but Gemini is currently hidden from the main team creation UI while the launch flow remains in development.
Gemini is available as a supported provider path with Google ADC (gcloud auth), Gemini CLI OAuth, and API key authentication. It appears alongside other providers in the team creation and runtime setup UI when the runtime reports it as available.
## Provider ids
@ -48,7 +48,7 @@ The app currently recognizes these provider ids in team/runtime configuration:
| --- | --- |
| `anthropic` | Anthropic / Claude Code path |
| `codex` | Codex path |
| `gemini` | Gemini provider path when exposed by the runtime |
| `gemini` | Gemini provider path (Google ADC, Gemini CLI, or API key) |
| `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.
@ -76,6 +76,8 @@ Agent Teams keeps orchestration provider-aware but not provider-owned:
- 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
For contributor-facing boundaries and canonical implementation guidance, see [Contributor Architecture](/reference/contributor-architecture).
Recommended patterns:
| Pattern | When it helps | Risk |

View file

@ -0,0 +1,41 @@
---
title: Release Notes Agent Teams Docs
description: Release notes and changelog for Agent Teams. Links to the canonical RELEASE.md and CHANGELOG.md for full details.
---
# Release Notes
Current release: **v1.2.0** (2026-03-31). Active development continues on the `main` branch with unreleased changes for member work-sync, OpenCode delivery hardening, and CI stabilization.
## How releases work
Agent Teams follows [Semantic Versioning](https://semver.org/). Tags pushed to the repository trigger an automated [release workflow](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) that builds signed packages for macOS, Windows, and Linux, then publishes them to GitHub Releases.
## Recent releases
### v1.2.0 — Agent Graph, per-team tool approval, interactive AskUserQuestion
Agent Graph with force-directed visualization and kanban task layout, per-team tool approval controls with readable permission prompts, task comment notifications, and interactive AskUserQuestion buttons. Permission system overhaul with Write/Edit/NotebookEdit seeding and MCP tool catalog integration. See [full changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#120---2026-03-31).
### v1.1.0 — React 19 + Electron 40, user-initiated task starts
React 19 + Electron 40 migration, user-initiated task starts from the kanban board, auth troubleshooting guide, syntax highlighting for R/Ruby/PHP/SQL, 3x faster transcript search, WSL/Windows path fixes, and XSS vulnerability fix. See [full changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#110---2026-03-25).
### v1.0.0 — Initial public release
First stable build: CLI/auth reliability in packaged apps, IPC hardening, cross-platform packaging with signed macOS builds, open-source governance docs (LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY). See [full changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#100---2026-03-23).
## Canonical sources
| Document | Description |
| --- | --- |
| [RELEASE.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) | Release process, versioning guide, artifact naming, auto-update setup, and release notes template. |
| [CHANGELOG.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md) | Full changelog with all versions, features, improvements, and bug fixes from the user perspective. |
| [GitHub Releases](https://github.com/777genius/agent-teams-ai/releases) | Downloadable installers for all platforms. |
## Related pages
- [Installation](/guide/installation)
- [Quickstart](/guide/quickstart)
- [Contributor architecture](/reference/contributor-architecture)
- [Developers](/developers/)

View file

@ -0,0 +1,68 @@
---
title: Разработчикам Agent Teams Docs
description: Входная страница для contributor docs, архитектуры, guardrails, debugging и MCP extension paths в Agent Teams.
---
# Разработчикам
Эта страница нужна, когда вы меняете Agent Teams, разбираете зависший запуск команды или расширяете runtime через MCP tools. Ссылки ведут в canonical repo docs, чтобы правила реализации не расходились между страницами.
## С чего начать
| Нужно | Открыть |
| --- | --- |
| Обзор репозитория, scripts и setup из исходников | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
| Рабочие правила для агентов и contributors | [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) |
| Жёсткие implementation guardrails | [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) |
| Структура medium и large features | [Feature architecture standard](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) |
| Debugging launch, bootstrap и teammate messaging | [Agent team debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) |
| Contribution process | [Contributing guide](https://github.com/777genius/agent-teams-ai/blob/main/.github/CONTRIBUTING.md) |
| Приватность и модель данных | [Privacy and local data](/ru/reference/privacy-local-data) |
| Релизы / Changelog | [RELEASE.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) — [CHANGELOG.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md) |
## Локальный development path
Для обычной разработки запускайте desktop Electron app:
```bash
pnpm install
pnpm dev
```
Browser/web path не заменяет desktop runtime. Desktop mode - поддерживаемый локальный путь, потому что в нём есть IPC, terminals, provider auth, team lifecycle handling, launch diagnostics и runtime bridges, которые используют реальные команды.
## Architecture checkpoints
Перед изменением feature определите её границу:
| Область | Ожидаемое место |
| --- | --- |
| Medium или large product feature | `src/features/<feature-name>/` |
| Electron main process orchestration | `src/main/` |
| Preload-safe API surface | `src/preload/` |
| Renderer UI и app state | `src/renderer/` |
| Shared types и pure helpers | `src/shared/` |
| Agent Teams board MCP server | `mcp-server/` |
| Board data controller | `agent-teams-controller/` |
Используйте `src/features/recent-projects` как reference slice для feature organization. Держите cross-process contracts явными и не делайте deep imports через feature boundaries.
## Debugging path
Для launch hangs, OpenCode `registered` / bootstrap-unconfirmed states, missing teammate replies или suspicious task logs:
1. Начните с [debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md).
2. Проверьте самый новый artifact pack в `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`.
3. Откройте `manifest.json` и посмотрите `classification`, bootstrap breadcrumbs, launch diagnostics, member spawn statuses и redacted log tails.
4. Очищайте только team, run, pane или process, который точно принадлежит smoke test или failed launch.
## MCP development path
Agent Teams использует встроенный MCP server `agent-teams` для board operations. User и project MCP servers добавляют внешние capabilities для runtimes. См. [MCP integration](/ru/guide/mcp-integration) для setup examples, структуры `.mcp.json` и tool registration guidance.
## Related docs
- [Архитектура для контрибьюторов](/ru/reference/contributor-architecture)
- [Настройка рантайма](/ru/guide/runtime-setup)
- [MCP интеграция](/ru/guide/mcp-integration)
- [Диагностика](/ru/guide/troubleshooting)

View file

@ -19,25 +19,43 @@ Agent Teams делает работу агентов видимой через t
## Жизненный цикл задачи
Agent Teams отслеживает каждую задачу в двух независимых измерениях: work status и review state.
| Измерение | Состояния | Описание |
|-----------|-----------|----------|
| Work status | `pending`, `in_progress`, `completed` | Отслеживает, ожидает ли задача, активно выполняется или завершена исполнителем |
| Review state | `none`, `review`, `needsFix`, `approved` | Отслеживает положение задачи в процессе ревью после завершения |
На канбан-доске отображается комбинированное представление, но два измерения движутся независимо.
### Рабочий статус
| Этап | Что происходит | Ответственный |
|------|---------------|---------------|
| Provisioning | Приложение запускает runtime, проверяет, что процесс жив, и ждёт подтверждения bootstrap | Приложение |
| Planning | Lead создаёт задачи, назначает teammates и задаёт зависимости | Lead или пользователь |
| In progress | Агенты работают параллельно и обновляют статус задач через board MCP tools | Teammates |
| Review | Изменения проверяют агенты или вы перед финальным принятием | Team lead или пользователь |
| Done | Принятая работа остаётся связанной с историей задачи и доступна для инспекции | Пользователь |
| Pending | Задача создана, но никто ещё не начал работу | Lead или пользователь |
| In progress | Агенты работают и обновляют статус через board MCP tools | Teammates |
| Completed | Исполнитель публикует result comment и помечает задачу завершённой | Teammate |
### Статус ревью
| Этап | Что происходит | Ответственный |
|------|---------------|---------------|
| None | Задача ещё не на ревью (может быть pending, in progress или только что completed) | — |
| Review | Запрошено ревью; reviewer проверяет diff и результат | Reviewer |
| Needs fix | В ходе ревью запрошены правки; исполнитель должен обновить задачу | Teammate (owner) |
| Approved | Ревью пройдено; задача финализирована | Reviewer |
### Planning → In progress
Когда teammate берёт задачу, статус на доске меняется на `in_progress`. Агент создаёт task comment с планом работы и продолжает. Все нативные инструменты (read, bash, edit, write) попадают в task log.
Когда teammate берёт задачу, work status меняется на `in_progress`. Агент создаёт task comment с планом работы и продолжает. Все нативные инструменты (read, bash, edit, write) попадают в task log.
### In progress → Review
### Completed → Review
Когда teammate завершает работу, он публикует result comment и помечает задачу `completed`. Lead затем решает — принять сразу или отправить на ревью.
Когда teammate завершает работу, он публикует result comment и помечает work status как `completed`. Lead или reviewer могут запросить ревью для начала проверки.
### Review → Done
### Review → Approved
Если изменения в review surface выглядят приемлемо, approve the review. Задача финализируется и связывается со своим diff.
Если изменения в review surface выглядят приемлемо, утвердите ревью. Задача финализируется и связывается со своим diff.
::: warning Ревью с правками
Если teammate попросили внести правки во время ревью, он должен добавить follow-up comment с исправлениями, после чего lead может approve.

View file

@ -55,7 +55,7 @@ lang: ru-RU
Пример request-changes comment:
```text
Please keep the copy improvements, but revert the unrelated runtime wording in the provider table. Add a docs build result before resubmitting.
Please keep the copy improvements, but revert the unrelated runtime wording in the provider table. Add the `pnpm --dir landing docs:build` result before resubmitting.
```
## Состояния ревью
@ -87,6 +87,8 @@ Team lead — ревьюер по умолчанию. Вы можете наст
- **Parsing и task lifecycle logic** — изменения в task references, chunking или filtering могут сломать доставку сообщений
- **Persistence и code review flows** — изменения в хранении задач или review state должны оставаться консистентными через IPC layers
Canonical feature layout и hard guardrail links смотрите в [Архитектуре для контрибьюторов](/ru/reference/contributor-architecture).
## Верификация
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.

View file

@ -31,7 +31,7 @@ lang: ru-RU
Микс провайдеров в одной команде поддерживается — например, Claude lead с OpenCode builder-ами.
::: info
Поддержка Gemini в разработке и появится в списке провайдеров, когда будет готова.
Gemini — поддерживаемый провайдер. Варианты auth смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
:::
## Хороший team brief

View file

@ -0,0 +1,99 @@
---
title: Git и стратегия worktree Документация Agent Teams
description: Как выбирать main worktree, feature branches или OpenCode worktree isolation для parallel agent work.
lang: ru-RU
---
# Git и стратегия worktree
Git даёт Agent Teams самый сильный review path: narrow diffs, branch visibility, task-scoped changes и более безопасную parallel work.
## Выбор стратегии
| Strategy | Когда использовать | Tradeoff |
| --- | --- | --- |
| Main worktree | Solo work, docs-only edits или один teammate за раз | Просто, но parallel edits могут конфликтовать |
| Feature branch | Одна team работает над одним coherent change | Чистый review target, но teammates всё ещё делят files |
| Worktree isolation | Несколько OpenCode teammates могут параллельно менять один repo | Лучше isolation, но merge/review требует дисциплины |
Начинайте просто. Включайте worktree isolation, когда parallel edits вероятны, а не потому что каждому task нужен отдельный checkout.
## Когда включать изоляцию worktree
Включайте для OpenCode teammates, когда:
- два или больше teammates могут менять один repository одновременно
- task может запускать formatters, code generators или broad tests
- нужно держать branch и diff каждого teammate отдельно
- lead workspace dirty и не должен получать прямые edits
Оставляйте выключенным, когда:
- task read-only
- один teammate владеет всеми edits
- repo не Git-tracked
- нужен runtime path, который не поддерживает этот isolation mode
::: warning
Worktree isolation сейчас применяется к OpenCode members и требует Git-tracked project.
:::
## Гигиена веток
Перед parallel work:
```bash
git status --short
git branch --show-current
```
По возможности используйте clean branch. Если main worktree уже содержит user changes, скажите agents не revert unrelated files и держать task scope узким.
Рекомендуемый branch style:
```text
agent/<team-or-task>/<short-purpose>
```
Примеры:
```text
agent/docs/mcp-guide
agent/review/task-log-filtering
agent/ui/code-review-polish
```
## Процесс ревью
Для isolated worktrees проверяйте diff teammate до merge или apply в main workspace.
1. Убедитесь, что task result comment называет changed scope и verification.
2. Проверьте task diff в review UI.
3. Запросите changes в task, если diff трогает unrelated files.
4. Approve только когда tests или manual checks соответствуют risk.
5. Merge или apply changes осознанно.
Не auto-merge worktree output только потому, что task complete. Completion значит, что agent считает работу ready for review.
## Политика разрешения конфликтов
| Situation | Action |
| --- | --- |
| Два teammates меняют один file | Pause one task или назначьте одного owner для integration |
| Generated files changed broadly | Требуйте comment с generator и command |
| Main worktree имеет unrelated changes | Preserve them и review только task-owned changes |
| Worktree branch diverges | Rebase или merge manually после review, не внутри vague agent task |
## Пример промпта для задачи
```text
Implement the settings validation fix in your assigned worktree. Keep edits inside src/features/settings and focused tests. Do not touch provider auth or task storage. Post the test command and result before completing the task.
```
Этот prompt работает, потому что называет allowed area, sensitive boundaries и completion evidence.
## Связанные руководства
- [Создание команды](/ru/guide/create-team)
- [Код-ревью](/ru/guide/code-review)
- [Примеры team brief](/ru/guide/team-brief-examples)

View file

@ -31,11 +31,11 @@ Agent Teams распространяется как desktop-приложение
| ------------------ | ---------------------------------------------------------- |
| Claude (Anthropic) | Claude Code CLI login или API key |
| Codex (OpenAI) | Codex CLI login или API key |
| Gemini (Google) | _В разработке_ |
| Gemini (Google) | Google ADC, Gemini CLI или API key |
| OpenCode | API key для поддерживаемого бэкенда (например, OpenRouter) |
::: info
Поддержка провайдера Gemini в разработке. Вы можете подготовить доступ сейчас, но он не появится в редакторе команды, пока не будет готов.
Gemini — поддерживаемый провайдер. Варианты auth смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
:::
Для запуска из исходников также нужны:

View file

@ -0,0 +1,102 @@
---
title: MCP интеграция Документация Agent Teams
description: Как использовать MCP в Agent Teams для board operations, координации teammates и внешних tool servers.
lang: ru-RU
---
# MCP интеграция
Agent Teams использует MCP двумя практическими способами:
| Слой | Что делает | Кто использует |
| --- | --- | --- |
| Board MCP tools | Создают, стартуют, комментируют, завершают и читают tasks | Agents и leads |
| External MCP servers | Добавляют инструменты вроде browser, design, docs или company systems | Users и настроенные runtimes |
Держите эти слои отдельно. Board MCP нужен для координации внутри Agent Teams. External MCP servers - это дополнительные инструменты для runtimes.
## Board MCP workflow
Agents должны использовать board MCP tools, когда работа относится к task:
1. Прочитать свежий task context.
2. Стартовать task только когда реально начинают работу.
3. Добавлять task comments для blockers, plan и final results.
4. Завершать task после result comment.
5. Отправлять короткое сообщение, если lead или teammate должен увидеть результат.
Пример flow:
```text
task_get -> task_start -> edit/test -> task_add_comment -> task_complete -> message_send
```
Direct message подходит для координации. Task comment подходит для durable task history.
::: tip
Если заметка влияет на review, verification, changed scope или blocker, пишите её в task.
:::
## External MCP servers
Используйте external MCP servers, когда teammate нужен устойчивый tool connection, а не один prompt с pasted context.
Хорошие случаи:
- browser или website testing tools
- design или product data tools
- internal docs и search systems
- issue tracker или support systems
- database inspection tools с read-only credentials
Плохие случаи:
- secrets, вставленные в prompts
- one-off files, которые проще attached напрямую
- tools, которые меняют production systems без review
- широкий local filesystem access, когда достаточно project scope
## Scopes
Agent Teams распознаёт shared и project-oriented MCP scopes.
| Scope | Когда использовать |
| --- | --- |
| User или Global | Один server нужен в разных projects |
| Project или Local | Server относится к одному repository, workspace или team context |
Выбирайте самый узкий scope, который всё ещё удобен. Project-scoped servers легче проверять на review, потому что tool привязан к изменяемому project.
## Setup checklist
Перед task, который зависит от MCP server:
1. Установите или настройте server.
2. Проверьте, что он виден в installed MCP list.
3. Запустите diagnostics, если app их предлагает.
4. Начните с low-risk read-only task.
5. Укажите ожидаемый MCP tool use в task description или team brief.
Если diagnostics падают, сначала чините setup. Лучший prompt не исправит missing command, неправильный config path или rejected credentials.
## Task example
```text
Audit the docs home page with the browser MCP. Check desktop and mobile widths, capture any layout issue as a task comment, and only edit landing/product-docs files. Run `pnpm --dir landing docs:build` before completion.
```
Такой task работает, потому что называет tool, surface, write boundary и verification step.
## Safety rules
- Не выдавайте каждому teammate все MCP servers по умолчанию.
- Не добавляйте write-capable tools в broad teams без review.
- Для inspection tasks предпочитайте read-only credentials.
- Production-impacting tool use фиксируйте через explicit task comments и review.
- MCP diagnostic failures считайте setup failures, а не agent failures.
## Related guides
- [Настройка рантайма](/ru/guide/runtime-setup)
- [Примеры team brief](/ru/guide/team-brief-examples)
- [Работа агентов](/ru/guide/agent-workflow)

View file

@ -16,6 +16,10 @@ lang: ru-RU
Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру — подробности в разделе [Установка](/ru/guide/installation).
:::
::: info
Desktop-приложение — основной продукт. Agent Teams также работает в браузере для разработки, но браузерный режим не имеет полного desktop IPC, терминала, provider auth и lifecycle. Для обычной разработки используйте `pnpm dev` (Electron), а не браузерный режим.
:::
## 2. Откройте проект
Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные файлы проекта и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
@ -43,7 +47,7 @@ git status --short
| OpenCode | Для multi-model команд и большого числа provider backends |
::: info
Поддержка Gemini в разработке и появится в списке рантаймов, когда будет готова.
Gemini — поддерживаемый провайдер. Варианты auth смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
:::
Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup).
@ -85,7 +89,7 @@ opencode --version
Хороший первый prompt содержит scope, safety boundaries и verification:
```text
Improve the docs quickstart. Keep edits inside landing/product-docs. Add practical examples, preserve existing VitePress syntax, and run the docs build before marking tasks done.
Improve the docs quickstart. Keep edits inside landing/product-docs. Add practical examples, preserve existing VitePress syntax, and run `pnpm --dir landing docs:build` before marking tasks done.
```
Избегайте размытых prompts вроде "make the app better" для первого запуска. Lead может дробить большие цели, но хороший input даёт более маленькие tasks и чище review.

View file

@ -1,8 +1,3 @@
---
title: Настройка рантайма
description: Настройте Claude Code, Codex или OpenCode рантаймы и аутентификацию провайдеров для команд агентов.
---
---
title: Настройка рантайма Документация Agent Teams
description: Конфигурация Claude Code, Codex или OpenCode. Авторизация, провайдеры, multimodel mode и предзапусковые проверки.
@ -11,7 +6,7 @@ lang: ru-RU
# Настройка рантайма
Agent Teams — coordination layer. Model work выполняется через локальные runtimes и providers.
Agent Teams - координационный слой. Работа моделей выполняется через локальные рантаймы и провайдеры.
## Предварительные требования
@ -46,7 +41,7 @@ command -v opencode
Приложение по возможности определяет доступные runtimes и ведёт настройку через UI.
Gemini встречается во внутренних provider lists, но сейчас скрыт из основного team creation UI, пока launch experience отмечен как in development.
Gemini — поддерживаемый провайдер с Google ADC (`gcloud auth`), Gemini CLI OAuth и API key аутентификацией. Настройка доступна через UI управления провайдерами, когда Gemini backend обнаружен.
## Доступ к провайдеру
@ -115,6 +110,16 @@ Codex-native launches используют Codex account state и model catalog
Если OpenCode запускается, но teammate не становится deliverable, сначала смотрите lane evidence, а не предполагаете, что model проигнорировала prompt. См. [Диагностика](/ru/guide/troubleshooting#opencode-registered-но-bootstrap-не-подтверждён).
### Gemini
Gemini поддерживает три метода аутентификации:
- **Google ADC** — запустите `gcloud auth application-default login` для авторизации через Google Application Default Credentials.
- **Gemini CLI** — запустите `gemini login`, если Gemini CLI установлен.
- **API key** — установите `GEMINI_API_KEY` в переменные окружения или настройте через UI управления провайдерами.
Приложение автоматически определяет доступный метод auth и показывает провайдера Gemini в UI настройки рантайма и создания команд, когда backend доступен.
## Multimodel-режим
Multimodel-режим может направлять работу через разные provider backends в OpenCode-совместимой конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.

View file

@ -0,0 +1,131 @@
---
title: Примеры team brief Документация Agent Teams
description: Практические шаблоны team brief для small fixes, docs work, implementation tasks, review и risky areas.
lang: ru-RU
---
# Примеры team brief
Хороший team brief даёт lead достаточно структуры, чтобы создать small tasks, но не требует заранее расписать каждую деталь реализации.
Используйте форму:
```text
Outcome:
Scope:
Boundaries:
Coordination:
Verification:
Review:
```
## Minimal brief
Для маленькой low-risk работы.
```text
Outcome: Improve the quickstart so a new user can launch one team successfully.
Scope: Keep edits inside landing/product-docs.
Boundaries: Do not rewrite the whole docs structure.
Coordination: Create one or two tasks, keep comments on the task.
Verification: Run `pnpm --dir landing docs:build`.
Review: Summarize changed pages and any remaining gaps.
```
## Implementation brief
Для code changes внутри одной feature area.
```text
Outcome: Add a focused improvement to task comment filtering.
Scope: Work inside the task/comment feature files unless a shared helper is clearly needed.
Boundaries: Do not change task storage format or review state semantics.
Coordination: Split parser, UI, and tests into separate tasks if they can be reviewed independently.
Verification: Run the focused unit tests first, then the feature typecheck if touched.
Review: Call out parsing edge cases and any behavior that affects existing task comments.
```
## Docs brief
Для documentation и guide work.
```text
Outcome: Draft practical workflow guides from the docs audit.
Scope: Add concise VitePress pages under landing/product-docs/guide.
Boundaries: Avoid moving existing navigation hubs owned by other tasks.
Coordination: Check related docs tasks before editing nav.
Verification: Run `pnpm --dir landing docs:build`.
Review: Include links added to sidebar and any pages intentionally left as drafts.
```
## Review-heavy brief
Для risky areas: IPC, provider auth, persistence, Git или task lifecycle logic.
```text
Outcome: Fix the launch failure without changing successful launch behavior.
Scope: Start from the newest launch-failure artifact and the affected runtime adapter.
Boundaries: Do not change provider prompts until setup and runtime evidence are inspected.
Coordination: Make one diagnostic task and one fix task if the cause is confirmed.
Verification: Run focused tests and one desktop smoke check when practical.
Review: Lead must inspect the diff before approval.
```
## Mixed provider brief
Когда teammates работают на разных provider/model lanes.
```text
Outcome: Implement and review a small feature using separate builder and reviewer lanes.
Scope: Builder edits the feature. Reviewer inspects only the task diff and tests.
Boundaries: Do not switch model ids mid-task unless launch fails before work begins.
Coordination: Builder posts result comment first. Reviewer posts findings as task comments.
Verification: Builder runs focused tests. Reviewer checks failure output and changed scope.
Review: Lead approves only after reviewer comments are resolved.
```
## Agent blocks в briefs
Agent blocks - это скрытый текст для агентов, обёрнутый в маркеры `<info_for_agent>...</info_for_agent>`. Приложение убирает их из обычного отображения, но оставляет для координации агентов. Используйте их, когда brief должен сказать агентам то, что будет шумом для человека.
Пример - brief, который указывает lead, как разделить работу, не показывая инструкции пользователю:
```text
Outcome: Add a dark mode toggle to the application settings.
Scope: Settings UI, theme context, and CSS variables.
Boundaries: Do not change existing light theme values or provider auth screens.
<info_for_agent>
Split this into three tasks: (1) theme context and CSS vars, (2) toggle component and settings wiring, (3) dark mode preview in existing docs screenshots if practical.
</info_for_agent>
```
Блок оставляет human-facing brief чистым, а lead получает структурированные указания по разделению задач.
## What to avoid
| Weak brief | Better replacement |
| --- | --- |
| "Improve the app" | Назовите workflow, files и success check |
| "Fix all docs" | Выберите одну guide group и build command |
| "Use the best model" | Назовите provider/model choices или оставьте app defaults |
| "Refactor as needed" | Укажите modules, которые можно менять |
| "Make it production ready" | Определите review, tests и rollout checks |
## Before launch
Проверьте перед стартом:
1. Brief называет concrete outcome.
2. Risk boundaries explicit.
3. Lead может разделить работу на reviewable tasks.
4. Verification commands указаны, если известны.
5. Sensitive areas требуют review before approval.
Если brief всё ещё широкий, запустите solo или small team и попросите сначала task plan, а не implementation.
## Related guides
- [Создание команды](/ru/guide/create-team)
- [MCP интеграция](/ru/guide/mcp-integration)
- [Git и стратегия worktree](/ru/guide/git-worktree-strategy)

View file

@ -1,8 +1,3 @@
---
title: Диагностика
description: Исправление ошибок запуска, пропавших ответов агентов, rate limits, проблем auth и lane bootstrap в Agent Teams.
---
---
title: Диагностика Документация Agent Teams
description: Решение проблем с запуском команд, отсутствующими ответами агентов, rate limits, CLI auth и lane bootstrap stalls через локальные диагностики.
@ -31,6 +26,8 @@ lang: ru-RU
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала inspect artifacts, прежде чем менять team prompts.
Contributor/debugging details находятся в [Архитектуре для контрибьюторов](/ru/reference/contributor-architecture), где есть ссылка на canonical debugging runbook для agent teams.
Посмотрите на последний artifact неудачного запуска:
```bash
@ -55,6 +52,57 @@ jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane
Всегда сопоставляйте UI-диагностику с сохранёнными файлами (`launch-state.json`, `bootstrap-journal.jsonl`) и runtime-специфичными доказательствами.
:::
## Общая диагностика
Начинайте с сохранённых файлов на диске, а не только с UI.
### Корневая директория команды
```bash
~/.claude/teams/<team>/
```
Ключевые файлы и что они показывают:
- `launch-state.json` — состояние запуска/активности участников (`.teamLaunchState`, `.summary`, `.members`)
- `bootstrap-journal.jsonl` — упорядоченные события bootstrap от CLI/runtime (`tail -80`)
- `bootstrap-state.json` — сводка фазы bootstrap
- `config.json` — конфигурация провайдера, модели и проекта
- `inboxes/*.json` и `sentMessages.json` — состояние доставки сообщений
```bash
jq '.teamLaunchState, .summary, .members' ~/.claude/teams/<team>/launch-state.json
tail -80 ~/.claude/teams/<team>/bootstrap-journal.jsonl 2>/dev/null
```
### OpenCode runtime evidence
Для OpenCode участников доказательство сессии находится в lane runtime store:
- `.opencode-runtime/lanes.json` — индекс lane с состоянием
- `.opencode-runtime/lanes/<lane>/manifest.json``activeRunId` и записи evidence
- `.opencode-runtime/lanes/<lane>/opencode-sessions.json` — зафиксированные записи сессий
Ожидаемое здоровое состояние: состояние lane `active`, manifest содержит `activeRunId` хотя бы с одной записью evidence, участник имеет `bootstrapConfirmed: true`.
```bash
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json 2>/dev/null
find ~/.claude/teams/<team>/.opencode-runtime -maxdepth 3 -type f | sort
```
### Артефакты неудачного запуска
Когда запуск помечен как неудачный, проверьте `latest.json`:
```bash
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
```
Манифест включает:
- `classification` — почему запуск считался неудачным
- `bootstrapTransportBreadcrumb` — использованный путь доставки
- Статусы старта участников и редактированные логи/трейсы
## Не видны ответы агента
Откройте task logs и teammate messages. Пропавшие replies часто связаны с:

View file

@ -1,6 +1,7 @@
---
title: Документация Agent Teams Запускайте команды AI-агентов из локального desktop-приложения
description: Документация Agent Teams, бесплатного desktop-приложения для оркестрации AI-агентов. Создавайте команды, наблюдайте за канбан-доской, ревьюйте изменения и координируйте Claude, Codex, OpenCode и multimodel workflows.
lang: ru-RU
layout: home
hero:
name: Документация Agent Teams
@ -57,13 +58,24 @@ Agent Teams - бесплатное desktop-приложение для орке
<DocsCardGrid />
## Что дальше после запуска
После создания первой команды изучите эти руководства:
- **Настройка рантайма** - настройте Claude, Codex, OpenCode или multimodel-провайдеров: [Настроить рантаймы](/ru/guide/runtime-setup)
- **Workflow агентов** - как агенты координируются через task board: [Разобрать workflow](/ru/guide/agent-workflow)
- **Примеры team briefs** - паттерны промптов из реальных примеров: [Примеры](/ru/guide/team-brief-examples)
- **Код-ревью** - проверяйте diff, принимайте или отклоняйте изменения: [Ревью изменений](/ru/guide/code-review)
- **Диагностика** - исправляйте проблемы запуска и missing teammates: [Диагностика](/ru/guide/troubleshooting)
- **Стратегия git worktree** - используйте изоляцию worktree, когда несколько участников редактируют один репозиторий параллельно: [О работе с worktree](/ru/guide/git-worktree-strategy)
- **Релизы** - что нового в каждой версии: [Релизы](/ru/reference/release-notes)
## Справочник
Используйте справочник, когда нужны точные термины, поведение провайдеров или границы приватности.
Используйте справочник, когда нужны точные термины, поведение провайдеров, contributor architecture или границы приватности.
<DocsCardGrid type="reference" />
## Превью продукта
<ZoomImage src="/screenshots/1.jpg" alt="Канбан-доска Agent Teams" caption="Статусы задач, активность агентов и review workflow видны в одном рабочем пространстве." />

View file

@ -1,11 +1,6 @@
---
title: Концепции
description: Основной словарь Agent Teams — команды, lead-агенты, teammates, задачи, канбан, inboxes, рантаймы и review.
---
---
title: Концепции Документация Agent Teams
description: Основные термины Agent Teams: teams, leads, teammates, tasks, kanban, inboxes, agent blocks, context phases, runtimes, providers.
description: "Основные термины Agent Teams: teams, leads, teammates, tasks, kanban, inboxes, agent blocks, context phases, runtimes, providers."
lang: ru-RU
---
@ -51,7 +46,7 @@ Messages - долговечные локальные записи. Но дост
Agent Block - скрытый agent-only instruction text, обёрнутый в `<info_for_agent>...</info_for_agent>`. UI убирает такие блоки из обычного human-facing display, но agents и runtime delivery могут использовать их для coordination details.
Текущий canonical marker - `info_for_agent`; в старых документах могут встречаться legacy agent block formats.
Текущий canonical marker `info_for_agent`. В старых документах могут встречаться fenced code blocks с маркером ````info_for_agent```` или XML-подобные теги `<agent_block>` — это устаревшие паттерны, которые стоит заменить на `info_for_agent` при встрече.
## Context Phase

View file

@ -0,0 +1,55 @@
---
title: Архитектура для контрибьюторов Документация Agent Teams
description: Карта для контрибьюторов по feature layout, runtime/provider boundaries, hard guardrails и canonical architecture docs.
lang: ru-RU
---
# Архитектура для контрибьюторов
Эта страница - карта для контрибьюторов. Она ведёт к canonical repo guidance и не дублирует все implementation rules.
## Канонические источники
Используйте эти файлы как source of truth при изменениях в приложении:
| Нужно | Канонический источник |
| --- | --- |
| Обзор репозитория и команды | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
| Локальные рабочие conventions | [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) |
| Жёсткие guardrails | [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) |
| Layout средних и больших features | [docs/FEATURE_ARCHITECTURE_STANDARD.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) |
| Диагностика запуска agent teams | [docs/team-management/debugging-agent-teams.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) |
## Feature layout
Средние и большие features должны жить в `src/features/<feature-name>/` и следовать feature architecture standard. Держите internals за public entrypoints и не делайте deep imports через границы feature.
Для новой работы ориентируйтесь на существующий slice `src/features/recent-projects`. Маленькие fixes можно оставлять рядом с текущим code path, если новый feature slice добавит больше структуры, чем пользы.
## Runtime и provider boundaries
Agent Teams отвечает за orchestration: teams, tasks, messages, launch state, review UI, diagnostics и local persistence.
Выбранный runtime/provider path отвечает за model execution, auth, model availability, rate limits, tool semantics и runtime-specific transcript evidence. Не пытайтесь чинить prompts или UI state вместо missing auth, missing binaries, rejected model ids или provider outages. User-facing детали настройки смотрите в [Провайдерах и рантаймах](/ru/reference/providers-runtimes).
## Диагностика agent teams
При launch hangs, OpenCode `registered` / bootstrap-unconfirmed states, missing teammate replies или подозрительных task logs начинайте с dedicated debugging runbook. Сначала смотрите newest launch failure artifact в `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`, затем сопоставляйте UI state с persisted files и runtime-specific evidence.
Не делайте broad cleanup во время диагностики. Останавливайте только process, lane, team или smoke run, который точно относится к проблеме.
## Contributor conventions
- Используйте `pnpm dev` для desktop Electron app при обычной разработке.
- Не используйте browser dev mode как замену desktop runtime, IPC, terminal, provider auth или team lifecycle behavior.
- Разделяйте ответственности Electron main, preload, renderer, shared и features.
- Используйте `wrapAgentBlock(text)` для agent-only blocks вместо ручной склейки markers.
- Предпочитайте focused verification. Избегайте broad `lint:fix` или formatting churn, если задача не про formatting.
- Parsing, task lifecycle, provider/runtime detection, persistence, IPC, Git и review flows считайте high-risk зонами, где нужны targeted tests или clear verification path.
## Связанные страницы
- [Настройка рантайма](/ru/guide/runtime-setup)
- [Диагностика](/ru/guide/troubleshooting)
- [Код-ревью](/ru/guide/code-review)
- [Приватность и локальные данные](/ru/reference/privacy-local-data)

View file

@ -1,8 +1,3 @@
---
title: FAQ
description: Часто задаваемые вопросы об Agent Teams — цена, доступ к моделям, рантаймы, приватность, ревью и диагностика.
---
---
title: FAQ Документация Agent Teams
description: Часто задаваемые вопросы о цене, доступе к моделям, настройке рантаймов, приватности данных, worktree isolation и код-ревью.
@ -47,7 +42,7 @@ opencode --version
## Где хранятся team files?
Team coordination data хранится локально в `~/.claude/teams/<team>/`, task files - в `~/.claude/tasks/<team>/`, а project session data - в `~/.claude/projects/<encoded-project>/`, когда она доступна.
Team coordination data хранится локально в `~/.claude/teams/<team>/` (macOS/Linux) или `%APPDATA%\Claude\teams\<team>\` (Windows), task files - в `~/.claude/tasks/<team>/` или `%APPDATA%\Claude\tasks\<team>\`, а project session data - в `~/.claude/projects/<encoded-project>/`, когда она доступна.
## Что может выйти с моей машины?
@ -62,7 +57,7 @@ Prompt context, selected file contents, tool results, command output, task text,
Дайте lead конкретный outcome, file или feature boundaries, risk limits и verification expectations. Например:
```text
Improve the docs quickstart. Keep edits inside landing/product-docs, add practical examples, and run the docs build before marking work done.
Improve the docs quickstart. Keep edits inside landing/product-docs, add practical examples, and run `pnpm --dir landing docs:build` before marking work done.
```
## Можно ревьюить код перед принятием?

View file

@ -1,8 +1,3 @@
---
title: Приватность и локальные данные
description: Что desktop-приложение Agent Teams хранит локально и какие данные могут покинуть машину через provider-backed models.
---
---
title: Приватность и локальные данные Документация Agent Teams
description: Что Agent Teams хранит локально, что может покинуть машину через provider-backed model calls, и практические рекомендации по приватности.
@ -28,13 +23,16 @@ Desktop app работает на вашей машине и читает local
Важные local locations:
| Location | Purpose |
| --- | --- |
| `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state и review-related team files. |
| `~/.claude/tasks/<team>/` | Durable task JSON files для team board. |
| `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files для session history, context analysis и transcript-backed UI. |
| Платформа | Location | Purpose |
| --- | --- | --- |
| macOS/Linux | `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state и review-related team files. |
| Windows | `%APPDATA%\Claude\teams\<team>\` | То же — team config, member metadata, inboxes, launch state и diagnostics. |
| macOS/Linux | `~/.claude/tasks/<team>/` | Durable task JSON files для team board. |
| Windows | `%APPDATA%\Claude\tasks\<team>\` | То же — durable task JSON files. |
| macOS/Linux | `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files для session history, context analysis и transcript-backed UI. |
| Windows | `%APPDATA%\Claude\projects\<encoded-project>\` | То же — project session files. |
Точные файлы зависят от runtime и версии app. Для launch debugging самые свежие evidence обычно лежат в соответствующей папке `~/.claude/teams/<team>/`.
Точные файлы зависят от runtime и версии app. Для launch debugging самые свежие evidence обычно лежат в соответствующей папке `~/.claude/teams/<team>/` (или `%APPDATA%\Claude\teams\<team>\`).
## Что может выйти с машины

View file

@ -1,8 +1,3 @@
---
title: Провайдеры и рантаймы
description: Поддерживаемые runtime paths, provider ids, model ids, multi-provider стратегия и capability checks в Agent Teams.
---
---
title: Провайдеры и рантаймы Документация Agent Teams
description: Поддерживаемые runtime paths (Claude Code, Codex, OpenCode), provider IDs, модели, multi-provider стратегии и capability checks.
@ -44,7 +39,7 @@ Runtime отвечает за:
| 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. |
Gemini provider ids существуют во внутренних configuration paths, но Gemini сейчас скрыт из основного team creation UI, пока launch flow остаётся in development.
Gemini — поддерживаемый провайдер с Google ADC, Gemini CLI и API key аутентификацией. Он доступен в UI создания команд и настройки рантайма, когда runtime сообщает о его доступности.
## Provider ids
@ -54,7 +49,7 @@ Gemini provider ids существуют во внутренних configuration
| --- | --- |
| `anthropic` | Anthropic / Claude Code path |
| `codex` | Codex path |
| `gemini` | Gemini provider path, когда его отдаёт runtime |
| `gemini` | Gemini provider path (Google ADC, Gemini CLI или API key) |
| `opencode` | OpenCode path, включая OpenCode-managed provider routing |
Эта таблица не гарантирует, что каждый provider authenticated, installed или доступен для каждой модели на каждой машине. Runtime status и capability checks - source of truth для конкретного launch.
@ -82,6 +77,8 @@ Agent Teams остаётся provider-aware, но не provider-owned:
- model availability, auth, rate limits и tool behavior остаются ответственностью runtime/provider
- OpenCode - основной путь, когда одной team нужны разные provider/model lanes
Contributor-facing границы и canonical implementation guidance смотрите в [Архитектуре для контрибьюторов](/ru/reference/contributor-architecture).
Рекомендуемые patterns:
| Pattern | When it helps | Risk |

View file

@ -0,0 +1,42 @@
---
title: Релизы Документация Agent Teams
description: Release notes и changelog для Agent Teams. Ссылки на канонические RELEASE.md и CHANGELOG.md.
lang: ru-RU
---
# Релизы
Текущий релиз: **v1.2.0** (2026-03-31). Активная разработка продолжается в ветке `main` с незарелизенными изменениями для member work-sync, OpenCode delivery hardening и CI stabilization.
## Как публикуются релизы
Agent Teams следует [Semantic Versioning](https://semver.org/). Пуш тега в репозиторий запускает автоматический [release workflow](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md), который собирает подписанные пакеты для macOS, Windows и Linux и публикует их в GitHub Releases.
## Последние релизы
### v1.2.0 — Agent Graph, per-team tool approval, interactive AskUserQuestion
Agent Graph с force-directed визуализацией и kanban layout, per-team tool approval controls с понятными permission prompts, уведомления о комментариях к задачам и интерактивные AskUserQuestion кнопки. Permission system overhaul с Write/Edit/NotebookEdit seeding и MCP tool catalog. Полный [changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#120---2026-03-31).
### v1.1.0 — React 19 + Electron 40, user-initiated task starts
React 19 + Electron 40 migration, запуск задач пользователем с kanban board, auth troubleshooting guide, подсветка синтаксиса для R/Ruby/PHP/SQL, ускорение поиска транскриптов в 3 раза, исправления WSL/Windows paths и XSS vulnerability. Полный [changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#110---2026-03-25).
### v1.0.0 — Первый публичный релиз
Первый стабильный билд: надёжность CLI/auth в packaged apps, IPC hardening, cross-platform packaging с подписанными macOS сборками, open-source governance docs (LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY). Полный [changelog](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md#100---2026-03-23).
## Канонические источники
| Документ | Описание |
| --- | --- |
| [RELEASE.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/RELEASE.md) | Процесс релиза, версионирование, имена артефактов, auto-update setup и шаблон release notes. |
| [CHANGELOG.md](https://github.com/777genius/agent-teams-ai/blob/main/docs/CHANGELOG.md) | Полный changelog со всеми версиями, фичами, улучшениями и исправлениями. |
| [GitHub Releases](https://github.com/777genius/agent-teams-ai/releases) | Установочные файлы для всех платформ. |
## Связанные страницы
- [Установка](/ru/guide/installation)
- [Быстрый старт](/ru/guide/quickstart)
- [Архитектура для контрибьюторов](/ru/reference/contributor-architecture)
- [Разработчикам](/ru/developers/)

View file

@ -19,7 +19,6 @@
"main": "dist-electron/main/index.cjs",
"scripts": {
"dev": "node ./scripts/dev-with-runtime.mjs",
"dev:web": "node ./scripts/dev-web.mjs",
"dev:kill": "node bin/kill-dev.js",
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
"opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs",
@ -30,6 +29,7 @@
"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",
"team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts",
"smoke:codex-runtime-install": "tsx scripts/smoke/codex-runtime-install.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",
"dist": "node ./scripts/electron-builder/dist.mjs --mac --win --linux",
@ -38,6 +38,7 @@
"dist:mac:x64": "node ./scripts/electron-builder/dist.mjs --mac --x64",
"dist:win": "node ./scripts/electron-builder/dist.mjs --win",
"dist:linux": "node ./scripts/electron-builder/dist.mjs --linux",
"smoke:packaged": "node ./scripts/electron-builder/smokePackagedApp.cjs",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"typecheck:workspace": "pnpm typecheck && pnpm --filter agent-teams-mcp typecheck && pnpm --filter agent-teams-mcp typecheck:test",
@ -55,6 +56,7 @@
"check:ci": "pnpm check:workspace:ci && pnpm lint && pnpm lint:mcp",
"fix": "pnpm lint:fix && pnpm format",
"quality": "pnpm check && pnpm format:check && npx knip",
"guard:runtime-artifacts": "node ./scripts/ci/forbid-runtime-artifacts.cjs",
"test:chunks": "tsx test/test-chunk-building.ts",
"test:semantic": "tsx test/test-semantic-steps.ts",
"test:noise": "tsx test/test-noise-filtering.ts",
@ -147,10 +149,12 @@
"diff": "^8.0.3",
"dompurify": "^3.4.2",
"electron-updater": "^6.7.3",
"fast-json-stringify": "^6.4.0",
"fastify": "^5.8.5",
"highlight.js": "^11.11.1",
"idb-keyval": "^6.2.2",
"isbinaryfile": "^6.0.0",
"json-schema-ref-resolver": "^3.0.0",
"lucide-react": "^0.577.0",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.15.0",

View file

@ -213,6 +213,9 @@ importers:
electron-updater:
specifier: ^6.7.3
version: 6.7.3
fast-json-stringify:
specifier: ^6.4.0
version: 6.4.0
fastify:
specifier: ^5.8.5
version: 5.8.5
@ -225,6 +228,9 @@ importers:
isbinaryfile:
specifier: ^6.0.0
version: 6.0.0
json-schema-ref-resolver:
specifier: ^3.0.0
version: 3.0.0
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
@ -6792,8 +6798,8 @@ packages:
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-json-stringify@6.3.0:
resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==}
fast-json-stringify@6.4.0:
resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==}
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
@ -12337,7 +12343,7 @@ snapshots:
'@fastify/fast-json-stringify-compiler@5.0.3':
dependencies:
fast-json-stringify: 6.3.0
fast-json-stringify: 6.4.0
'@fastify/forwarded@3.0.1': {}
@ -18240,7 +18246,7 @@ snapshots:
fast-json-stable-stringify@2.1.0: {}
fast-json-stringify@6.3.0:
fast-json-stringify@6.4.0:
dependencies:
'@fastify/merge-json-schemas': 0.2.1
ajv: 8.18.0
@ -18269,7 +18275,7 @@ snapshots:
'@fastify/proxy-addr': 5.1.0
abstract-logging: 2.0.1
avvio: 9.1.0
fast-json-stringify: 6.3.0
fast-json-stringify: 6.4.0
find-my-way: 9.4.0
light-my-request: 6.6.0
pino: 10.3.1

View file

@ -1,27 +1,27 @@
{
"version": "0.0.31",
"sourceRef": "v0.0.31",
"version": "0.0.33",
"sourceRef": "v0.0.33",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v1.2.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.31.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.33.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.31.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.33.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.31.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.33.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.31.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.33.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -0,0 +1,26 @@
const { execFileSync } = require('node:child_process');
const allowedRuntimeFiles = new Set(['resources/runtime/.gitkeep']);
function trackedFiles() {
return execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' })
.split('\0')
.filter(Boolean);
}
const forbidden = trackedFiles().filter((file) => {
if (file.startsWith('.runtime-download/')) return true;
if (file.startsWith('resources/runtime/') && !allowedRuntimeFiles.has(file)) return true;
return false;
});
if (forbidden.length > 0) {
console.error('Runtime release artifacts must not be committed.');
console.error('These files are downloaded from GitHub Releases during dev/release builds:');
for (const file of forbidden) {
console.error(`- ${file}`);
}
process.exit(1);
}
console.log('Runtime artifact guard passed.');

View file

@ -0,0 +1,130 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const STARTUP_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_TIMEOUT_MS ?? 30_000);
const POST_STARTUP_STABLE_MS = Number(process.env.PACKAGED_SMOKE_STABLE_MS ?? 8_000);
const REQUIRED_LOG_MARKERS = ['renderer did-finish-load'];
const FAILURE_PATTERNS = [
/Cannot find module/i,
/MODULE_NOT_FOUND/i,
/Failed to start HTTP server/i,
/Unable to set login item/i,
/\[DEP0180\]/i,
/DeprecationWarning: fs\.Stats constructor is deprecated/i,
];
function fail(message, log = '') {
console.error(`[smokePackagedApp] ${message}`);
if (log.trim()) {
console.error('--- packaged app log ---');
console.error(log.trim());
}
process.exit(1);
}
function findExecutable(bundlePath, platform) {
if (platform === 'darwin') {
const macOsDir = path.join(bundlePath, 'Contents', 'MacOS');
const entries = fs.readdirSync(macOsDir);
const executable = entries.find((entry) => {
const fullPath = path.join(macOsDir, entry);
return fs.statSync(fullPath).isFile() && (fs.statSync(fullPath).mode & 0o111) !== 0;
});
if (!executable) fail(`No executable found in ${macOsDir}`);
return path.join(macOsDir, executable);
}
if (platform === 'win32') {
const executable = fs
.readdirSync(bundlePath)
.find((entry) => entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall'));
if (!executable) fail(`No .exe found in ${bundlePath}`);
return path.join(bundlePath, executable);
}
if (platform === 'linux') {
const packageJsonPath = path.join(bundlePath, 'resources', 'app.asar.unpacked', 'package.json');
const packageJson = fs.existsSync(packageJsonPath)
? JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
: {};
const preferredNames = [packageJson.name, 'agent-teams-ai', 'Agent Teams UI'].filter(Boolean);
for (const name of preferredNames) {
const candidate = path.join(bundlePath, name);
if (fs.existsSync(candidate)) return candidate;
}
const executable = fs.readdirSync(bundlePath).find((entry) => {
const fullPath = path.join(bundlePath, entry);
return fs.statSync(fullPath).isFile() && (fs.statSync(fullPath).mode & 0o111) !== 0;
});
if (!executable) fail(`No executable found in ${bundlePath}`);
return path.join(bundlePath, executable);
}
fail(`Unsupported platform: ${platform}`);
}
async function main() {
const [bundlePathArg, platform] = process.argv.slice(2);
if (!bundlePathArg || !platform) {
fail('Usage: node ./scripts/electron-builder/smokePackagedApp.cjs <bundlePath> <platform>');
}
const bundlePath = path.resolve(bundlePathArg);
const executable = findExecutable(bundlePath, platform);
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-smoke-'));
const args = [`--user-data-dir=${userDataDir}`];
const child = spawn(executable, args, {
env: {
...process.env,
AGENT_TEAMS_PACKAGED_SMOKE: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (chunk) => {
log += chunk.toString();
});
child.stderr.on('data', (chunk) => {
log += chunk.toString();
});
const exitPromise = new Promise((resolve) => {
child.on('exit', (code, signal) => resolve({ code, signal }));
});
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
let startupSeenAt = null;
while (Date.now() < deadline) {
if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) {
child.kill();
fail('Detected startup failure pattern', log);
}
if (startupSeenAt === null && REQUIRED_LOG_MARKERS.every((marker) => log.includes(marker))) {
startupSeenAt = Date.now();
}
if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) {
child.kill();
console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`);
return;
}
const exit = await Promise.race([
exitPromise,
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
]);
if (exit) {
fail(`Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`, log);
}
}
child.kill();
fail(`Timed out after ${STARTUP_TIMEOUT_MS}ms waiting for packaged startup`, log);
}
main().catch((error) => fail(error?.stack || String(error)));

View file

@ -0,0 +1,170 @@
#!/usr/bin/env tsx
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import {
CodexRuntimeInstallerService,
resolveAppManagedCodexRuntimeBinaryPath,
resolveVerifiedAppManagedCodexRuntimeBinaryPath,
} from '@features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService';
import { CodexBinaryResolver } from '@main/services/infrastructure/codexAppServer/CodexBinaryResolver';
import { getAppDataPath, setAppDataBasePath } from '@main/utils/pathDecoder';
const execFileAsync = promisify(execFile);
const VERSION_TIMEOUT_MS = 15_000;
interface CodexRuntimeSmokeManifest {
rootVersion?: string;
platformVersion?: string;
platformTarget?: string;
binaryPath?: string;
integrity?: string;
}
interface CodexRuntimeSmokeReport {
platform: NodeJS.Platform;
arch: string;
appDataPath: string;
binaryPath: string;
statusVersion: string | null;
versionStdout: string;
resolverVersion: string | null;
rootVersion: string | null;
platformVersion: string | null;
platformTarget: string | null;
}
function assertCondition(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function isInsidePath(parentPath: string, childPath: string): boolean {
const relativePath = path.relative(parentPath, childPath);
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
}
async function readManifest(appDataPath: string): Promise<CodexRuntimeSmokeManifest> {
const manifestPath = path.join(appDataPath, 'runtimes', 'codex', 'current.json');
const raw = await readFile(manifestPath, 'utf8');
return JSON.parse(raw) as CodexRuntimeSmokeManifest;
}
async function assertExecutableVersion(binaryPath: string): Promise<string> {
const { stdout, stderr } = await execFileAsync(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
const output = `${stdout ?? ''}\n${stderr ?? ''}`.trim();
assertCondition(
/\bcodex-cli\s+\d+\.\d+\.\d+\b/i.test(output),
`Unexpected version output: ${output}`
);
return output;
}
async function runSmoke(): Promise<CodexRuntimeSmokeReport> {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-runtime-smoke-'));
const keepTemp = process.env.CODEX_RUNTIME_SMOKE_KEEP_TEMP === '1';
setAppDataBasePath(tempRoot);
CodexBinaryResolver.clearCache();
try {
const service = new CodexRuntimeInstallerService();
const status = await service.install();
assertCondition(status.installed, `Codex runtime install failed: ${JSON.stringify(status)}`);
assertCondition(status.binaryPath, 'Codex runtime install did not return a binary path');
assertCondition(
path.isAbsolute(status.binaryPath),
`Binary path is not absolute: ${status.binaryPath}`
);
assertCondition(existsSync(status.binaryPath), `Binary does not exist: ${status.binaryPath}`);
const binaryStat = await stat(status.binaryPath);
assertCondition(binaryStat.isFile(), `Binary path is not a file: ${status.binaryPath}`);
const appDataPath = getAppDataPath();
assertCondition(
isInsidePath(path.join(appDataPath, 'runtimes', 'codex'), status.binaryPath),
`Binary path is outside the app-managed Codex runtime root: ${status.binaryPath}`
);
const manifest = await readManifest(appDataPath);
assertCondition(
manifest.binaryPath === status.binaryPath,
'Manifest binary path does not match install status'
);
assertCondition(
typeof manifest.integrity === 'string' && manifest.integrity.startsWith('sha512-'),
'Manifest integrity is missing sha512 metadata'
);
assertCondition(typeof manifest.rootVersion === 'string', 'Manifest rootVersion is missing');
assertCondition(
typeof manifest.platformVersion === 'string',
'Manifest platformVersion is missing'
);
assertCondition(
typeof manifest.platformTarget === 'string',
'Manifest platformTarget is missing'
);
const appManagedPath = resolveAppManagedCodexRuntimeBinaryPath();
const verifiedPath = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
const resolvedPath = await CodexBinaryResolver.resolve();
assertCondition(
appManagedPath === status.binaryPath,
'resolveAppManagedCodexRuntimeBinaryPath mismatch'
);
assertCondition(
verifiedPath === status.binaryPath,
'resolveVerifiedAppManagedCodexRuntimeBinaryPath mismatch'
);
assertCondition(
resolvedPath === status.binaryPath,
'CodexBinaryResolver did not prefer the app-managed binary'
);
const versionStdout = await assertExecutableVersion(status.binaryPath);
const resolverVersion = await CodexBinaryResolver.resolveVersion(resolvedPath);
assertCondition(
typeof resolverVersion === 'string' && /^\d+\.\d+\.\d+/.test(resolverVersion),
`CodexBinaryResolver returned an invalid version: ${resolverVersion}`
);
return {
platform: process.platform,
arch: process.arch,
appDataPath,
binaryPath: status.binaryPath,
statusVersion: status.version ?? null,
versionStdout,
resolverVersion,
rootVersion: manifest.rootVersion,
platformVersion: manifest.platformVersion,
platformTarget: manifest.platformTarget,
};
} finally {
CodexBinaryResolver.clearCache();
setAppDataBasePath(null);
if (keepTemp) {
console.log(`CODEX_RUNTIME_SMOKE_KEEP_TEMP=1, keeping temp root: ${tempRoot}`);
} else {
await rm(tempRoot, { recursive: true, force: true });
}
}
}
runSmoke()
.then((report) => {
console.log(JSON.stringify(report, null, 2));
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -19,13 +19,60 @@ Default location for new feature work:
- `src/features/<feature-name>/`
Before adding or moving code:
Before adding a medium or large feature:
- decide whether the feature is full, thin, or process-limited
- add only the layers the feature actually owns
- expose production callers through public entrypoints only
- keep tests close to the layer they verify under `test/features/<feature>/` or
feature-local `__tests__` when that is the established local pattern
- start with the layer set the feature actually owns; do not add placeholder
folders just to match the full template
- create explicit public entrypoints for every layer production callers need
- put shared DTOs, channel names, and API fragments in `contracts/`
- keep business policy in `core/domain` and use-case orchestration in
`core/application`
- keep Electron, IPC, HTTP, file system, process, and provider specifics outside
`core/`
- wire runtime dependencies from `main/composition/` when the feature owns main
process behavior
- expose preload bridges through `preload/index.ts` and renderer surfaces
through `renderer/index.ts`
- add focused tests for the layers that carry behavior
When modifying an existing feature:
- preserve the feature's current shape unless the change introduces a real new
boundary
- route app shell and cross-feature imports through public entrypoints
- move duplicated rules toward `core/domain` before adding another adapter copy
- keep transport validation and normalization close to the boundary that receives
the data
- update the feature README or local notes when the public surface or intended
shape changes
- keep local README examples concrete and file-based; link back to the standard
for architecture rules instead of restating them
Public entrypoint expectations:
- `contracts/index.ts` exports only browser-safe contracts, constants, and
normalizers intended for cross-process use
- `main/index.ts` exports composition and registration surfaces for main-process
callers
- `preload/index.ts` exports bridge creation only
- `renderer/index.ts` exports reusable renderer components, hooks, or utilities
that are intentionally consumed outside the feature
- root `index.ts` is optional; use it only when the feature deliberately owns a
stable public barrel
Testing expectations:
- test pure domain rules directly and keep those tests independent of runtime
services
- test application use cases with ports or fakes, not Electron or real provider
processes
- test adapter mapping, boundary normalization, and renderer utilities where they
can regress user-visible behavior
- prefer `test/features/<feature>/...` for cross-layer coverage; feature-local
`__tests__` are fine when the surrounding feature already uses that pattern
- for docs-only changes, verify links and examples instead of running broad test
suites
Do not duplicate architecture rules here. Keep architecture rules centralized in
[../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md).

View file

@ -3,22 +3,38 @@
This directory contains the canonical home for medium and large feature slices.
Before creating or refactoring a feature, read:
- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md)
- [Feature-local agent guidance](./CLAUDE.md)
Reference implementation:
- `src/features/recent-projects`
- `src/features/agent-graph`
Reference examples:
- [`recent-projects`](./recent-projects/README.md) - full cross-process feature
with contracts, core, main, preload, renderer, and focused tests
- [`agent-graph`](./agent-graph/README.md) - thin feature with `core/domain` and
renderer integration only
- `codex-model-catalog` and `team-runtime-lanes` - process-limited features
that omit renderer or preload layers when they do not own those boundaries
Use `src/features/<feature-name>/` by default when the work introduces:
- a new use case or business policy
- transport wiring
- more than one process boundary
- more than one adapter or provider
Feature-local docs should answer navigation questions:
- which shape the feature uses
- which entrypoints are public
- where new adapters, rules, bridges, or renderer surfaces belong
- what tests protect the behavior
- which local files are the best examples for future changes
Do not duplicate architecture rules in feature folders.
Keep the standard centralized in [../../docs/FEATURE_ARCHITECTURE_STANDARD.md](../../docs/FEATURE_ARCHITECTURE_STANDARD.md).
Rule of thumb:
- `recent-projects` is the full slice example with process-aware outer layers
- `agent-graph` is the thin slice example built around `core/` plus `renderer/`

View file

@ -108,14 +108,46 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
return <MessageSquareText className={`${className} text-slate-300`} />;
}
function hasOpenCodeRuntimeWarning(preview: MemberLogPreviewMember | undefined): boolean {
return (
preview?.warnings.some(
(warning) =>
warning.code === 'opencode_runtime_timeout' ||
warning.code === 'opencode_runtime_unavailable' ||
warning.code === 'opencode_ambiguous_lane'
) === true
);
}
function hasOpenCodeDeliveryDelayedWarning(preview: MemberLogPreviewMember | undefined): boolean {
return preview?.warnings.some((warning) => warning.code === 'opencode_delivery_delayed') === true;
}
function hasOpenCodeEmptyStateWarning(preview: MemberLogPreviewMember | undefined): boolean {
return hasOpenCodeDeliveryDelayedWarning(preview) || hasOpenCodeRuntimeWarning(preview);
}
function resolveEmptyText(
preview: MemberLogPreviewMember | undefined,
loading: boolean,
error: string | null
): string {
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
const hasCodexUnsupportedWarning = preview?.warnings.some(
(warning) => warning.code === 'codex_member_wide_not_supported'
);
const hasOnlyCodexUnsupportedCoverage =
hasCodexUnsupportedWarning === true &&
(preview?.coverage.length ?? 0) > 0 &&
preview?.coverage.every((coverage) => coverage.provider === 'codex_native_trace');
if (hasOnlyCodexUnsupportedCoverage) {
return 'Unsupported provider';
}
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeDeliveryDelayedWarning(preview)) {
return 'OpenCode logs delayed';
}
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeRuntimeWarning(preview)) {
return 'Logs unavailable';
}
if (loading && !preview) return 'Loading logs';
if (error && !preview) return 'Logs unavailable';
return 'No recent logs';
@ -552,7 +584,8 @@ export const GraphMemberLogPreviewHud = ({
: node.label;
const preview = previewsByMember.get(normalizeMemberName(memberName));
const items = preview?.items ?? [];
const isInitialLoading = loading && !preview;
const isEmptyLoading =
loading && (!preview || (items.length === 0 && hasOpenCodeEmptyStateWarning(preview)));
return (
<div
@ -578,7 +611,7 @@ export const GraphMemberLogPreviewHud = ({
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{items.length > 0 ? (
items.slice(0, 3).map((item) => renderItem(memberName, item))
) : isInitialLoading ? (
) : isEmptyLoading ? (
<button
type="button"
className="flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60"

View file

@ -0,0 +1,8 @@
import type { CodexRuntimeStatus } from './dto';
export interface CodexRuntimeAPI {
getStatus: () => Promise<CodexRuntimeStatus>;
install: () => Promise<CodexRuntimeStatus>;
invalidateStatus: () => Promise<void>;
onProgress: (callback: (event: unknown, data: CodexRuntimeStatus) => void) => () => void;
}

View file

@ -0,0 +1,4 @@
export const CODEX_RUNTIME_GET_STATUS = 'codexRuntime:getStatus';
export const CODEX_RUNTIME_INSTALL = 'codexRuntime:install';
export const CODEX_RUNTIME_PROGRESS = 'codexRuntime:progress';
export const CODEX_RUNTIME_INVALIDATE_STATUS = 'codexRuntime:invalidateStatus';

View file

@ -0,0 +1,27 @@
export type CodexRuntimeSource = 'app-managed' | 'path' | 'missing';
export type CodexRuntimeInstallerState =
| 'idle'
| 'checking'
| 'downloading'
| 'installing'
| 'ready'
| 'failed';
export interface CodexRuntimeInstallProgress {
phase: CodexRuntimeInstallerState;
downloadedBytes?: number;
totalBytes?: number;
percent?: number;
detail?: string | null;
}
export interface CodexRuntimeStatus {
installed: boolean;
binaryPath?: string;
version?: string;
source: CodexRuntimeSource;
state: CodexRuntimeInstallerState;
progress?: CodexRuntimeInstallProgress;
error?: string;
}

View file

@ -0,0 +1,3 @@
export type * from './api';
export * from './channels';
export type * from './dto';

View file

@ -0,0 +1,7 @@
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
export interface CodexRuntimeInstallerPort {
getStatus: () => Promise<CodexRuntimeStatus>;
install: () => Promise<CodexRuntimeStatus>;
invalidateStatusCache: () => void;
}

View file

@ -0,0 +1,10 @@
import type { CodexRuntimeInstallerPort } from '../ports/CodexRuntimeInstallerPort';
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
export class GetCodexRuntimeStatusUseCase {
constructor(private readonly installer: Pick<CodexRuntimeInstallerPort, 'getStatus'>) {}
execute(): Promise<CodexRuntimeStatus> {
return this.installer.getStatus();
}
}

View file

@ -0,0 +1,10 @@
import type { CodexRuntimeInstallerPort } from '../ports/CodexRuntimeInstallerPort';
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
export class InstallCodexRuntimeUseCase {
constructor(private readonly installer: Pick<CodexRuntimeInstallerPort, 'install'>) {}
execute(): Promise<CodexRuntimeStatus> {
return this.installer.install();
}
}

View file

@ -0,0 +1 @@
export type * from './contracts';

View file

@ -0,0 +1,64 @@
import {
CODEX_RUNTIME_GET_STATUS,
CODEX_RUNTIME_INSTALL,
CODEX_RUNTIME_INVALIDATE_STATUS,
} from '@features/codex-runtime-installer/contracts';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import type { CodexRuntimeInstallerFeatureFacade } from '../../../composition/createCodexRuntimeInstallerFeature';
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
import type { IpcResult } from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('Feature:codex-runtime-installer:ipc');
export function registerCodexRuntimeInstallerIpc(
ipcMain: IpcMain,
feature: CodexRuntimeInstallerFeatureFacade
): void {
ipcMain.handle(
CODEX_RUNTIME_GET_STATUS,
(_event: IpcMainInvokeEvent): Promise<IpcResult<CodexRuntimeStatus>> =>
withIpcResult(() => feature.getStatus())
);
ipcMain.handle(
CODEX_RUNTIME_INSTALL,
(_event: IpcMainInvokeEvent): Promise<IpcResult<CodexRuntimeStatus>> =>
withIpcResult(() => feature.install())
);
ipcMain.handle(
CODEX_RUNTIME_INVALIDATE_STATUS,
(_event: IpcMainInvokeEvent): IpcResult<void> =>
withSyncIpcResult(() => {
feature.invalidateStatus();
return undefined;
})
);
logger.info('Codex runtime installer IPC handlers registered');
}
export function removeCodexRuntimeInstallerIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(CODEX_RUNTIME_GET_STATUS);
ipcMain.removeHandler(CODEX_RUNTIME_INSTALL);
ipcMain.removeHandler(CODEX_RUNTIME_INVALIDATE_STATUS);
logger.info('Codex runtime installer IPC handlers removed');
}
async function withIpcResult<T>(work: () => Promise<T>): Promise<IpcResult<T>> {
try {
return { success: true, data: await work() };
} catch (error) {
const message = getErrorMessage(error);
return { success: false, error: message };
}
}
function withSyncIpcResult<T>(work: () => T): IpcResult<T> {
try {
return { success: true, data: work() };
} catch (error) {
const message = getErrorMessage(error);
return { success: false, error: message };
}
}

View file

@ -0,0 +1,30 @@
import { GetCodexRuntimeStatusUseCase } from '../../core/application/use-cases/GetCodexRuntimeStatusUseCase';
import { InstallCodexRuntimeUseCase } from '../../core/application/use-cases/InstallCodexRuntimeUseCase';
import { CodexRuntimeInstallerService } from '../infrastructure/CodexRuntimeInstallerService';
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
import type { BrowserWindow } from 'electron';
export interface CodexRuntimeInstallerFeatureFacade {
getStatus: () => Promise<CodexRuntimeStatus>;
install: () => Promise<CodexRuntimeStatus>;
invalidateStatus: () => void;
setMainWindow: (window: BrowserWindow | null) => void;
}
export function createCodexRuntimeInstallerFeature(): CodexRuntimeInstallerFeatureFacade {
const service = new CodexRuntimeInstallerService();
const getStatusUseCase = new GetCodexRuntimeStatusUseCase(service);
const installUseCase = new InstallCodexRuntimeUseCase(service);
return {
getStatus: () => getStatusUseCase.execute(),
install: () => installUseCase.execute(),
invalidateStatus: () => {
service.invalidateStatusCache();
},
setMainWindow: (window) => {
service.setMainWindow(window);
},
};
}

View file

@ -0,0 +1,13 @@
export {
registerCodexRuntimeInstallerIpc,
removeCodexRuntimeInstallerIpc,
} from './adapters/input/ipc/registerCodexRuntimeInstallerIpc';
export type { CodexRuntimeInstallerFeatureFacade } from './composition/createCodexRuntimeInstallerFeature';
export { createCodexRuntimeInstallerFeature } from './composition/createCodexRuntimeInstallerFeature';
export {
extractCodexRuntimePackageFilesFromTarball,
getCodexRuntimePlatformCandidates,
resolveAppManagedCodexRuntimeBinaryPath,
resolveVerifiedAppManagedCodexRuntimeBinaryPath,
verifyCodexRuntimePackageIntegrity,
} from './infrastructure/CodexRuntimeInstallerService';

View file

@ -0,0 +1,679 @@
import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts';
import { execCli } from '@main/utils/childProcess';
import { getAppDataPath } from '@main/utils/pathDecoder';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createHash, randomUUID } from 'crypto';
import { existsSync, promises as fsp, readFileSync, statSync } from 'fs';
import path from 'path';
import { gunzipSync } from 'zlib';
import type { CodexRuntimeInstallerPort } from '../../core/application/ports/CodexRuntimeInstallerPort';
import type {
CodexRuntimeInstallProgress,
CodexRuntimeStatus,
} from '@features/codex-runtime-installer/contracts';
import type { BrowserWindow } from 'electron';
const logger = createLogger('CodexRuntimeInstallerService');
const CHANNEL = CODEX_RUNTIME_PROGRESS;
const ROOT_PACKAGE_NAME = '@openai/codex';
const NPM_REGISTRY_BASE_URL = 'https://registry.npmjs.org';
const CURRENT_MANIFEST_SCHEMA_VERSION = 1;
const MAX_TARBALL_BYTES = 160 * 1024 * 1024;
const MAX_UNPACKED_BYTES = 650 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 60_000;
const VERSION_TIMEOUT_MS = 10_000;
interface NpmPackageMetadata {
name?: string;
version?: string;
dist?: {
tarball?: string;
integrity?: string;
};
optionalDependencies?: Record<string, string>;
}
interface CodexRuntimeManifest {
schemaVersion: 1;
rootVersion: string;
platformVersion: string;
platformTarget: string;
binaryPath: string;
integrity: string;
installedAt: string;
}
export interface CodexRuntimePlatformCandidate {
optionalDependencyName: string;
platformTag: string;
vendorTarget: string;
reason: string;
}
interface CodexRuntimePackageFile {
relativePath: string;
data: Buffer;
mode: number;
}
function getRuntimeRootPath(): string {
return path.join(getAppDataPath(), 'runtimes', 'codex');
}
function getCurrentManifestPath(): string {
return path.join(getRuntimeRootPath(), 'current.json');
}
function isAbsoluteExistingFile(filePath: string | null | undefined): filePath is string {
if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) {
return false;
}
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function parseManifest(value: unknown): CodexRuntimeManifest | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const manifest = value as Partial<CodexRuntimeManifest>;
if (
manifest.schemaVersion !== CURRENT_MANIFEST_SCHEMA_VERSION ||
typeof manifest.rootVersion !== 'string' ||
typeof manifest.platformVersion !== 'string' ||
typeof manifest.platformTarget !== 'string' ||
typeof manifest.binaryPath !== 'string' ||
typeof manifest.integrity !== 'string' ||
typeof manifest.installedAt !== 'string'
) {
return null;
}
return manifest as CodexRuntimeManifest;
}
function readCurrentManifestSync(): CodexRuntimeManifest | null {
try {
const raw = readFileSync(getCurrentManifestPath(), 'utf8');
return parseManifest(JSON.parse(raw));
} catch {
return null;
}
}
export function resolveAppManagedCodexRuntimeBinaryPath(): string | null {
const manifest = readCurrentManifestSync();
return isAbsoluteExistingFile(manifest?.binaryPath) ? manifest.binaryPath : null;
}
export async function resolveVerifiedAppManagedCodexRuntimeBinaryPath(): Promise<string | null> {
const binaryPath = resolveAppManagedCodexRuntimeBinaryPath();
if (!binaryPath) {
return null;
}
try {
await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
return binaryPath;
} catch {
return null;
}
}
function getExecutableName(): string {
return process.platform === 'win32' ? 'codex.exe' : 'codex';
}
function getPathExecutableNames(): string[] {
return process.platform === 'win32'
? ['codex.exe', 'codex.cmd', 'codex.bat', 'codex']
: ['codex'];
}
function splitPathEnv(pathValue: string | undefined): string[] {
if (!pathValue) {
return [];
}
return pathValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
function resolvePathCodexBinary(): string | null {
const shellEnv = getCachedShellEnv() ?? {};
const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)];
const seen = new Set<string>();
for (const entry of pathEntries) {
const normalizedEntry = path.resolve(entry);
if (seen.has(normalizedEntry)) {
continue;
}
seen.add(normalizedEntry);
for (const executableName of getPathExecutableNames()) {
const candidate = path.join(normalizedEntry, executableName);
if (isAbsoluteExistingFile(candidate)) {
return candidate;
}
}
}
return null;
}
export function getCodexRuntimePlatformCandidates(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch
): CodexRuntimePlatformCandidate[] {
if (platform === 'darwin') {
if (arch === 'arm64') {
return [
{
optionalDependencyName: '@openai/codex-darwin-arm64',
platformTag: 'darwin-arm64',
vendorTarget: 'aarch64-apple-darwin',
reason: 'macOS arm64',
},
];
}
if (arch === 'x64') {
return [
{
optionalDependencyName: '@openai/codex-darwin-x64',
platformTag: 'darwin-x64',
vendorTarget: 'x86_64-apple-darwin',
reason: 'macOS x64',
},
];
}
}
if (platform === 'linux') {
if (arch === 'arm64') {
return [
{
optionalDependencyName: '@openai/codex-linux-arm64',
platformTag: 'linux-arm64',
vendorTarget: 'aarch64-unknown-linux-musl',
reason: 'Linux arm64',
},
];
}
if (arch === 'x64') {
return [
{
optionalDependencyName: '@openai/codex-linux-x64',
platformTag: 'linux-x64',
vendorTarget: 'x86_64-unknown-linux-musl',
reason: 'Linux x64',
},
];
}
}
if (platform === 'win32') {
if (arch === 'arm64') {
return [
{
optionalDependencyName: '@openai/codex-win32-arm64',
platformTag: 'win32-arm64',
vendorTarget: 'aarch64-pc-windows-msvc',
reason: 'Windows arm64',
},
];
}
if (arch === 'x64') {
return [
{
optionalDependencyName: '@openai/codex-win32-x64',
platformTag: 'win32-x64',
vendorTarget: 'x86_64-pc-windows-msvc',
reason: 'Windows x64',
},
];
}
}
throw new Error(`Codex app install is not supported on ${platform}/${arch}`);
}
async function fetchText(url: string): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${url}`);
}
return await response.text();
} finally {
clearTimeout(timer);
}
}
async function fetchPackageMetadata(
packageName: string,
version = 'latest'
): Promise<NpmPackageMetadata> {
const url = `${NPM_REGISTRY_BASE_URL}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
const raw = await fetchText(url);
const parsed = JSON.parse(raw) as NpmPackageMetadata;
if (!parsed.version || !parsed.dist?.tarball || !parsed.dist.integrity) {
throw new Error(`Invalid npm metadata for ${packageName}@${version}`);
}
return parsed;
}
export function verifyCodexRuntimePackageIntegrity(buffer: Buffer, integrity: string): void {
const match = /^sha512-([A-Za-z0-9+/=]+)$/.exec(integrity.trim());
if (!match) {
throw new Error('Codex package integrity is missing sha512 metadata');
}
const actual = createHash('sha512').update(buffer).digest('base64');
if (actual !== match[1]) {
throw new Error('Codex package integrity check failed');
}
}
async function downloadTarball(
url: string,
onProgress: (progress: CodexRuntimeInstallProgress) => void
): Promise<Buffer> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok || !response.body) {
throw new Error(`Failed to download Codex package: HTTP ${response.status}`);
}
const totalHeader = response.headers.get('content-length');
const totalBytes = totalHeader ? Number.parseInt(totalHeader, 10) : undefined;
if (totalBytes && totalBytes > MAX_TARBALL_BYTES) {
throw new Error('Codex package is unexpectedly large');
}
const chunks: Buffer[] = [];
let downloadedBytes = 0;
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = Buffer.from(value);
downloadedBytes += chunk.length;
if (downloadedBytes > MAX_TARBALL_BYTES) {
throw new Error('Codex package exceeded the maximum allowed download size');
}
chunks.push(chunk);
onProgress({
phase: 'downloading',
downloadedBytes,
totalBytes,
percent: totalBytes
? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
: undefined,
detail: totalBytes
? `Downloading Codex ${Math.round((downloadedBytes / totalBytes) * 100)}%`
: 'Downloading Codex...',
});
}
return Buffer.concat(chunks, downloadedBytes);
} finally {
clearTimeout(timer);
}
}
function readTarString(buffer: Buffer, start: number, length: number): string {
const end = buffer.indexOf(0, start);
const safeEnd = end >= start && end < start + length ? end : start + length;
return buffer.toString('utf8', start, safeEnd).trim();
}
function readTarOctal(buffer: Buffer, offset: number, length: number, label: string): number {
const raw = readTarString(buffer, offset, length).replace(/\0/g, '').trim();
const value = Number.parseInt(raw || '0', 8);
if (!Number.isFinite(value) || value < 0) {
throw new Error(`Invalid Codex package tar entry ${label}`);
}
return value;
}
function assertSafeTarPath(name: string): void {
if (
!name ||
name.startsWith('/') ||
name.startsWith('\\') ||
name.includes('..') ||
name.includes('\\')
) {
throw new Error(`Unsafe Codex package tar entry: ${name}`);
}
}
export function extractCodexRuntimePackageFilesFromTarball(
tarball: Buffer,
vendorTarget: string,
executableName = getExecutableName()
): CodexRuntimePackageFile[] {
const tar = gunzipSync(tarball, { maxOutputLength: MAX_UNPACKED_BYTES });
const targetPrefix = `package/vendor/${vendorTarget}/`;
const targetBinaryName = `${targetPrefix}codex/${executableName}`;
const files: CodexRuntimePackageFile[] = [];
let foundBinary = false;
let offset = 0;
while (offset + 512 <= tar.length) {
const name = readTarString(tar, offset, 100);
if (!name) {
break;
}
const prefix = readTarString(tar, offset + 345, 155);
const fullName = prefix ? `${prefix}/${name}` : name;
assertSafeTarPath(fullName);
const typeFlag = readTarString(tar, offset + 156, 1);
const mode = readTarOctal(tar, offset + 100, 8, 'mode');
const size = readTarOctal(tar, offset + 124, 12, 'size');
const dataStart = offset + 512;
const dataEnd = dataStart + size;
if (dataEnd > tar.length) {
throw new Error('Codex package tar entry exceeds archive bounds');
}
if ((typeFlag === '0' || typeFlag === '') && fullName.startsWith(targetPrefix)) {
const relativePath = fullName.slice(targetPrefix.length);
assertSafeTarPath(relativePath);
if (relativePath.length > 0) {
files.push({
relativePath,
data: Buffer.from(tar.subarray(dataStart, dataEnd)),
mode,
});
foundBinary = foundBinary || fullName === targetBinaryName;
}
} else if (
fullName.startsWith(targetPrefix) &&
typeFlag !== '5' &&
typeFlag !== '0' &&
typeFlag !== ''
) {
throw new Error(`Unsupported Codex package tar entry type: ${typeFlag || 'unknown'}`);
}
offset = dataStart + Math.ceil(size / 512) * 512;
}
if (!foundBinary) {
throw new Error(`Codex package did not contain ${targetBinaryName}`);
}
return files;
}
async function readCurrentManifest(): Promise<CodexRuntimeManifest | null> {
try {
const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8');
return parseManifest(JSON.parse(raw));
} catch {
return null;
}
}
function parsePlatformVersion(value: string | undefined, fallback: string): string {
const normalized = value?.trim();
if (!normalized) {
return fallback;
}
const aliasMatch = /^npm:@openai\/codex@(.+)$/.exec(normalized);
if (aliasMatch?.[1]) {
return aliasMatch[1];
}
return normalized.replace(/^[~^]/, '');
}
async function writePackageFiles(
rootDir: string,
files: readonly CodexRuntimePackageFile[]
): Promise<void> {
const normalizedRoot = path.resolve(rootDir);
for (const file of files) {
const targetPath = path.resolve(normalizedRoot, file.relativePath);
if (targetPath !== normalizedRoot && !targetPath.startsWith(`${normalizedRoot}${path.sep}`)) {
throw new Error(`Unsafe Codex package output path: ${file.relativePath}`);
}
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.writeFile(targetPath, file.data);
if (process.platform !== 'win32' && (file.mode & 0o111) !== 0) {
// Preserve executable bits for codex, rg, and bundled sandbox helpers.
await fsp.chmod(targetPath, file.mode & 0o777);
}
}
}
export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
private mainWindow: BrowserWindow | null = null;
private installPromise: Promise<CodexRuntimeStatus> | null = null;
private latestStatus: CodexRuntimeStatus | null = null;
setMainWindow(win: BrowserWindow | null): void {
this.mainWindow = win;
}
invalidateStatusCache(): void {
this.latestStatus = null;
}
async getStatus(): Promise<CodexRuntimeStatus> {
if (this.installPromise && this.latestStatus) {
return this.latestStatus;
}
const appManagedStatus = await this.getAppManagedStatus();
if (appManagedStatus.installed) {
this.latestStatus = appManagedStatus;
return appManagedStatus;
}
const pathStatus = await this.getPathStatus();
const status =
pathStatus.installed ||
appManagedStatus.source !== 'app-managed' ||
appManagedStatus.state !== 'failed'
? pathStatus
: appManagedStatus;
this.latestStatus = status;
return status;
}
async install(): Promise<CodexRuntimeStatus> {
if (this.installPromise) {
return this.installPromise;
}
this.installPromise = this.installInternal().finally(() => {
this.installPromise = null;
});
return this.installPromise;
}
private publish(status: CodexRuntimeStatus): void {
this.latestStatus = status;
safeSendToRenderer(this.mainWindow, CHANNEL, status);
}
private publishProgress(progress: CodexRuntimeInstallProgress): void {
this.publish({
installed: false,
source: 'missing',
state: progress.phase,
progress,
});
}
private async getAppManagedStatus(): Promise<CodexRuntimeStatus> {
const manifest = await readCurrentManifest();
if (!isAbsoluteExistingFile(manifest?.binaryPath)) {
return { installed: false, source: 'missing', state: 'idle' };
}
try {
const { stdout } = await execCli(manifest.binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
return {
installed: true,
binaryPath: manifest.binaryPath,
version: stdout.trim() || manifest.platformVersion,
source: 'app-managed',
state: 'ready',
};
} catch (error) {
return {
installed: false,
binaryPath: manifest.binaryPath,
version: manifest.platformVersion,
source: 'app-managed',
state: 'failed',
error: getErrorMessage(error),
};
}
}
private async getPathStatus(): Promise<CodexRuntimeStatus> {
const binaryPath = resolvePathCodexBinary();
if (!binaryPath) {
return { installed: false, source: 'missing', state: 'idle' };
}
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
return {
installed: true,
binaryPath,
version: stdout.trim() || undefined,
source: 'path',
state: 'ready',
};
} catch (error) {
return {
installed: false,
binaryPath,
source: 'path',
state: 'failed',
error: getErrorMessage(error),
};
}
}
private async installInternal(): Promise<CodexRuntimeStatus> {
let tempDir: string | null = null;
try {
this.publishProgress({ phase: 'checking', detail: 'Resolving latest Codex package...' });
const rootMetadata = await fetchPackageMetadata(ROOT_PACKAGE_NAME);
const candidates = getCodexRuntimePlatformCandidates();
const optionalDependencies = rootMetadata.optionalDependencies ?? {};
const selected =
candidates.find((candidate) => optionalDependencies[candidate.optionalDependencyName]) ??
candidates[0];
if (!selected) {
throw new Error(
`No Codex binary package is available for ${process.platform}/${process.arch}`
);
}
const fallbackPlatformVersion = `${rootMetadata.version!}-${selected.platformTag}`;
const platformVersion = parsePlatformVersion(
optionalDependencies[selected.optionalDependencyName],
fallbackPlatformVersion
);
const platformMetadata = await fetchPackageMetadata(ROOT_PACKAGE_NAME, platformVersion);
this.publishProgress({
phase: 'downloading',
detail: `Downloading Codex ${platformMetadata.version}...`,
});
const tarball = await downloadTarball(platformMetadata.dist!.tarball!, (progress) => {
this.publishProgress(progress);
});
verifyCodexRuntimePackageIntegrity(tarball, platformMetadata.dist!.integrity!);
this.publishProgress({ phase: 'installing', detail: 'Extracting Codex runtime...' });
const files = extractCodexRuntimePackageFilesFromTarball(tarball, selected.vendorTarget);
const runtimeRoot = getRuntimeRootPath();
tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
const versionDir = path.join(
runtimeRoot,
'versions',
platformMetadata.version!,
selected.vendorTarget
);
const binaryPath = path.join(versionDir, 'codex', getExecutableName());
await fsp.rm(tempDir, { recursive: true, force: true });
await fsp.mkdir(tempDir, { recursive: true });
await writePackageFiles(tempDir, files);
this.publishProgress({ phase: 'installing', detail: 'Verifying Codex binary...' });
const tempBinaryPath = path.join(tempDir, 'codex', getExecutableName());
const { stdout } = await execCli(tempBinaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
await fsp.rm(versionDir, { recursive: true, force: true });
await fsp.mkdir(path.dirname(versionDir), { recursive: true });
await fsp.rename(tempDir, versionDir);
tempDir = null;
const manifest: CodexRuntimeManifest = {
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
rootVersion: rootMetadata.version!,
platformVersion: platformMetadata.version!,
platformTarget: selected.vendorTarget,
binaryPath,
integrity: platformMetadata.dist!.integrity!,
installedAt: new Date().toISOString(),
};
await fsp.writeFile(
getCurrentManifestPath(),
`${JSON.stringify(manifest, null, 2)}\n`,
'utf8'
);
const status: CodexRuntimeStatus = {
installed: true,
binaryPath,
version: stdout.trim() || manifest.platformVersion,
source: 'app-managed',
state: 'ready',
progress: {
phase: 'ready',
percent: 100,
detail: `Installed Codex ${stdout.trim() || manifest.platformVersion}`,
},
};
this.publish(status);
return status;
} catch (error) {
if (tempDir) {
await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
const status: CodexRuntimeStatus = {
installed: false,
source: 'missing',
state: 'failed',
error: getErrorMessage(error),
progress: {
phase: 'failed',
detail: getErrorMessage(error),
},
};
logger.error('Failed to install Codex runtime:', status.error);
this.publish(status);
return status;
}
}
}

View file

@ -0,0 +1,37 @@
import {
CODEX_RUNTIME_GET_STATUS,
CODEX_RUNTIME_INSTALL,
CODEX_RUNTIME_INVALIDATE_STATUS,
CODEX_RUNTIME_PROGRESS,
} from '@features/codex-runtime-installer/contracts';
import type { CodexRuntimeAPI } from '@features/codex-runtime-installer/contracts';
import type { IpcRenderer } from 'electron';
interface CreateCodexRuntimeInstallerBridgeDeps {
ipcRenderer: IpcRenderer;
invokeIpcWithResult: <T>(channel: string, ...args: unknown[]) => Promise<T>;
}
export function createCodexRuntimeInstallerBridge({
ipcRenderer,
invokeIpcWithResult,
}: CreateCodexRuntimeInstallerBridgeDeps): CodexRuntimeAPI {
return {
getStatus: () => invokeIpcWithResult(CODEX_RUNTIME_GET_STATUS),
install: () => invokeIpcWithResult(CODEX_RUNTIME_INSTALL),
invalidateStatus: () => invokeIpcWithResult(CODEX_RUNTIME_INVALIDATE_STATUS),
onProgress: (callback) => {
ipcRenderer.on(
CODEX_RUNTIME_PROGRESS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
CODEX_RUNTIME_PROGRESS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
};
}

View file

@ -0,0 +1 @@
export { createCodexRuntimeInstallerBridge } from './createCodexRuntimeInstallerBridge';

View file

@ -34,6 +34,7 @@ export interface MemberLogStreamCoverage {
export interface MemberLogStreamWarning {
code:
| 'opencode_ambiguous_lane'
| 'opencode_delivery_delayed'
| 'opencode_missing_runtime_session'
| 'opencode_runtime_unavailable'
| 'opencode_runtime_timeout'

View file

@ -16,7 +16,7 @@ export const DEFAULT_MEMBER_LOG_PREVIEW_BUDGET: MemberLogPreviewBudget = {
maxTranscriptFiles: 8,
maxSourceMessagesPerProvider: 120,
openCodeMessageLimit: 80,
openCodeTimeoutMs: 2_500,
openCodeTimeoutMs: 5_000,
cacheTtlMs: 3_000,
};

View file

@ -134,6 +134,75 @@ API Error: 500 hidden MCP protocol instructions.
expect(result.items[1]?.preview).not.toContain('{"type"');
});
it('marks OpenCode system runtime errors as latest error previews', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
sourceId: 'ses-real-opencode',
sourceLabel: 'OpenCode runtime',
sessionId: 'ses-real-opencode',
laneId: 'secondary:opencode:bob',
messages: [
message({
uuid: 'assistant-before-error',
timestamp: '2026-05-13T17:04:24.347Z',
content: [
{
type: 'text',
text: 'All done. Task #622701b8 completed and approved.',
},
],
}),
message({
uuid: 'opencode-system-error',
type: 'system',
role: 'system',
timestamp: '2026-05-13T17:04:45.546Z',
content: 'OpenCode runtime error - UnknownError: database or disk is full',
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'Runtime error',
preview: 'OpenCode runtime error - UnknownError: database or disk is full',
tone: 'error',
sourceLabel: 'OpenCode runtime',
sessionId: 'ses-real-opencode',
laneId: 'secondary:opencode:bob',
});
expect(result.items[1]).toMatchObject({
title: 'Assistant',
tone: 'neutral',
});
});
it('does not flag normal runtime-error discussion as a runtime failure', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'normal-runtime-error-discussion',
timestamp: '2026-05-13T17:04:24.347Z',
content: [
{ type: 'text', text: 'Fixed OpenCode runtime error handling for the preview.' },
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'Assistant',
preview: 'Fixed OpenCode runtime error handling for the preview.',
tone: 'neutral',
});
});
it('extracts readable inbound task and comment messages without agent-only blocks', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',

View file

@ -45,6 +45,7 @@ describe('memberLogPreviewMergePolicy', () => {
'claude_transcript',
'opencode_runtime',
]);
expect(member.warnings).toEqual([{ code: 'large_log_window_limited', message: 'limited' }]);
expect(member.truncated).toBe(true);
expect(member.overflowCount).toBe(2);
});

View file

@ -403,6 +403,7 @@ function formatRuntimeErrorText(
const payload = parseJsonObjectFromText(compact);
const hasErrorSignal =
/^(api error|runtime error|provider error|tool error)\b/i.test(compact) ||
/^(opencode|codex|claude|openai|anthropic|provider)\s+runtime\s+error\b/i.test(compact) ||
/\b(api|codex|claude|openai|anthropic)\s+api\s+error\b/i.test(compact) ||
/\b(api|codex|claude|openai|anthropic|provider)\s+error\s*:\s*\d{3}\b/i.test(compact) ||
unknownPayloadLooksLikeError(payload);
@ -415,10 +416,11 @@ function formatRuntimeErrorText(
const jsonStart = compact.indexOf('{');
const header = jsonStart > 0 ? compact.slice(0, jsonStart).trim() : '';
const payloadMessage = payload ? payloadErrorMessage(payload) : null;
const fallbackText = payloadMessage ?? header;
const text =
payloadMessage && header && !header.toLowerCase().includes(payloadMessage.toLowerCase())
? `${header} - ${payloadMessage}`
: (payloadMessage ?? header);
: fallbackText || compact;
return { ...truncatePreview(text || 'Runtime error', limit), title };
}
@ -2390,6 +2392,33 @@ export function extractMemberLogPreviewItems(
}
}
if (role === 'system') {
const runtimeErrorPreview = formatRuntimeErrorText(
textFromPreviewContent(message.content),
textLimit
);
if (runtimeErrorPreview) {
candidates.push(
buildCandidate({
provider: input.provider,
sourceId,
message,
messageIndex,
blockIndex: 10,
kind: 'text',
title: runtimeErrorPreview.title,
preview: runtimeErrorPreview.preview,
tone: 'error',
sourceLabel: input.sourceLabel,
sessionId: input.sessionId ?? message.sessionId,
laneId: input.laneId,
token: 'system-runtime-error',
textTruncated: runtimeErrorPreview.truncated,
})
);
}
}
if (role === 'user' && message.isMeta !== true && !messageHasToolResult(message)) {
const inboundPreview = extractInboundTextPreview(message.content, textLimit);
if (inboundPreview) {

View file

@ -1,11 +1,24 @@
import * as path from 'node:path';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import {
createOpenCodePromptDeliveryLedgerStore,
type OpenCodePromptDeliveryLedgerRecord,
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
import { getOpenCodeTeamRuntimeLaneDirectory } from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { mapOpenCodeRuntimeTranscriptMessagesToParsedMessages } from '@main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { extractMemberLogPreviewItems } from '../../../../core/domain/policies/memberLogPreviewExtractor';
import { normalizeMemberName } from './memberLogStreamSourceUtils';
import {
type OpenCodeMemberVisibleActivityEntry,
OpenCodeMemberVisibleActivityReader,
sanitizeOpenCodeVisibleActivityText,
} from './OpenCodeMemberVisibleActivityReader';
import type { MemberLogStreamWarning } from '../../../../contracts';
import type { MemberLogPreviewItem, MemberLogStreamWarning } from '../../../../contracts';
import type {
MemberLogPreviewSource,
MemberLogPreviewSourceInput,
@ -13,23 +26,96 @@ import type {
} from '../../../../core/application/ports/MemberLogPreviewSource';
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
const OPENCODE_PROMPT_DELIVERY_LEDGER_FILE = 'opencode-prompt-delivery-ledger.json';
const MAX_LEDGER_RECORDS_TO_CONSIDER = 24;
const HIDDEN_PREVIEW_BLOCK_TAGS = [
'info_for_agent',
'opencode_runtime_identity',
'opencode_app_message_delivery',
'system-reminder',
] as const;
const ERROR_RESPONSE_STATES: ReadonlySet<string> = new Set([
'permission_blocked',
'tool_error',
'empty_assistant_turn',
'prompt_delivered_no_assistant_message',
'session_stale',
'session_error',
'reconcile_failed',
] as const);
const OPENCODE_DELIVERY_DELAYED_WARNING: MemberLogStreamWarning = {
code: 'opencode_delivery_delayed',
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
};
interface LedgerPreviewCandidate {
item: MemberLogPreviewItem | null;
warning?: MemberLogStreamWarning;
}
interface BinaryResolverLike {
resolve(): Promise<string | null>;
}
interface OpenCodePromptDeliveryLedgerPreviewReader {
list(input: {
teamName: string;
memberName: string;
laneId: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord[]>;
}
class FileOpenCodePromptDeliveryLedgerPreviewReader implements OpenCodePromptDeliveryLedgerPreviewReader {
async list(input: {
teamName: string;
memberName: string;
laneId: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
const laneDir = getOpenCodeTeamRuntimeLaneDirectory(
getTeamsBasePath(),
input.teamName,
input.laneId
);
const store = createOpenCodePromptDeliveryLedgerStore({
filePath: path.join(laneDir, OPENCODE_PROMPT_DELIVERY_LEDGER_FILE),
});
const normalizedMemberName = normalizeMemberName(input.memberName);
return (await store.list()).filter(
(record) =>
record.teamName === input.teamName &&
normalizeMemberName(record.memberName) === normalizedMemberName &&
record.laneId === input.laneId
);
}
}
const DEFAULT_LEDGER_PREVIEW_READER = new FileOpenCodePromptDeliveryLedgerPreviewReader();
const DEFAULT_VISIBLE_ACTIVITY_READER = new OpenCodeMemberVisibleActivityReader();
function classifyOpenCodePreviewError(error: unknown): MemberLogStreamWarning {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
if (normalized.includes('timed out') || normalized.includes('timeout')) {
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : null;
const code = typeof record?.code === 'string' ? record.code : '';
const signal = typeof record?.signal === 'string' ? record.signal : '';
const killed = record?.killed === true ? 'killed' : '';
const normalized = [message, code, signal, killed].join(' ').toLowerCase();
if (
normalized.includes('timed out') ||
normalized.includes('timeout') ||
normalized.includes('code 143') ||
normalized.includes('signal sigterm') ||
normalized.includes('killed')
) {
return {
code: 'opencode_runtime_timeout',
message: 'OpenCode runtime preview timed out; graph preview will use other sources.',
};
}
if (
normalized.includes('--lane') ||
normalized.includes('multiple') ||
normalized.includes('ambiguous')
normalized.includes('ambiguous') ||
normalized.includes('without a safe lane') ||
normalized.includes('requires --lane') ||
(normalized.includes('multiple') && normalized.includes('lane'))
) {
return {
code: 'opencode_ambiguous_lane',
@ -42,6 +128,281 @@ function classifyOpenCodePreviewError(error: unknown): MemberLogStreamWarning {
};
}
function parseTimestampMs(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function ledgerRecordTimestampMs(record: OpenCodePromptDeliveryLedgerRecord): number {
return Math.max(
parseTimestampMs(record.respondedAt),
parseTimestampMs(record.lastObservedAt),
parseTimestampMs(record.failedAt),
parseTimestampMs(record.acceptedAt),
parseTimestampMs(record.lastAttemptAt),
parseTimestampMs(record.updatedAt),
parseTimestampMs(record.createdAt),
parseTimestampMs(record.inboxTimestamp)
);
}
function ledgerRecordTimestampIso(record: OpenCodePromptDeliveryLedgerRecord): string {
return new Date(ledgerRecordTimestampMs(record) || 0).toISOString();
}
function removeHiddenPreviewBlocks(value: string): string {
let result = value;
for (const tag of HIDDEN_PREVIEW_BLOCK_TAGS) {
result = result.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' ');
}
return result;
}
function stripAngleTags(value: string): string {
let result = '';
let insideTag = false;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (!insideTag && char === '<') {
const next = value[index + 1] ?? '';
if (/[A-Za-z/!]/.test(next)) {
insideTag = true;
result += ' ';
continue;
}
}
if (insideTag) {
if (char === '>') {
insideTag = false;
result += ' ';
}
continue;
}
result += char;
}
return result;
}
function sanitizeLedgerPreviewText(value: string, limit: number): string {
const compact = stripAngleTags(removeHiddenPreviewBlocks(value))
.replace(/\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '$1')
.replace(/\s+/g, ' ')
.trim();
if (compact.length <= limit) {
return compact;
}
const allowed = Math.max(1, limit - 3);
return `${compact.slice(0, allowed)}...`;
}
function formatLedgerToolNames(toolNames: readonly string[], limit: number): string {
const uniqueNames = [...new Set(toolNames.map((name) => name.trim()).filter(Boolean))];
const visibleNames = uniqueNames.slice(0, 5);
const suffix = uniqueNames.length > visibleNames.length ? ` +${uniqueNames.length - 5} more` : '';
return sanitizeLedgerPreviewText(`${visibleNames.join(', ')}${suffix}`, limit);
}
function formatTaskRefs(record: OpenCodePromptDeliveryLedgerRecord): string {
const refs = record.taskRefs
.map((taskRef) => taskRef.displayId || taskRef.taskId.slice(0, 8))
.filter(Boolean)
.slice(0, 2);
return refs.length > 0 ? ` for #${refs.join(', #')}` : '';
}
function ledgerStatusText(record: OpenCodePromptDeliveryLedgerRecord): string {
const taskSuffix = formatTaskRefs(record);
switch (record.status) {
case 'pending':
return `Prompt queued${taskSuffix}`;
case 'accepted':
return `Prompt accepted${taskSuffix}`;
case 'responded':
return `Response observed${taskSuffix}`;
case 'unanswered':
return `Prompt delivered, response not observed yet${taskSuffix}`;
case 'retry_scheduled':
return `Delivery retry scheduled${taskSuffix}`;
case 'retried':
return `Prompt retried${taskSuffix}`;
case 'failed_retryable':
return `Delivery retry pending${taskSuffix}`;
case 'failed_terminal':
return `Delivery failed${taskSuffix}`;
default:
return `OpenCode delivery updated${taskSuffix}`;
}
}
function ledgerRecordHasDeliveryIssue(record: OpenCodePromptDeliveryLedgerRecord): boolean {
return record.status === 'failed_terminal' || ERROR_RESPONSE_STATES.has(record.responseState);
}
function firstNonEmptyText(values: readonly (string | null | undefined)[]): string {
return values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim() ?? '';
}
function ledgerRecordHasObservedEvidence(record: OpenCodePromptDeliveryLedgerRecord): boolean {
return (
Boolean(record.observedAssistantPreview?.trim()) ||
record.observedToolCallNames.length > 0 ||
Boolean(record.observedVisibleMessageId?.trim()) ||
record.responseState === 'responded_visible_message' ||
record.responseState === 'responded_plain_text'
);
}
function buildLedgerPreviewWarning(
record: OpenCodePromptDeliveryLedgerRecord
): MemberLogStreamWarning | undefined {
if (!ledgerRecordHasDeliveryIssue(record)) {
return undefined;
}
return OPENCODE_DELIVERY_DELAYED_WARNING;
}
function buildLedgerPreviewItem(
record: OpenCodePromptDeliveryLedgerRecord,
input: MemberLogPreviewSourceInput
): LedgerPreviewCandidate {
const timestamp = ledgerRecordTimestampIso(record);
const warning = buildLedgerPreviewWarning(record);
const sourceBase = {
provider: 'opencode_runtime' as const,
timestamp,
sourceLabel: 'OpenCode delivery',
sessionId: record.runtimeSessionId ?? undefined,
laneId: input.laneId,
};
if (record.observedAssistantPreview?.trim()) {
return {
item: {
...sourceBase,
id: `opencode-ledger:${record.id}:assistant`,
kind: 'text',
title:
record.responseState === 'responded_visible_message' ||
record.responseState === 'responded_plain_text'
? 'OpenCode reply'
: 'Assistant',
preview: sanitizeLedgerPreviewText(record.observedAssistantPreview, input.textLimit),
tone: 'neutral',
},
warning,
};
}
if (record.observedToolCallNames.length > 0) {
return {
item: {
...sourceBase,
id: `opencode-ledger:${record.id}:tools`,
kind: 'tool_use',
title: 'Tool activity',
preview: formatLedgerToolNames(record.observedToolCallNames, input.textLimit),
tone: 'neutral',
},
warning,
};
}
if (
record.responseState === 'responded_visible_message' ||
record.responseState === 'responded_plain_text'
) {
return {
item: {
...sourceBase,
id: `opencode-ledger:${record.id}:reply`,
kind: 'text',
title: 'OpenCode reply',
preview: sanitizeLedgerPreviewText(ledgerStatusText(record), input.textLimit),
tone: 'success',
},
warning,
};
}
if (ledgerRecordHasDeliveryIssue(record) && !ledgerRecordHasObservedEvidence(record)) {
return { item: null, warning };
}
const statusText = sanitizeLedgerPreviewText(
firstNonEmptyText([record.lastReason, ledgerStatusText(record)]),
input.textLimit
);
return statusText
? {
item: {
...sourceBase,
id: `opencode-ledger:${record.id}:status`,
kind: 'text',
title: 'OpenCode status',
preview: statusText,
tone:
record.status === 'failed_retryable' || record.status === 'retry_scheduled'
? 'warning'
: 'neutral',
},
warning,
}
: { item: null, warning };
}
function dedupeWarnings(warnings: readonly MemberLogStreamWarning[]): MemberLogStreamWarning[] {
const seen = new Set<string>();
const result: MemberLogStreamWarning[] = [];
for (const warning of warnings) {
const key = `${warning.code}:${warning.message}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(warning);
}
return result;
}
function previewItemTimestampMs(item: MemberLogPreviewItem): number {
const parsed = Date.parse(item.timestamp);
return Number.isFinite(parsed) ? parsed : 0;
}
function comparePreviewItemsNewestFirst(
left: MemberLogPreviewItem,
right: MemberLogPreviewItem
): number {
const byTime = previewItemTimestampMs(right) - previewItemTimestampMs(left);
return byTime !== 0 ? byTime : right.id.localeCompare(left.id);
}
function dedupePreviewItems(items: readonly MemberLogPreviewItem[]): MemberLogPreviewItem[] {
const deduped = new Map<string, MemberLogPreviewItem>();
for (const item of items) {
if (!deduped.has(item.id)) {
deduped.set(item.id, item);
}
}
return [...deduped.values()];
}
function buildVisibleActivityPreviewItem(
entry: OpenCodeMemberVisibleActivityEntry,
input: MemberLogPreviewSourceInput
): MemberLogPreviewItem {
return {
id: `${entry.id}:preview`,
kind: 'text',
provider: 'opencode_runtime',
timestamp: entry.timestamp,
title: entry.title,
preview: sanitizeOpenCodeVisibleActivityText(entry.text, input.textLimit),
tone: entry.title === 'Agent error' ? 'error' : 'neutral',
sourceLabel: entry.sourceLabel,
laneId: input.laneId,
};
}
export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSource {
readonly provider = 'opencode_runtime' as const;
private readonly cache = new Map<
@ -52,7 +413,9 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
constructor(
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver,
private readonly ledgerReader: OpenCodePromptDeliveryLedgerPreviewReader = DEFAULT_LEDGER_PREVIEW_READER,
private readonly visibleActivityReader: OpenCodeMemberVisibleActivityReader = DEFAULT_VISIBLE_ACTIVITY_READER
) {}
async loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult> {
@ -108,11 +471,140 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
};
}
const ledgerResult = await this.buildLedgerResult(input);
const visibleActivityResult = await this.buildVisibleActivityResult(input, ledgerResult);
if (visibleActivityResult.status === 'included') {
return visibleActivityResult;
}
if (ledgerResult.status === 'included') {
return {
...ledgerResult,
warnings: dedupeWarnings([...ledgerResult.warnings, ...visibleActivityResult.warnings]),
};
}
return this.buildTranscriptResult(
input,
dedupeWarnings([...ledgerResult.warnings, ...visibleActivityResult.warnings])
);
}
private async buildVisibleActivityResult(
input: MemberLogPreviewSourceInput,
ledgerResult: MemberLogPreviewSourceResult
): Promise<MemberLogPreviewSourceResult> {
try {
const activityItems = (
await this.visibleActivityReader.list({
teamName: input.teamName,
memberName: input.memberName,
forceRefresh: input.forceRefresh,
})
).map((entry) => buildVisibleActivityPreviewItem(entry, input));
const mergedItems = dedupePreviewItems([...activityItems, ...ledgerResult.items]).sort(
comparePreviewItemsNewestFirst
);
const items = mergedItems.slice(0, input.maxItems);
const overflowCount = Math.max(
0,
mergedItems.length - items.length + ledgerResult.overflowCount
);
return {
provider: this.provider,
status: items.length > 0 ? 'included' : 'skipped',
reason: items.length > 0 ? undefined : 'opencode_visible_activity_empty',
items,
warnings: [...ledgerResult.warnings],
truncated: overflowCount > 0,
overflowCount,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_visible_activity_unavailable',
items: [],
warnings: [
...ledgerResult.warnings,
{
code: 'opencode_runtime_unavailable',
message: `OpenCode visible activity preview is unavailable: ${message}`,
},
],
truncated: false,
overflowCount: 0,
};
}
}
private async buildLedgerResult(
input: MemberLogPreviewSourceInput
): Promise<MemberLogPreviewSourceResult> {
try {
const orderedRecords = (
await this.ledgerReader.list({
teamName: input.teamName,
memberName: input.memberName,
laneId: input.laneId ?? '',
})
).sort((left, right) => {
const byTime = ledgerRecordTimestampMs(right) - ledgerRecordTimestampMs(left);
return byTime !== 0 ? byTime : right.id.localeCompare(left.id);
});
const records = orderedRecords.slice(0, MAX_LEDGER_RECORDS_TO_CONSIDER);
const candidates = records.map((record) => buildLedgerPreviewItem(record, input));
const previewItems = candidates
.map((candidate) => candidate.item)
.filter((item): item is MemberLogPreviewItem => Boolean(item));
const warnings = dedupeWarnings(
candidates
.map((candidate) => candidate.warning)
.filter((warning): warning is MemberLogStreamWarning => Boolean(warning))
);
const items = previewItems.slice(0, input.maxItems);
const overflowCount = Math.max(0, previewItems.length - items.length);
return {
provider: this.provider,
status: items.length > 0 ? 'included' : 'skipped',
reason:
items.length > 0
? undefined
: warnings.length > 0
? 'opencode_delivery_delayed'
: 'opencode_delivery_ledger_empty',
items,
warnings,
truncated: overflowCount > 0,
overflowCount,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return this.skipped(
'opencode_runtime_unavailable',
'OpenCode delivery ledger preview is unavailable.',
{
code: 'opencode_runtime_unavailable',
message: `OpenCode delivery ledger preview is unavailable: ${message}`,
}
);
}
}
private async buildTranscriptResult(
input: MemberLogPreviewSourceInput,
extraWarnings: readonly MemberLogStreamWarning[]
): Promise<MemberLogPreviewSourceResult> {
const binaryPath = await this.binaryResolver.resolve();
if (!binaryPath) {
return this.skipped(
'opencode_runtime_unavailable',
'OpenCode runtime bridge is unavailable.'
'OpenCode runtime bridge is unavailable.',
{
code: 'opencode_runtime_unavailable',
message: 'OpenCode runtime bridge is unavailable.',
},
extraWarnings
);
}
@ -134,7 +626,7 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
status: 'skipped',
reason: 'opencode_missing_runtime_session',
items: [],
warnings: [],
warnings: [...extraWarnings],
truncated: false,
overflowCount: 0,
};
@ -160,27 +652,28 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
status: extracted.items.length > 0 ? 'included' : 'skipped',
reason: extracted.items.length > 0 ? undefined : 'opencode_no_renderable_preview',
items: extracted.items,
warnings: [],
warnings: [...extraWarnings],
truncated: extracted.truncated,
overflowCount: extracted.overflowCount,
};
} catch (error) {
const warning = classifyOpenCodePreviewError(error);
return this.skipped(warning.code, warning.message, warning);
return this.skipped(warning.code, warning.message, warning, extraWarnings);
}
}
private skipped(
code: MemberLogStreamWarning['code'],
reason: string,
warning: MemberLogStreamWarning = { code, message: reason }
warning: MemberLogStreamWarning | undefined = { code, message: reason },
extraWarnings: readonly MemberLogStreamWarning[] = []
): MemberLogPreviewSourceResult {
return {
provider: this.provider,
status: 'skipped',
reason,
items: [],
warnings: [warning],
warnings: [...extraWarnings, ...(warning ? [warning] : [])],
truncated: false,
overflowCount: 0,
};

View file

@ -10,6 +10,10 @@ import {
normalizeMemberName,
withSegmentSource,
} from './memberLogStreamSourceUtils';
import {
type OpenCodeMemberVisibleActivityEntry,
OpenCodeMemberVisibleActivityReader,
} from './OpenCodeMemberVisibleActivityReader';
import type { MemberLogStreamWarning } from '../../../../contracts';
import type {
@ -19,26 +23,39 @@ import type {
} from '../../../../core/application/ports/MemberLogStreamSource';
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
import type { ParsedMessage } from '@main/types';
interface BinaryResolverLike {
resolve(): Promise<string | null>;
}
const CACHE_TTL_MS = 1_500;
const DEFAULT_VISIBLE_ACTIVITY_READER = new OpenCodeMemberVisibleActivityReader();
function classifyOpenCodeError(error: unknown): MemberLogStreamWarning {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
if (normalized.includes('timed out') || normalized.includes('timeout')) {
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : null;
const code = typeof record?.code === 'string' ? record.code : '';
const signal = typeof record?.signal === 'string' ? record.signal : '';
const killed = record?.killed === true ? 'killed' : '';
const normalized = [message, code, signal, killed].join(' ').toLowerCase();
if (
normalized.includes('timed out') ||
normalized.includes('timeout') ||
normalized.includes('code 143') ||
normalized.includes('signal sigterm') ||
normalized.includes('killed')
) {
return {
code: 'opencode_runtime_timeout',
message: 'OpenCode runtime transcript timed out; showing other member logs only.',
};
}
if (
normalized.includes('--lane') ||
normalized.includes('multiple') ||
normalized.includes('ambiguous')
normalized.includes('ambiguous') ||
normalized.includes('without a safe lane') ||
normalized.includes('requires --lane') ||
(normalized.includes('multiple') && normalized.includes('lane'))
) {
return {
code: 'opencode_ambiguous_lane',
@ -62,7 +79,8 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
constructor(
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder,
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver,
private readonly visibleActivityReader: OpenCodeMemberVisibleActivityReader = DEFAULT_VISIBLE_ACTIVITY_READER
) {}
async load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
@ -102,8 +120,14 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
): Promise<MemberLogStreamSourceResult> {
const binaryPath = await this.binaryResolver.resolve();
if (!binaryPath) {
return this.skipped(
'opencode_runtime_unavailable',
return this.loadVisibleActivityFallback(
input,
[
{
code: 'opencode_runtime_unavailable',
message: 'OpenCode runtime bridge is unavailable.',
},
],
'OpenCode runtime bridge is unavailable.'
);
}
@ -121,38 +145,17 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
projectedMessages
).sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
if (parsedMessages.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_missing_runtime_session',
participants: [],
segments: [],
warnings: [],
};
return this.loadVisibleActivityFallback(input, [], 'opencode_missing_runtime_session');
}
const budgeted = applyMemberLogMessageBudget(parsedMessages, input.budget);
if (budgeted.messages.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_no_renderable_chunks',
participants: [],
segments: [],
warnings: [],
};
return this.loadVisibleActivityFallback(input, [], 'opencode_no_renderable_chunks');
}
const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages);
if (chunks.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_no_renderable_chunks',
participants: [],
segments: [],
warnings: [],
};
return this.loadVisibleActivityFallback(input, [], 'opencode_no_renderable_chunks');
}
const first = budgeted.messages[0];
@ -228,14 +231,143 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
};
} catch (error) {
const warning = classifyOpenCodeError(error);
return this.skipped(warning.code, warning.message, warning);
return this.loadVisibleActivityFallback(input, [warning], warning.message);
}
}
private async loadVisibleActivityFallback(
input: MemberLogStreamSourceInput,
warnings: readonly MemberLogStreamWarning[],
skippedReason: string
): Promise<MemberLogStreamSourceResult> {
try {
const entries = await this.visibleActivityReader.list({
teamName: input.teamName,
memberName: input.memberName,
forceRefresh: input.forceRefresh,
});
if (entries.length === 0) {
return this.skippedFromWarnings(skippedReason, warnings);
}
const messages = entries
.map((entry) => toVisibleActivityParsedMessage(entry, input.memberName))
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
const budgeted = applyMemberLogMessageBudget(messages, input.budget);
if (budgeted.messages.length === 0) {
return this.skippedFromWarnings(skippedReason, warnings);
}
const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages);
if (chunks.length === 0) {
return this.skippedFromWarnings(skippedReason, warnings);
}
const first = budgeted.messages[0];
const last = budgeted.messages[budgeted.messages.length - 1];
if (!first || !last) {
return this.skippedFromWarnings(skippedReason, warnings);
}
const participant = buildMemberParticipant(input.memberName);
const sessionId = `opencode-visible:${normalizeMemberName(input.memberName)}`;
const segment = withSegmentSource(
{
id: buildSegmentId({
provider: this.provider,
teamName: input.teamName,
memberName: input.memberName,
sessionId,
fingerprint: `${sessionId}:${input.laneId ?? ''}:${budgeted.messages.length}`,
startTimestamp: first.timestamp.toISOString(),
}),
participantKey: participant.key,
actor: buildMemberActor({
memberName: input.memberName,
sessionId,
role: 'member',
}),
startTimestamp: first.timestamp.toISOString(),
endTimestamp: last.timestamp.toISOString(),
chunks,
},
{
provider: this.provider,
label: 'OpenCode visible activity',
sessionId,
...(input.laneId ? { laneId: input.laneId } : {}),
messageCount: budgeted.messages.length,
truncated:
budgeted.droppedMessageCount > 0 ||
budgeted.segmentWindowLimited ||
budgeted.contentLimited,
}
);
const resultWarnings = [...warnings];
if (budgeted.segmentWindowLimited) {
resultWarnings.push({
code: 'segment_message_window_limited',
message: 'OpenCode visible activity was trimmed to recent messages.',
});
}
if (budgeted.contentLimited) {
resultWarnings.push({
code: 'message_content_limited',
message: 'Some large OpenCode visible activity content was truncated before rendering.',
});
}
return {
provider: this.provider,
status: 'included',
participants: [participant],
segments: [segment],
warnings: resultWarnings,
metadata: {
droppedMessageCount: budgeted.droppedMessageCount,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return this.skippedFromWarnings(skippedReason, [
...warnings,
{
code: 'opencode_runtime_unavailable',
message: `OpenCode visible activity fallback is unavailable: ${message}`,
},
]);
}
}
private skippedFromWarnings(
reason: string,
warnings: readonly MemberLogStreamWarning[]
): MemberLogStreamSourceResult {
if (warnings.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason,
participants: [],
segments: [],
warnings: [],
};
}
const firstWarning = warnings[0];
return this.skipped(
firstWarning?.code ?? 'opencode_runtime_unavailable',
reason,
firstWarning,
[...warnings.slice(1)]
);
}
private skipped(
code: MemberLogStreamWarning['code'],
reason: string,
warning: MemberLogStreamWarning = { code, message: reason }
warning: MemberLogStreamWarning = { code, message: reason },
extraWarnings: readonly MemberLogStreamWarning[] = []
): MemberLogStreamSourceResult {
return {
provider: this.provider,
@ -243,7 +375,27 @@ export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource
reason,
participants: [],
segments: [],
warnings: [warning],
warnings: [warning, ...extraWarnings],
};
}
}
function toVisibleActivityParsedMessage(
entry: OpenCodeMemberVisibleActivityEntry,
memberName: string
): ParsedMessage {
return {
uuid: entry.id,
parentUuid: null,
type: 'assistant',
timestamp: new Date(entry.timestamp),
role: 'assistant',
content: `${entry.title}: ${entry.text}`,
isSidechain: true,
isMeta: false,
sessionId: `opencode-visible:${normalizeMemberName(memberName)}`,
agentName: memberName,
toolCalls: [],
toolResults: [],
};
}

View file

@ -0,0 +1,220 @@
import { TeamInboxReader } from '@main/services/team/TeamInboxReader';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { normalizeMemberName, shortHash } from './memberLogStreamSourceUtils';
import type { InboxMessage } from '@shared/types';
const MAX_VISIBLE_ACTIVITY_MESSAGES_TO_CONSIDER = 160;
const MAX_VISIBLE_ACTIVITY_ENTRIES = 24;
const TEAM_MESSAGES_CACHE_TTL_MS = 1_500;
const HIDDEN_ACTIVITY_BLOCK_TAGS = [
'info_for_agent',
'opencode_runtime_identity',
'opencode_app_message_delivery',
'system-reminder',
] as const;
export interface OpenCodeVisibleActivityInboxReader {
getMessages(teamName: string): Promise<InboxMessage[]>;
}
export interface OpenCodeMemberVisibleActivityEntry {
id: string;
timestamp: string;
title: string;
text: string;
sourceLabel: string;
message: InboxMessage;
}
export class OpenCodeMemberVisibleActivityReader {
private readonly teamMessagesCache = new Map<
string,
{ expiresAt: number; messages: readonly InboxMessage[] }
>();
private readonly teamMessagesInFlight = new Map<string, Promise<readonly InboxMessage[]>>();
constructor(
private readonly inboxReader: OpenCodeVisibleActivityInboxReader = new TeamInboxReader()
) {}
async list(input: {
teamName: string;
memberName: string;
forceRefresh?: boolean;
}): Promise<OpenCodeMemberVisibleActivityEntry[]> {
const normalizedMemberName = normalizeMemberName(input.memberName);
const messages = (await this.getTeamMessages(input.teamName, input.forceRefresh === true))
.filter((message) => isVisibleMemberActivityMessage(message, normalizedMemberName))
.sort(compareInboxMessagesNewestFirst)
.slice(0, MAX_VISIBLE_ACTIVITY_MESSAGES_TO_CONSIDER);
const deduped = new Map<string, OpenCodeMemberVisibleActivityEntry>();
for (const message of messages) {
const entry = toVisibleActivityEntry(message);
if (!entry || deduped.has(entry.id)) {
continue;
}
deduped.set(entry.id, entry);
if (deduped.size >= MAX_VISIBLE_ACTIVITY_ENTRIES) {
break;
}
}
return [...deduped.values()];
}
private async getTeamMessages(
teamName: string,
forceRefresh: boolean
): Promise<readonly InboxMessage[]> {
const cacheKey = `${getTeamsBasePath()}::${teamName}`;
if (!forceRefresh) {
const cached = this.teamMessagesCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.messages;
}
const inFlight = this.teamMessagesInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
}
const promise = this.inboxReader
.getMessages(teamName)
.then((messages) => {
this.teamMessagesCache.set(cacheKey, {
expiresAt: Date.now() + TEAM_MESSAGES_CACHE_TTL_MS,
messages,
});
return messages;
})
.finally(() => {
this.teamMessagesInFlight.delete(cacheKey);
});
this.teamMessagesInFlight.set(cacheKey, promise);
return promise;
}
}
export function sanitizeOpenCodeVisibleActivityText(value: string, limit?: number): string {
const compact = stripAngleTags(removeHiddenActivityBlocks(value))
.replace(/\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '$1')
.replace(/^\s*>\s?/gm, '')
.replace(/\s+/g, ' ')
.trim();
if (!limit || compact.length <= limit) {
return compact;
}
const allowed = Math.max(1, limit - 3);
return `${compact.slice(0, allowed)}...`;
}
function isVisibleMemberActivityMessage(
message: InboxMessage,
normalizedMemberName: string
): boolean {
if (normalizeMemberName(message.from) !== normalizedMemberName) {
return false;
}
if (!message.timestamp || !Number.isFinite(Date.parse(message.timestamp))) {
return false;
}
const text = message.summary ?? message.text;
if (!text || isInboxNoiseMessage(text)) {
return false;
}
return sanitizeOpenCodeVisibleActivityText(text).length > 0;
}
function toVisibleActivityEntry(message: InboxMessage): OpenCodeMemberVisibleActivityEntry | null {
const text = sanitizeOpenCodeVisibleActivityText(
[message.summary, message.text].filter(Boolean).join('\n\n')
);
if (!text) {
return null;
}
const id = buildVisibleActivityId(message);
return {
id,
timestamp: message.timestamp,
title: buildVisibleActivityTitle(message),
text,
sourceLabel: 'OpenCode visible activity',
message,
};
}
function buildVisibleActivityId(message: InboxMessage): string {
const messageKey =
message.messageId ??
[message.timestamp, message.from, message.to ?? '', message.summary ?? '', message.text].join(
'\u0000'
);
return `opencode-visible:${shortHash(messageKey)}`;
}
function buildVisibleActivityTitle(message: InboxMessage): string {
if (message.messageKind === 'task_comment_notification' || isCommentSummary(message.summary)) {
return 'Comment added';
}
if (message.messageKind === 'agent_error') {
return 'Agent error';
}
const text = `${message.summary ?? ''} ${message.text ?? ''}`.toLowerCase();
if (/\b(done|completed|approved|fixed|verified)\b/i.test(text) || /заверш|готов/i.test(text)) {
return 'Task completed';
}
if (message.to) {
return 'Message sent';
}
return 'Team message';
}
function isCommentSummary(value: string | undefined): boolean {
return value?.trim().toLowerCase().startsWith('comment on #') === true;
}
function compareInboxMessagesNewestFirst(left: InboxMessage, right: InboxMessage): number {
const byTime = Date.parse(right.timestamp) - Date.parse(left.timestamp);
if (byTime !== 0) {
return byTime;
}
return buildVisibleActivityId(right).localeCompare(buildVisibleActivityId(left));
}
function removeHiddenActivityBlocks(value: string): string {
let result = value;
for (const tag of HIDDEN_ACTIVITY_BLOCK_TAGS) {
result = result.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' ');
}
return result;
}
function stripAngleTags(value: string): string {
let result = '';
let insideTag = false;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (!insideTag && char === '<') {
const next = value[index + 1] ?? '';
if (/[A-Za-z/!]/.test(next)) {
insideTag = true;
result += ' ';
continue;
}
}
if (insideTag) {
if (char === '>') {
insideTag = false;
result += ' ';
}
continue;
}
result += char;
}
return result;
}

View file

@ -104,6 +104,12 @@ export interface MemberWorkSyncShadowDiagnostics {
fingerprintChanged: boolean;
previousFingerprint?: string;
triggerReasons?: string[];
recovery?: {
kind: 'proof_missing';
intentKey: string;
originalMessageId: string;
taskIds: string[];
};
}
export interface MemberWorkSyncStatus {

View file

@ -31,6 +31,18 @@ export async function appendMemberWorkSyncAudit(
}
export function reasonToAuditEvent(reason: string): MemberWorkSyncAuditEventName {
if (reason === 'proof_missing_recovery_scheduled') {
return 'proof_missing_recovery_scheduled';
}
if (reason === 'proof_missing_recovery_coalesced') {
return 'proof_missing_recovery_coalesced';
}
if (reason === 'proof_missing_recovery_suppressed') {
return 'proof_missing_recovery_suppressed';
}
if (reason === 'proof_missing_recovery_conflict') {
return 'proof_missing_recovery_conflict';
}
if (reason.startsWith('member_busy:')) {
return 'member_busy';
}

View file

@ -1,8 +1,17 @@
import {
isReviewPickupAgenda,
isStrictReviewPickupItem,
} from './MemberWorkSyncNudgeAgendaPredicates';
import {
decideMemberWorkSyncTargetedRecovery,
type MemberWorkSyncTargetedRecoveryReason,
} from './MemberWorkSyncTargetedRecoveryPolicy';
import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts';
export type MemberWorkSyncNudgeActivationReason =
| 'shadow_ready'
| 'opencode_targeted_shadow_collecting'
| MemberWorkSyncTargetedRecoveryReason
| 'review_pickup_required'
| 'status_not_nudgeable'
| 'blocking_metrics'
@ -23,31 +32,6 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean {
return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason));
}
function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean {
return (
status.providerId === 'opencode' &&
status.state === 'needs_sync' &&
status.agenda.items.length > 0 &&
!isReviewPickupAgenda(status) &&
status.shadow?.wouldNudge === true
);
}
function isStrictReviewPickupItem(item: MemberWorkSyncStatus['agenda']['items'][number]): boolean {
return (
item.kind === 'review' &&
item.evidence.reviewObligation === 'review_pickup_required' &&
item.evidence.canBypassPhase2 === true &&
typeof item.evidence.reviewRequestEventId === 'string' &&
item.evidence.reviewRequestEventId.length > 0 &&
(item.evidence.reviewDiagnostics?.length ?? 0) === 0
);
}
function isReviewPickupAgenda(status: MemberWorkSyncStatus): boolean {
return status.agenda.items.length > 0 && status.agenda.items.every(isStrictReviewPickupItem);
}
function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean {
return (
status.state === 'needs_sync' &&
@ -72,6 +56,11 @@ export function decideMemberWorkSyncNudgeActivation(input: {
return { active: true, reason: 'review_pickup_required' };
}
const targetedRecovery = decideMemberWorkSyncTargetedRecovery(input.status);
if (targetedRecovery.active) {
return { active: true, reason: targetedRecovery.reason };
}
if (hasBlockingMetrics(input.metrics)) {
return { active: false, reason: 'blocking_metrics' };
}
@ -84,12 +73,5 @@ export function decideMemberWorkSyncNudgeActivation(input: {
return { active: true, reason: 'shadow_ready' };
}
if (
input.metrics.phase2Readiness.state === 'collecting_shadow_data' &&
isOpenCodeTargetedCandidate(input.status)
) {
return { active: true, reason: 'opencode_targeted_shadow_collecting' };
}
return { active: false, reason: 'phase2_not_ready' };
}

View file

@ -0,0 +1,18 @@
import type { MemberWorkSyncStatus } from '../../contracts';
export function isStrictReviewPickupItem(
item: MemberWorkSyncStatus['agenda']['items'][number]
): boolean {
return (
item.kind === 'review' &&
item.evidence.reviewObligation === 'review_pickup_required' &&
item.evidence.canBypassPhase2 === true &&
typeof item.evidence.reviewRequestEventId === 'string' &&
item.evidence.reviewRequestEventId.length > 0 &&
(item.evidence.reviewDiagnostics?.length ?? 0) === 0
);
}
export function isReviewPickupAgenda(status: MemberWorkSyncStatus): boolean {
return status.agenda.items.length > 0 && status.agenda.items.every(isStrictReviewPickupItem);
}

View file

@ -61,6 +61,17 @@ function isReviewPickupOutboxItem(item: MemberWorkSyncOutboxItem): boolean {
return item.payload.workSyncIntent === 'review_pickup';
}
function getProofMissingRecoveryOriginalMessageId(item: MemberWorkSyncOutboxItem): string | null {
const prefix = 'proof-missing:';
const intentKey = item.payload.workSyncIntentKey?.trim();
if (!intentKey?.startsWith(prefix)) {
return null;
}
const originalMessageId = intentKey.slice(prefix.length).trim();
return originalMessageId.length > 0 ? originalMessageId : null;
}
function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] {
return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])]
.filter((id) => id.length > 0)
@ -463,6 +474,11 @@ export class MemberWorkSyncNudgeDispatcher {
}
}
const proofMissingRecovery = await this.revalidateProofMissingRecovery(item, nowIso);
if (!proofMissingRecovery.ok) {
return proofMissingRecovery;
}
const recentDelivered = await this.deps.outboxStore?.countRecentDelivered({
teamName: item.teamName,
memberName: item.memberName,
@ -485,6 +501,7 @@ export class MemberWorkSyncNudgeDispatcher {
memberName: item.memberName,
nowIso,
workSyncIntent: item.payload.workSyncIntent,
workSyncIntentKey: item.payload.workSyncIntentKey,
taskRefs: item.payload.taskRefs,
});
if (busy?.busy) {
@ -512,6 +529,32 @@ export class MemberWorkSyncNudgeDispatcher {
return { ok: true, ...(providerId ? { providerId } : {}) };
}
private async revalidateProofMissingRecovery(
item: MemberWorkSyncOutboxItem,
nowIso: string
): Promise<
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
> {
const originalMessageId = getProofMissingRecoveryOriginalMessageId(item);
if (!originalMessageId) {
return { ok: true };
}
const guard = this.deps.proofMissingRecoveryGuard;
if (!guard) {
return { ok: true };
}
return guard.shouldDispatch({
teamName: item.teamName,
memberName: item.memberName,
intentKey: item.payload.workSyncIntentKey ?? '',
originalMessageId,
taskIds: item.payload.taskRefs.map((taskRef) => taskRef.taskId),
nowIso,
});
}
private async scheduleDeliveryWake(
item: MemberWorkSyncOutboxItem,
messageId: string,

View file

@ -14,6 +14,12 @@ import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from
export interface MemberWorkSyncReconcileContext {
reconciledBy?: 'request' | 'queue';
triggerReasons?: string[];
recovery?: {
kind: 'proof_missing';
intentKey: string;
originalMessageId: string;
taskIds?: string[];
};
}
export function finalizeMemberWorkSyncAgenda(
@ -107,6 +113,16 @@ export class MemberWorkSyncReconciler {
...(context.triggerReasons?.length
? { triggerReasons: [...new Set(context.triggerReasons)].sort() }
: {}),
...(context.recovery
? {
recovery: {
kind: context.recovery.kind,
intentKey: context.recovery.intentKey,
originalMessageId: context.recovery.originalMessageId,
taskIds: [...new Set(context.recovery.taskIds ?? [])].sort(),
},
}
: {}),
},
evaluatedAt: nowIso,
diagnostics: [

View file

@ -0,0 +1,69 @@
import { isReviewPickupAgenda } from './MemberWorkSyncNudgeAgendaPredicates';
import type { MemberWorkSyncStatus } from '../../contracts';
export type MemberWorkSyncTargetedRecoveryReason =
| 'opencode_targeted_shadow_collecting'
| 'lead_targeted_shadow_collecting';
export type MemberWorkSyncTargetedRecoveryCapability =
| 'opencode_runtime_delivery'
| 'lead_inbox_relay';
export type MemberWorkSyncTargetedRecoveryDecision =
| {
active: true;
reason: MemberWorkSyncTargetedRecoveryReason;
capability: MemberWorkSyncTargetedRecoveryCapability;
}
| { active: false };
function isLeadLikeMemberName(memberName: string): boolean {
const normalized = memberName
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-');
return (
normalized === 'lead' ||
normalized === 'team-lead' ||
normalized === 'teamlead' ||
normalized === 'team-leader'
);
}
function resolveTargetedRecoveryCapability(status: MemberWorkSyncStatus): {
capability: MemberWorkSyncTargetedRecoveryCapability;
reason: MemberWorkSyncTargetedRecoveryReason;
} | null {
if (status.providerId === 'opencode') {
return {
capability: 'opencode_runtime_delivery',
reason: 'opencode_targeted_shadow_collecting',
};
}
if (isLeadLikeMemberName(status.memberName)) {
return {
capability: 'lead_inbox_relay',
reason: 'lead_targeted_shadow_collecting',
};
}
return null;
}
export function decideMemberWorkSyncTargetedRecovery(
status: MemberWorkSyncStatus
): MemberWorkSyncTargetedRecoveryDecision {
if (
status.state !== 'needs_sync' ||
status.shadow?.wouldNudge !== true ||
status.agenda.items.length === 0 ||
isReviewPickupAgenda(status)
) {
return { active: false };
}
const target = resolveTargetedRecoveryCapability(status);
return target ? { active: true, ...target } : { active: false };
}

View file

@ -2,11 +2,13 @@ export * from './MemberWorkSyncAudit';
export * from './MemberWorkSyncDiagnosticsReader';
export * from './MemberWorkSyncMetricsReader';
export * from './MemberWorkSyncNudgeActivationPolicy';
export * from './MemberWorkSyncNudgeAgendaPredicates';
export * from './MemberWorkSyncNudgeDispatcher';
export * from './MemberWorkSyncNudgeOutboxPlanner';
export * from './MemberWorkSyncPendingReportIntentReplayer';
export * from './MemberWorkSyncReconciler';
export * from './MemberWorkSyncReporter';
export * from './MemberWorkSyncTargetedRecoveryPolicy';
export type * from './ports';
export * from './RuntimeTurnSettledIngestor';
export type * from './RuntimeTurnSettledPorts';

View file

@ -94,7 +94,11 @@ export type MemberWorkSyncAuditEventName =
| 'member_busy'
| 'team_inactive'
| 'index_repaired'
| 'legacy_fallback_used';
| 'legacy_fallback_used'
| 'proof_missing_recovery_scheduled'
| 'proof_missing_recovery_coalesced'
| 'proof_missing_recovery_suppressed'
| 'proof_missing_recovery_conflict';
export interface MemberWorkSyncAuditEvent {
timestamp: string;
@ -161,6 +165,18 @@ export interface MemberWorkSyncOutboxStorePort {
memberName: string;
reviewRequestEventIds: string[];
}): Promise<string[]>;
findRecentRecoveryByIntent?(input: {
teamName: string;
memberName: string;
intentKey: string;
sinceIso: string;
}): Promise<{
id: string;
status: MemberWorkSyncOutboxItem['status'];
deliveredMessageId?: string;
payloadHash: string;
updatedAt: string;
} | null>;
}
export interface MemberWorkSyncInboxNudgePort {
@ -189,10 +205,24 @@ export interface MemberWorkSyncBusySignalPort {
memberName: string;
nowIso: string;
workSyncIntent?: MemberWorkSyncOutboxItem['payload']['workSyncIntent'];
workSyncIntentKey?: MemberWorkSyncOutboxItem['payload']['workSyncIntentKey'];
taskRefs?: MemberWorkSyncOutboxItem['payload']['taskRefs'];
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
}
export interface MemberWorkSyncProofMissingRecoveryGuardPort {
shouldDispatch(input: {
teamName: string;
memberName: string;
intentKey: string;
originalMessageId: string;
taskIds: string[];
nowIso: string;
}): Promise<
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
>;
}
export interface MemberWorkSyncNudgeDeliveryWakePort {
schedule(input: {
teamName: string;
@ -264,6 +294,7 @@ export interface MemberWorkSyncUseCaseDeps {
inboxNudge?: MemberWorkSyncInboxNudgePort;
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
busySignal?: MemberWorkSyncBusySignalPort;
proofMissingRecoveryGuard?: MemberWorkSyncProofMissingRecoveryGuardPort;
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;

View file

@ -88,6 +88,24 @@ function buildAgendaPreview(status: MemberWorkSyncStatus): string {
.join('; ');
}
function hasLeadClarificationItem(status: MemberWorkSyncStatus): boolean {
return status.agenda.items.some(
(item) => item.kind === 'clarification' && item.evidence.needsClarification === 'lead'
);
}
function buildProofMissingRecoveryText(status: MemberWorkSyncStatus): string[] {
const recovery = status.shadow?.recovery;
if (recovery?.kind !== 'proof_missing') {
return [];
}
return [
`This also repairs OpenCode delivery proof for original messageId "${recovery.originalMessageId}".`,
'If you already completed the work, do not duplicate it; instead create the missing visible reply or task progress proof for the current agenda.',
];
}
function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload {
const taskRefs = buildTaskRefs(status);
const preview = buildAgendaPreview(status);
@ -133,6 +151,7 @@ export function buildMemberWorkSyncNudgePayload(
.map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`)
.join('; ');
const taskIds = status.agenda.items.map((item) => item.taskId).filter(Boolean);
const hasLeadClarification = hasLeadClarificationItem(status);
return {
from: 'system',
@ -141,9 +160,13 @@ export function buildMemberWorkSyncNudgePayload(
source: 'member-work-sync',
actionMode: 'do',
workSyncIntent: 'agenda_sync',
...(status.shadow?.recovery?.intentKey
? { workSyncIntentKey: status.shadow.recovery.intentKey }
: {}),
taskRefs,
text: [
'Work sync check: you have current actionable work assigned.',
...buildProofMissingRecoveryText(status),
preview ? `Current agenda: ${preview}.` : '',
`Required sync action: call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then call member_work_sync_report with the same teamName/memberName and the returned agendaFingerprint and reportToken.`,
taskIds.length
@ -151,6 +174,9 @@ export function buildMemberWorkSyncNudgePayload(
: '',
`Do not use provider names, runtime names, or team names as memberName; use exactly "${status.memberName}".`,
'If you are still working, report state "still_working"; if you are blocked, report state "blocked" and record the blocker on the task.',
hasLeadClarification
? 'If a lead clarification was already escalated to the user, update the task board first with task_set_clarification value "user"; do not rely on a message alone.'
: '',
'Continue concrete task work, report a real blocker with task tools, or sync your current fingerprint before going idle.',
'Do not reply only with acknowledgement.',
]
@ -181,8 +207,7 @@ export function buildMemberWorkSyncOutboxEnsureInput(input: {
}
const payload = buildMemberWorkSyncNudgePayload(status);
const intentKey =
payload.workSyncIntent === 'review_pickup' ? payload.workSyncIntentKey : undefined;
const intentKey = payload.workSyncIntentKey;
return {
id: buildMemberWorkSyncNudgeId({
teamName: status.teamName,

View file

@ -11,7 +11,11 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
if (existing.some((message) => message.messageId === input.messageId)) {
const existingMessage = existing.find((message) => message.messageId === input.messageId);
if (existingMessage) {
if (existingMessage.workSyncPayloadHash !== input.payloadHash) {
return { inserted: false, messageId: input.messageId, conflict: true };
}
return { inserted: false, messageId: input.messageId };
}
@ -30,6 +34,7 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
workSyncIntent: input.payload.workSyncIntent,
workSyncIntentKey: input.payload.workSyncIntentKey,
workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds,
workSyncPayloadHash: input.payloadHash,
});
return {

View file

@ -51,6 +51,7 @@ import type {
MemberWorkSyncBusySignalPort,
MemberWorkSyncLoggerPort,
MemberWorkSyncNudgeDeliveryWakePort,
MemberWorkSyncProofMissingRecoveryGuardPort,
MemberWorkSyncReviewPickupDeliveryPort,
MemberWorkSyncReviewPickupEscalationPort,
} from '../../core/application';
@ -62,6 +63,7 @@ import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamChangeEvent } from '@shared/types';
const STALE_STATUS_MAX_AGE_MS = 2 * 60_000;
const PROOF_MISSING_RECOVERY_RECENT_WINDOW_MS = 10 * 60_000;
function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] {
const diagnostics: string[] = [];
@ -103,6 +105,9 @@ export interface MemberWorkSyncFeatureFacade {
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
scheduleProofMissingRecovery(
request: MemberWorkSyncProofMissingRecoveryScheduleRequest
): Promise<MemberWorkSyncProofMissingRecoveryScheduleResult>;
noteTeamChange(event: TeamChangeEvent): void;
enqueueStartupScan(teamNames: string[]): Promise<void>;
replayPendingReports(teamNames: string[]): Promise<MemberWorkSyncPendingReportReplaySummary>;
@ -118,6 +123,45 @@ export interface MemberWorkSyncFeatureFacade {
dispose(): Promise<void>;
}
export interface MemberWorkSyncProofMissingRecoveryScheduleRequest {
teamName: string;
memberName: string;
originalMessageId: string;
taskRefs?: { taskId: string; displayId?: string; teamName?: string }[];
reason?: string;
}
export interface MemberWorkSyncProofMissingRecoveryScheduleResult {
scheduled: boolean;
reason: 'scheduled' | 'coalesced_recent' | 'invalid';
intentKey?: string;
existingOutboxId?: string;
}
function buildProofMissingRecoveryIntentKey(originalMessageId: string): string {
return `proof-missing:${originalMessageId}`;
}
function normalizeRecoveryTaskRefs(
taskRefs: MemberWorkSyncProofMissingRecoveryScheduleRequest['taskRefs']
): { taskId: string; displayId?: string; teamName?: string }[] {
const seen = new Set<string>();
const normalized: { taskId: string; displayId?: string; teamName?: string }[] = [];
for (const taskRef of taskRefs ?? []) {
const taskId = taskRef.taskId.trim();
if (!taskId || seen.has(taskId)) {
continue;
}
seen.add(taskId);
normalized.push({
taskId,
...(taskRef.displayId?.trim() ? { displayId: taskRef.displayId.trim() } : {}),
...(taskRef.teamName?.trim() ? { teamName: taskRef.teamName.trim() } : {}),
});
}
return normalized.sort((left, right) => left.taskId.localeCompare(right.taskId));
}
export function createMemberWorkSyncFeature(deps: {
teamsBasePath: string;
configReader: TeamConfigReader;
@ -125,10 +169,12 @@ export function createMemberWorkSyncFeature(deps: {
kanbanManager: TeamKanbanManager;
membersMetaStore: TeamMembersMetaStore;
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
canDispatchNudges?: (teamName: string) => Promise<boolean> | boolean;
listLifecycleActiveTeamNames?: () => Promise<string[]>;
queueQuietWindowMs?: number;
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
extraBusySignals?: MemberWorkSyncBusySignalPort[];
proofMissingRecoveryGuard?: MemberWorkSyncProofMissingRecoveryGuardPort;
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
@ -194,6 +240,9 @@ export function createMemberWorkSyncFeature(deps: {
inboxNudge,
watchdogCooldown,
busySignal,
...(deps.proofMissingRecoveryGuard
? { proofMissingRecoveryGuard: deps.proofMissingRecoveryGuard }
: {}),
...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}),
...(deps.reviewPickupDelivery ? { reviewPickupDelivery: deps.reviewPickupDelivery } : {}),
...(deps.reviewPickupEscalation ? { reviewPickupEscalation: deps.reviewPickupEscalation } : {}),
@ -208,13 +257,51 @@ export function createMemberWorkSyncFeature(deps: {
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
const nudgeDispatcher = new MemberWorkSyncNudgeDispatcher(useCaseDeps);
const emptyNudgeDispatchSummary = (): MemberWorkSyncNudgeDispatchSummary => ({
claimed: 0,
delivered: 0,
superseded: 0,
retryable: 0,
terminal: 0,
});
const filterNudgeDispatchReadyTeamNames = async (teamNames: string[]): Promise<string[]> => {
const uniqueTeamNames = [...new Set(teamNames.map((name) => name.trim()).filter(Boolean))];
if (!deps.canDispatchNudges) {
return uniqueTeamNames;
}
const readiness = await Promise.all(
uniqueTeamNames.map(async (teamName) => {
try {
return { teamName, ready: await deps.canDispatchNudges!(teamName) };
} catch (error) {
deps.logger?.warn('member work sync nudge dispatch readiness check failed', {
teamName,
error: String(error),
});
return { teamName, ready: false };
}
})
);
return readiness.filter((item) => item.ready).map((item) => item.teamName);
};
const dispatchNudgesForReadyTeams = async (
teamNames: string[],
claimedBy: string
): Promise<MemberWorkSyncNudgeDispatchSummary> => {
const readyTeamNames = await filterNudgeDispatchReadyTeamNames(teamNames);
if (readyTeamNames.length === 0) {
return emptyNudgeDispatchSummary();
}
return nudgeDispatcher.dispatchDue({
teamNames: readyTeamNames,
claimedBy,
});
};
const queue = new MemberWorkSyncEventQueue({
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
await reconciler.execute(request, context);
await nudgeDispatcher.dispatchDue({
teamNames: [request.teamName],
claimedBy: `member-work-sync:${process.pid}`,
});
await dispatchNudgesForReadyTeams([request.teamName], `member-work-sync:${process.pid}`);
},
isTeamActive: deps.isTeamActive ?? (() => true),
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
@ -264,10 +351,7 @@ export function createMemberWorkSyncFeature(deps: {
? new MemberWorkSyncNudgeDispatchScheduler({
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
dispatchDue: (teamNames) =>
nudgeDispatcher.dispatchDue({
teamNames,
claimedBy: `member-work-sync:${process.pid}:scheduled`,
}),
dispatchNudgesForReadyTeams(teamNames, `member-work-sync:${process.pid}:scheduled`),
logger: deps.logger,
})
: null;
@ -293,11 +377,97 @@ export function createMemberWorkSyncFeature(deps: {
};
};
const scheduleProofMissingRecovery = async (
request: MemberWorkSyncProofMissingRecoveryScheduleRequest
): Promise<MemberWorkSyncProofMissingRecoveryScheduleResult> => {
const teamName = request.teamName.trim();
const memberName = request.memberName.trim();
const originalMessageId = request.originalMessageId.trim();
if (!teamName || !memberName || !originalMessageId) {
return { scheduled: false, reason: 'invalid' };
}
const taskRefs = normalizeRecoveryTaskRefs(request.taskRefs);
if (taskRefs.length === 0) {
await auditJournal.append({
timestamp: clock.now().toISOString(),
teamName,
memberName,
event: 'proof_missing_recovery_suppressed',
source: 'proof_missing_recovery_scheduler',
reason: 'missing_task_refs',
metadata: {
originalMessageId,
},
});
return { scheduled: false, reason: 'invalid' };
}
const intentKey = buildProofMissingRecoveryIntentKey(originalMessageId);
const sinceIso = new Date(
clock.now().getTime() - PROOF_MISSING_RECOVERY_RECENT_WINDOW_MS
).toISOString();
const existing = await store.findRecentRecoveryByIntent?.({
teamName,
memberName,
intentKey,
sinceIso,
});
if (existing) {
await auditJournal.append({
timestamp: clock.now().toISOString(),
teamName,
memberName,
event: 'proof_missing_recovery_coalesced',
source: 'proof_missing_recovery_scheduler',
reason: existing.status,
metadata: {
intentKey,
originalMessageId,
existingOutboxId: existing.id,
},
});
return {
scheduled: false,
reason: 'coalesced_recent',
intentKey,
existingOutboxId: existing.id,
};
}
await auditJournal.append({
timestamp: clock.now().toISOString(),
teamName,
memberName,
event: 'proof_missing_recovery_scheduled',
source: 'proof_missing_recovery_scheduler',
reason: request.reason?.trim() || 'protocol_proof_missing',
taskRefs,
metadata: {
intentKey,
originalMessageId,
},
});
queue.enqueue({
teamName,
memberName,
triggerReason: 'proof_missing_recovery',
recovery: {
kind: 'proof_missing',
intentKey,
originalMessageId,
taskIds: taskRefs.map((taskRef) => taskRef.taskId),
},
});
return { scheduled: true, reason: 'scheduled', intentKey };
};
return {
getStatus: readStatusWithStaleRefresh,
refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }),
getMetrics: (request) => metricsReader.execute(request),
report: (request) => reporter.execute(request),
scheduleProofMissingRecovery,
noteTeamChange: (event) => {
toolActivityBusySignal.noteTeamChange(event);
router.noteTeamChange(event);
@ -322,10 +492,7 @@ export function createMemberWorkSyncFeature(deps: {
);
},
dispatchDueNudges: (teamNames) =>
nudgeDispatcher.dispatchDue({
teamNames,
claimedBy: `member-work-sync:${process.pid}`,
}),
dispatchNudgesForReadyTeams(teamNames, `member-work-sync:${process.pid}`),
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
runtimeTurnSettledSpool.buildHookSettings({ provider }),
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>

View file

@ -980,6 +980,46 @@ export class JsonMemberWorkSyncStore
return [...delivered].sort();
}
async findRecentRecoveryByIntent(input: {
teamName: string;
memberName: string;
intentKey: string;
sinceIso: string;
}): Promise<{
id: string;
status: MemberWorkSyncOutboxItem['status'];
deliveredMessageId?: string;
payloadHash: string;
updatedAt: string;
} | null> {
const intentKey = input.intentKey.trim();
if (!intentKey) {
return null;
}
const memberOutbox = await this.readMemberOutboxFile(input.teamName, input.memberName);
const matches = Object.values(memberOutbox.items)
.filter(
(item) =>
item.payload.workSyncIntentKey === intentKey &&
item.updatedAt >= input.sinceIso &&
item.status !== 'failed_terminal'
)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
const latest = matches[0];
if (!latest) {
return null;
}
return {
id: latest.id,
status: latest.status,
...(latest.deliveredMessageId ? { deliveredMessageId: latest.deliveredMessageId } : {}),
payloadHash: latest.payloadHash,
updatedAt: latest.updatedAt,
};
}
private async readLegacyStatusFile(teamName: string): Promise<LegacyStatusFile> {
return readJsonFile(
this.paths.getLegacyStatusPath(teamName),

View file

@ -14,6 +14,7 @@ export type MemberWorkSyncTriggerReason =
| 'tool_finished'
| 'runtime_activity'
| 'turn_settled'
| 'proof_missing_recovery'
| 'manual_refresh';
export interface MemberWorkSyncQueueDiagnostics {
@ -60,6 +61,7 @@ interface QueueItem {
maxRunAt: number;
triggerReasons: Set<MemberWorkSyncTriggerReason>;
triggerReasonCounts: Map<MemberWorkSyncTriggerReason, number>;
recovery?: MemberWorkSyncReconcileContext['recovery'];
}
interface RunningItem {
@ -68,6 +70,7 @@ interface RunningItem {
startedAt: number;
rerunRequested: boolean;
triggerReasons: Set<MemberWorkSyncTriggerReason>;
recovery?: MemberWorkSyncReconcileContext['recovery'];
}
interface TriggerTimingPolicy {
@ -151,6 +154,7 @@ export class MemberWorkSyncEventQueue {
memberName: string;
triggerReason: MemberWorkSyncTriggerReason;
runAfterMs?: number;
recovery?: MemberWorkSyncReconcileContext['recovery'];
}): void {
if (this.stopped) {
return;
@ -171,6 +175,9 @@ export class MemberWorkSyncEventQueue {
if (running) {
running.rerunRequested = true;
running.triggerReasons.add(input.triggerReason);
if (input.recovery) {
running.recovery = input.recovery;
}
this.counters.coalesced += 1;
this.appendAudit({
teamName,
@ -185,6 +192,9 @@ export class MemberWorkSyncEventQueue {
const existing = this.items.get(key);
if (existing) {
existing.triggerReasons.add(input.triggerReason);
if (input.recovery) {
existing.recovery = input.recovery;
}
existing.lastQueuedAt = now;
existing.maxRunAt = Math.max(
existing.maxRunAt,
@ -220,6 +230,7 @@ export class MemberWorkSyncEventQueue {
maxRunAt: now + timing.maxCoalesceWaitMs,
triggerReasons: new Set([input.triggerReason]),
triggerReasonCounts: new Map([[input.triggerReason, 1]]),
...(input.recovery ? { recovery: input.recovery } : {}),
});
this.counters.enqueued += 1;
this.appendAudit({
@ -351,6 +362,7 @@ export class MemberWorkSyncEventQueue {
startedAt: this.now(),
rerunRequested: false,
triggerReasons: new Set(item.triggerReasons),
...(item.recovery ? { recovery: item.recovery } : {}),
};
this.running.set(key, running);
@ -377,6 +389,7 @@ export class MemberWorkSyncEventQueue {
private enqueueFollowUp(item: QueueItem, running: RunningItem): void {
const reasons = [...running.triggerReasons].sort();
const recovery = running.recovery ?? item.recovery;
const primaryReason =
reasons.find((reason) => reason === 'manual_refresh') ??
reasons.find((reason) => reason === 'turn_settled' || reason === 'tool_finished') ??
@ -387,6 +400,7 @@ export class MemberWorkSyncEventQueue {
memberName: item.memberName,
triggerReason: primaryReason,
runAfterMs: Math.min(this.resolveTimingPolicy(primaryReason).runAfterMs, 5_000),
...(recovery ? { recovery } : {}),
});
const queued = this.items.get(keyOf(item.teamName, item.memberName));
if (!queued) {
@ -413,11 +427,13 @@ export class MemberWorkSyncEventQueue {
return;
}
const recovery = running.recovery ?? item.recovery;
await this.deps.reconcile(
{ teamName: item.teamName, memberName: item.memberName },
{
reconciledBy: 'queue',
triggerReasons: [...running.triggerReasons].sort(),
...(recovery ? { recovery } : {}),
}
);
this.counters.reconciled += 1;
@ -461,6 +477,8 @@ function defaultRunAfterMs(reason: MemberWorkSyncTriggerReason): number {
switch (reason) {
case 'manual_refresh':
return 0;
case 'proof_missing_recovery':
return 5_000;
case 'turn_settled':
case 'tool_finished':
return 5_000;
@ -479,6 +497,8 @@ function defaultMaxCoalesceWaitMs(reason: MemberWorkSyncTriggerReason): number {
switch (reason) {
case 'manual_refresh':
return 0;
case 'proof_missing_recovery':
return 30_000;
case 'turn_settled':
case 'tool_finished':
return 30_000;

Some files were not shown because too many files have changed in this diff Show more