fix(ci): stabilize ci and release workflows

This commit is contained in:
777genius 2026-05-14 22:10:30 +03:00
parent 6e8f938da2
commit 9c0b8beb7c
86 changed files with 3663 additions and 344 deletions

View file

@ -50,7 +50,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
@ -59,7 +61,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 +69,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 +80,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 +91,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 +109,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 +120,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

@ -51,7 +51,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
@ -60,7 +62,7 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile --ignore-scripts
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:

View file

@ -0,0 +1 @@
4968c54bb28f62ce55220de3437fa6d610729736

View file

@ -0,0 +1 @@
0.0.32

Binary file not shown.

View file

@ -48,15 +48,24 @@ const rootGuide: DefaultTheme.SidebarItem[] = [
items: [
{ text: "Runtime setup", link: "/guide/runtime-setup" },
{ text: "Agent workflow", link: "/guide/agent-workflow" },
{ text: "MCP integration", link: "/guide/mcp-integration" },
{ text: "Team brief examples", link: "/guide/team-brief-examples" },
{ text: "Git and worktree strategy", link: "/guide/git-worktree-strategy" },
{ text: "Code review", link: "/guide/code-review" },
{ 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" }
]
@ -77,15 +86,24 @@ const ruGuide: DefaultTheme.SidebarItem[] = [
items: [
{ text: "Настройка рантайма", link: "/ru/guide/runtime-setup" },
{ text: "Работа агентов", link: "/ru/guide/agent-workflow" },
{ text: "MCP integration", link: "/ru/guide/mcp-integration" },
{ 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/code-review" },
{ 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 +112,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 +128,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

@ -86,6 +86,8 @@ Prioritize these areas when reviewing:
- **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 provider support is in development. See [Providers and runtimes](/reference/providers-runtimes) for current provider status.
:::
## Write a good team brief

View file

@ -0,0 +1,100 @@
---
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)

View file

@ -34,7 +34,7 @@ To use agent runtimes, you need access to at least one provider:
| 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 provider support is in development. See [Providers and runtimes](/reference/providers-runtimes) for 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 the 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

@ -15,6 +15,10 @@ Download the latest release for your platform from the <a href="https://github.c
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 provider support is in development. See [Providers and runtimes](/reference/providers-runtimes) for current provider status.
:::
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.

View file

@ -0,0 +1,112 @@
---
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 the 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 the VitePress 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.
```
## 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

View file

@ -59,11 +59,10 @@ Agent Teams is a free desktop app for orchestrating AI agent teams. You are not
## 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

@ -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 block formats wrapped with `<opencode_agent_info>` or `[AGENT_BLOCK]` markers — these are legacy patterns and should be migrated to `info_for_agent` when encountered.
## 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?

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

@ -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 version: **v1.2.0** (2026-03-31)
## 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,67 @@
---
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) |
| Релизы / 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
- [Contributor architecture](/ru/reference/contributor-architecture)
- [Настройка рантайма](/ru/guide/runtime-setup)
- [MCP integration](/ru/guide/mcp-integration)
- [Диагностика](/ru/guide/troubleshooting)

View file

@ -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 в разработке. Текущий статус провайдеров смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
:::
## Хороший team brief

View file

@ -0,0 +1,98 @@
---
title: Git and Worktree Strategy - Agent Teams Docs
description: Как выбирать main worktree, feature branches или OpenCode worktree isolation для parallel agent work.
---
# Git and Worktree Strategy
Git даёт Agent Teams самый сильный review path: narrow diffs, branch visibility, task-scoped changes и более безопасную parallel work.
## Choose a strategy
| 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.
## When to enable worktree isolation
Включайте для 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.
:::
## Branch hygiene
Перед 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
```
## Review flow
Для 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.
## Conflict policy
| 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 |
## 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.
```
Этот prompt работает, потому что называет allowed area, sensitive boundaries и completion evidence.
## Related guides
- [Создание команды](/ru/guide/create-team)
- [Код-ревью](/ru/guide/code-review)
- [Team brief examples](/ru/guide/team-brief-examples)

View file

@ -35,7 +35,7 @@ Agent Teams распространяется как desktop-приложение
| OpenCode | API key для поддерживаемого бэкенда (например, OpenRouter) |
::: info
Поддержка провайдера Gemini в разработке. Вы можете подготовить доступ сейчас, но он не появится в редакторе команды, пока не будет готов.
Поддержка провайдера Gemini в разработке. Текущий статус всех провайдеров смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
:::
Для запуска из исходников также нужны:

View file

@ -0,0 +1,101 @@
---
title: MCP Integration - Agent Teams Docs
description: Как использовать MCP в Agent Teams для board operations, координации teammates и внешних tool servers.
---
# MCP Integration
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 the 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
- [Runtime setup](/ru/guide/runtime-setup)
- [Team brief examples](/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 в разработке. Текущий статус провайдеров смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes).
:::
Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup).

View file

@ -0,0 +1,112 @@
---
title: Team Brief Examples - Agent Teams Docs
description: Практические шаблоны team brief для small fixes, docs work, implementation tasks, review и risky areas.
---
# Team Brief Examples
Хороший 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 the 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 the VitePress 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.
```
## 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 integration](/ru/guide/mcp-integration)
- [Git and worktree strategy](/ru/guide/git-worktree-strategy)

View file

@ -31,6 +31,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

View file

@ -59,11 +59,10 @@ Agent Teams - бесплатное desktop-приложение для орке
## Справочник
Используйте справочник, когда нужны точные термины, поведение провайдеров или границы приватности.
Используйте справочник, когда нужны точные термины, поведение провайдеров, contributor architecture или границы приватности.
<DocsCardGrid type="reference" />
## Превью продукта
<ZoomImage src="/screenshots/1.jpg" alt="Канбан-доска Agent Teams" caption="Статусы задач, активность агентов и review workflow видны в одном рабочем пространстве." />

View file

@ -51,7 +51,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`. В старых документах могут встречаться block formats с маркерами `<opencode_agent_info>` или `[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

@ -47,7 +47,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>/`, когда она доступна.
## Что может выйти с моей машины?

View file

@ -28,13 +28,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

@ -82,6 +82,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)
## Как публикуются релизы
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

@ -38,6 +38,7 @@
"dist:mac:x64": "electron-builder --mac --x64",
"dist:win": "electron-builder --win",
"dist:linux": "electron-builder --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",
@ -147,10 +148,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

@ -0,0 +1 @@
4968c54bb28f62ce55220de3437fa6d610729736

View file

@ -0,0 +1 @@
0.0.32

Binary file not shown.

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

@ -108,6 +108,25 @@ 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,
@ -123,13 +142,10 @@ function resolveEmptyText(
if (hasOnlyCodexUnsupportedCoverage) {
return 'Unsupported provider';
}
const hasOpenCodeRuntimeWarning = preview?.warnings.some(
(warning) =>
warning.code === 'opencode_runtime_timeout' ||
warning.code === 'opencode_runtime_unavailable' ||
warning.code === 'opencode_ambiguous_lane'
);
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeRuntimeWarning) {
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';
@ -568,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
@ -594,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

@ -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

@ -12,6 +12,11 @@ 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 { MemberLogPreviewItem, MemberLogStreamWarning } from '../../../../contracts';
import type {
@ -32,9 +37,21 @@ const HIDDEN_PREVIEW_BLOCK_TAGS = [
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>;
@ -73,6 +90,7 @@ class FileOpenCodePromptDeliveryLedgerPreviewReader implements OpenCodePromptDel
}
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);
@ -217,34 +235,7 @@ function ledgerStatusText(record: OpenCodePromptDeliveryLedgerRecord): string {
}
}
function ledgerErrorTitle(record: OpenCodePromptDeliveryLedgerRecord): string {
const reason = record.lastReason?.toLowerCase() ?? '';
if (reason.includes('visible_reply')) {
return 'Visible reply missing';
}
switch (record.responseState) {
case 'empty_assistant_turn':
return 'Empty assistant turn';
case 'permission_blocked':
return 'Permission blocked';
case 'prompt_delivered_no_assistant_message':
return 'No assistant reply';
case 'responded_non_visible_tool':
return 'Visible reply missing';
case 'session_stale':
return 'OpenCode session stale';
case 'tool_error':
return 'Tool error';
case 'session_error':
return 'OpenCode session error';
case 'reconcile_failed':
return 'OpenCode reconcile failed';
default:
return 'OpenCode delivery failed';
}
}
function ledgerRecordIsError(record: OpenCodePromptDeliveryLedgerRecord): boolean {
function ledgerRecordHasDeliveryIssue(record: OpenCodePromptDeliveryLedgerRecord): boolean {
return record.status === 'failed_terminal' || ERROR_RESPONSE_STATES.has(record.responseState);
}
@ -252,15 +243,31 @@ function firstNonEmptyText(values: readonly (string | null | undefined)[]): stri
return values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim() ?? '';
}
function humanizeShortReason(value: string): string {
return /^[a-z0-9_]+$/i.test(value) ? value.replace(/_/g, ' ') : value;
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
): MemberLogPreviewItem | null {
): LedgerPreviewCandidate {
const timestamp = ledgerRecordTimestampIso(record);
const warning = buildLedgerPreviewWarning(record);
const sourceBase = {
provider: 'opencode_runtime' as const,
timestamp,
@ -269,51 +276,35 @@ function buildLedgerPreviewItem(
laneId: input.laneId,
};
if (ledgerRecordIsError(record)) {
const preview = sanitizeLedgerPreviewText(
humanizeShortReason(
firstNonEmptyText([
record.lastReason,
record.diagnostics[0],
record.observedAssistantPreview,
ledgerStatusText(record),
])
),
input.textLimit
);
return {
...sourceBase,
id: `opencode-ledger:${record.id}:error`,
kind: 'tool_result',
title: ledgerErrorTitle(record),
preview,
tone: 'error',
};
}
if (record.observedAssistantPreview?.trim()) {
return {
...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',
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 {
...sourceBase,
id: `opencode-ledger:${record.id}:tools`,
kind: 'tool_use',
title: 'Tool activity',
preview: formatLedgerToolNames(record.observedToolCallNames, input.textLimit),
tone: 'neutral',
item: {
...sourceBase,
id: `opencode-ledger:${record.id}:tools`,
kind: 'tool_use',
title: 'Tool activity',
preview: formatLedgerToolNames(record.observedToolCallNames, input.textLimit),
tone: 'neutral',
},
warning,
};
}
@ -322,32 +313,94 @@ function buildLedgerPreviewItem(
record.responseState === 'responded_plain_text'
) {
return {
...sourceBase,
id: `opencode-ledger:${record.id}:reply`,
kind: 'text',
title: 'OpenCode reply',
preview: sanitizeLedgerPreviewText(ledgerStatusText(record), input.textLimit),
tone: 'success',
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
? {
...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',
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,
}
: null;
: { 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 {
@ -361,7 +414,8 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
constructor(
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver,
private readonly ledgerReader: OpenCodePromptDeliveryLedgerPreviewReader = DEFAULT_LEDGER_PREVIEW_READER
private readonly ledgerReader: OpenCodePromptDeliveryLedgerPreviewReader = DEFAULT_LEDGER_PREVIEW_READER,
private readonly visibleActivityReader: OpenCodeMemberVisibleActivityReader = DEFAULT_VISIBLE_ACTIVITY_READER
) {}
async loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult> {
@ -418,11 +472,70 @@ 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;
return {
...ledgerResult,
warnings: dedupeWarnings([...ledgerResult.warnings, ...visibleActivityResult.warnings]),
};
}
return this.buildTranscriptResult(input, ledgerResult.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(
@ -440,20 +553,28 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
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 candidates = records.map((record) => buildLedgerPreviewItem(record, input));
const previewItems = candidates
.map((candidate) => candidate.item)
.filter((item): item is MemberLogPreviewItem => Boolean(item));
const items = candidates.slice(0, input.maxItems);
const overflowCount = Math.max(
0,
Math.max(candidates.length, orderedRecords.length) - items.length
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 : 'opencode_delivery_ledger_empty',
reason:
items.length > 0
? undefined
: warnings.length > 0
? 'opencode_delivery_delayed'
: 'opencode_delivery_ledger_empty',
items,
warnings: [],
warnings,
truncated: overflowCount > 0,
overflowCount,
};

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

@ -17,10 +17,12 @@ import { CodexNativeMemberTracePreviewSource } from '../CodexNativeMemberTracePr
import { CodexNativeMemberTraceStreamSource } from '../CodexNativeMemberTraceStreamSource';
import { OpenCodeMemberRuntimePreviewSource } from '../OpenCodeMemberRuntimePreviewSource';
import { OpenCodeMemberRuntimeStreamSource } from '../OpenCodeMemberRuntimeStreamSource';
import { OpenCodeMemberVisibleActivityReader } from '../OpenCodeMemberVisibleActivityReader';
import type { MemberLogPreviewSourceInput } from '../../../../../core/application/ports/MemberLogPreviewSource';
import type { MemberLogStreamSourceInput } from '../../../../../core/application/ports/MemberLogStreamSource';
import type { EnhancedChunk, ParsedMessage } from '@main/types';
import type { InboxMessage } from '@shared/types';
function parsedMessage(uuid: string, timestamp: string): ParsedMessage {
return {
@ -133,6 +135,41 @@ async function writeOpenCodePromptLedger(input: {
return ledgerPath;
}
async function writeTeamLeadInbox(input: {
claudeRoot: string;
teamName: string;
messages: InboxMessage[];
}): Promise<string> {
const inboxPath = path.join(
input.claudeRoot,
'teams',
input.teamName,
'inboxes',
'team-lead.json'
);
await mkdir(path.dirname(inboxPath), { recursive: true });
await writeFile(inboxPath, `${JSON.stringify(input.messages, null, 2)}\n`);
return inboxPath;
}
function inboxMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
return {
from: overrides.from ?? 'alice',
to: overrides.to ?? 'team-lead',
text: overrides.text ?? '#abc12345 done. Implemented visible team activity.',
timestamp: overrides.timestamp ?? '2026-04-04T00:00:00.000Z',
read: overrides.read ?? false,
taskRefs: overrides.taskRefs ?? [
{ taskId: 'task-1', displayId: 'abc12345', teamName: 'alpha-team' },
],
summary: overrides.summary ?? '#abc12345 done - visible activity',
messageId: overrides.messageId ?? 'visible-message-1',
source: overrides.source ?? 'runtime_delivery',
messageKind: overrides.messageKind,
...overrides,
};
}
function openCodeLedgerRecord(
overrides: Partial<OpenCodePromptDeliveryLedgerRecord> = {}
): OpenCodePromptDeliveryLedgerRecord {
@ -431,6 +468,110 @@ describe('OpenCodeMemberRuntimeStreamSource', () => {
],
});
});
it('falls back to visible OpenCode team activity when runtime transcript is empty', async () => {
const claudeRoot = await createTempClaudeRoot();
await writeTeamLeadInbox({
claudeRoot,
teamName: 'alpha-team',
messages: [
inboxMessage({
from: 'bob',
messageId: 'other-member',
text: 'Wrong member should not appear.',
}),
inboxMessage({
messageId: 'visible-message-1',
text: '#abc12345 done. <info_for_agent>hidden</info_for_agent> Verified docs.',
summary: '#abc12345 completed - docs verified',
timestamp: '2026-04-04T00:03:00.000Z',
}),
],
});
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'opencode-session',
logProjection: { messages: [] },
});
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [
fakeChunk(messages[0]?.uuid ?? 'chunk'),
]);
const source = new OpenCodeMemberRuntimeStreamSource(
{ getOpenCodeTranscript } as never,
{ buildBundleChunks } as never,
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
);
const result = await source.load(sourceInput({ memberName: 'alice' }));
expect(result.status).toBe('included');
expect(result.segments[0]?.source).toMatchObject({
provider: 'opencode_runtime',
label: 'OpenCode visible activity',
messageCount: 1,
});
expect(buildBundleChunks).toHaveBeenCalledWith([
expect.objectContaining({
uuid: expect.stringMatching(/^opencode-visible:/),
content: expect.stringContaining('#abc12345 completed - docs verified'),
}),
]);
expect(JSON.stringify(buildBundleChunks.mock.calls[0]?.[0])).not.toContain('info_for_agent');
});
it('uses visible OpenCode team activity when the runtime bridge is unavailable', async () => {
const claudeRoot = await createTempClaudeRoot();
await writeTeamLeadInbox({
claudeRoot,
teamName: 'alpha-team',
messages: [
inboxMessage({
from: 'jack',
messageId: 'jack-visible-message',
summary: '#e54d70b9 completed - release notes page added',
text: 'Task #e54d70b9 completed: added release notes page.',
timestamp: '2026-04-04T00:04:00.000Z',
}),
],
});
const source = new OpenCodeMemberRuntimeStreamSource(
{ getOpenCodeTranscript: vi.fn() } as never,
{ buildBundleChunks: vi.fn(() => [fakeChunk('visible-activity-chunk')]) } as never,
{ resolve: vi.fn().mockResolvedValue(null) }
);
const result = await source.load(sourceInput({ memberName: 'jack' }));
expect(result.status).toBe('included');
expect(result.warnings.map((warning) => warning.code)).toEqual([
'opencode_runtime_unavailable',
]);
expect(result.segments[0]?.source.label).toBe('OpenCode visible activity');
});
});
describe('OpenCodeMemberVisibleActivityReader', () => {
it('reuses one team inbox read across member lookups and respects force refresh', async () => {
const claudeRoot = await createTempClaudeRoot();
const getMessages = vi.fn(async () => [
inboxMessage({ from: 'alice', messageId: 'alice-message' }),
inboxMessage({ from: 'bob', messageId: 'bob-message' }),
]);
const reader = new OpenCodeMemberVisibleActivityReader({ getMessages });
const alice = await reader.list({ teamName: 'alpha-team', memberName: 'alice' });
const bob = await reader.list({ teamName: 'alpha-team', memberName: 'bob' });
const aliceForced = await reader.list({
teamName: 'alpha-team',
memberName: 'alice',
forceRefresh: true,
});
expect(claudeRoot).toContain('member-log-source-');
expect(alice.map((entry) => entry.message.messageId)).toEqual(['alice-message']);
expect(bob.map((entry) => entry.message.messageId)).toEqual(['bob-message']);
expect(aliceForced.map((entry) => entry.message.messageId)).toEqual(['alice-message']);
expect(getMessages).toHaveBeenCalledTimes(2);
});
});
describe('OpenCodeMemberRuntimePreviewSource', () => {
@ -524,7 +665,134 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
});
});
it('renders terminal OpenCode delivery errors as error-toned previews', async () => {
it('merges visible team activity with ledger previews before using runtime transcript', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
laneId,
responseState: 'responded_non_visible_tool',
observedAssistantPreview: null,
observedToolCallNames: ['task_get', 'glob', 'bash'],
updatedAt: '2026-04-04T00:02:00.000Z',
}),
],
});
await writeTeamLeadInbox({
claudeRoot,
teamName: 'alpha-team',
messages: [
inboxMessage({
messageId: 'visible-message-newer',
summary: '#abc12345 completed - visible reply',
text: '#abc12345 done. <system-reminder>hidden</system-reminder> Full result posted.',
timestamp: '2026-04-04T00:03:00.000Z',
}),
],
});
const getOpenCodeTranscript = vi.fn();
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn(),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items.map((item) => item.title)).toEqual(['Task completed', 'Tool activity']);
expect(result.items[0]?.preview).toContain('#abc12345 completed - visible reply');
expect(result.items[0]?.preview).not.toContain('system-reminder');
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('uses visible team activity instead of delayed empty state for warning-only ledger records', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
laneId,
status: 'failed_terminal',
responseState: 'session_stale',
observedAssistantPreview: null,
observedToolCallNames: [],
failedAt: '2026-04-04T00:03:00.000Z',
updatedAt: '2026-04-04T00:03:00.000Z',
}),
],
});
await writeTeamLeadInbox({
claudeRoot,
teamName: 'alpha-team',
messages: [
inboxMessage({
messageId: 'visible-message-after-stale-session',
summary: '#abc12345 done - visible fallback',
text: '#abc12345 done. Runtime session was stale but the team message is visible.',
timestamp: '2026-04-04T00:04:00.000Z',
}),
],
});
const getOpenCodeTranscript = vi.fn();
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn(),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
title: 'Task completed',
preview: expect.stringContaining('#abc12345 done - visible fallback'),
});
expect(result.warnings.map((warning) => warning.code)).toEqual(['opencode_delivery_delayed']);
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('keeps assistant evidence from terminal ledger records and reports delayed delivery', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
laneId,
status: 'failed_terminal',
responseState: 'responded_plain_text',
observedAssistantPreview: 'I completed the requested update.',
failedAt: '2026-04-04T00:01:00.000Z',
updatedAt: '2026-04-04T00:01:00.000Z',
}),
],
});
const getOpenCodeTranscript = vi.fn();
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn(),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'OpenCode reply',
preview: 'I completed the requested update.',
tone: 'neutral',
});
expect(result.warnings.map((warning) => warning.code)).toEqual(['opencode_delivery_delayed']);
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('turns terminal ledger failures without evidence into delayed warnings and falls back', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
@ -544,20 +812,136 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
}),
],
});
const source = new OpenCodeMemberRuntimePreviewSource(
{ getOpenCodeTranscript: vi.fn() } as never,
{ resolve: vi.fn() }
);
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'opencode-session',
logProjection: {
messages: [
{
uuid: 'opencode-transcript-1',
parentUuid: null,
type: 'assistant',
timestamp: '2026-04-04T00:02:00.000Z',
role: 'assistant',
content: 'Transcript recovered after delayed delivery.',
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'opencode-session',
},
],
},
});
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Tool error',
preview: 'tool failed with stderr output',
tone: 'error',
kind: 'text',
title: 'Assistant',
preview: 'Transcript recovered after delayed delivery.',
});
expect(result.warnings).toEqual([
{
code: 'opencode_delivery_delayed',
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
},
]);
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
});
it('preserves delayed ledger warnings when transcript fallback times out', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
laneId,
status: 'failed_terminal',
responseState: 'reconcile_failed',
observedAssistantPreview: null,
observedToolCallNames: [],
lastReason:
'opencode_message_delivery_exception: Bridge server runtime manifest high watermark is stale',
failedAt: '2026-04-04T00:01:00.000Z',
updatedAt: '2026-04-04T00:01:00.000Z',
}),
],
});
const getOpenCodeTranscript = vi.fn().mockRejectedValue(
Object.assign(new Error(`Command failed: runtime transcript --lane ${laneId}`), {
killed: true,
signal: 'SIGTERM',
})
);
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('skipped');
expect(result.items).toEqual([]);
expect(result.warnings.map((warning) => warning.code)).toEqual([
'opencode_delivery_delayed',
'opencode_runtime_timeout',
]);
});
it('keeps delayed ledger warnings when transcript is empty', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
id: 'opencode-prompt:session-error',
laneId,
status: 'accepted',
responseState: 'session_error',
observedAssistantPreview: null,
observedToolCallNames: [],
lastReason: 'Key limit exceeded',
}),
openCodeLedgerRecord({
id: 'opencode-prompt:empty-turn',
laneId,
status: 'failed_terminal',
responseState: 'empty_assistant_turn',
observedAssistantPreview: null,
observedToolCallNames: [],
lastReason: 'empty_assistant_turn',
failedAt: '2026-04-04T00:01:00.000Z',
updatedAt: '2026-04-04T00:01:00.000Z',
}),
],
});
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'opencode-session',
logProjection: { messages: [] },
});
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('skipped');
expect(result.items).toEqual([]);
expect(result.warnings).toEqual([
{
code: 'opencode_delivery_delayed',
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
},
]);
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
});
it('renders the real relay-works bob ledger shape as tool activity without runtime transcript', async () => {
@ -631,7 +1015,7 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('renders the real relay-works jack visible-reply failure as a readable error', async () => {
it('renders the real relay-works jack visible-reply failure as activity with a warning', async () => {
const claudeRoot = await createTempClaudeRoot();
const teamName = 'relay-works';
const memberName = 'jack';
@ -694,13 +1078,19 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Visible reply missing',
preview: 'visible reply destination not found yet',
tone: 'error',
kind: 'tool_use',
title: 'Tool activity',
preview: 'task_get, message_send',
tone: 'neutral',
sessionId: 'relay-jack-session',
laneId,
});
expect(result.warnings).toEqual([
{
code: 'opencode_delivery_delayed',
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
},
]);
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
@ -737,6 +1127,51 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
]);
});
it('does not count warning-only ledger records as preview overflow', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
...Array.from({ length: 5 }, (_, index) =>
openCodeLedgerRecord({
id: `opencode-prompt:warning-only-${index}`,
laneId,
status: 'failed_terminal',
responseState: 'reconcile_failed',
observedAssistantPreview: null,
observedToolCallNames: [],
lastReason:
'opencode_message_delivery_exception: Bridge server runtime manifest high watermark is stale',
failedAt: `2026-04-04T00:00:0${index}.000Z`,
updatedAt: `2026-04-04T00:00:0${index}.000Z`,
})
),
openCodeLedgerRecord({
id: 'opencode-prompt:visible',
laneId,
observedAssistantPreview: 'Visible event',
updatedAt: '2026-04-04T00:01:00.000Z',
}),
],
});
const getOpenCodeTranscript = vi.fn();
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn(),
});
const result = await source.loadPreview(previewInput({ laneId, maxItems: 3 }));
expect(result.status).toBe('included');
expect(result.items.map((item) => item.preview)).toEqual(['Visible event']);
expect(result.truncated).toBe(false);
expect(result.overflowCount).toBe(0);
expect(result.warnings.map((warning) => warning.code)).toEqual(['opencode_delivery_delayed']);
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('falls back to OpenCode transcript when the delivery ledger has no renderable records', async () => {
await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';

View file

@ -1,11 +1,11 @@
export type ClaudePreflightCommandCapabilities = {
export interface ClaudePreflightCommandCapabilities {
bare: boolean;
strictMcpConfig: boolean;
mcpConfig: boolean;
settingSources: boolean;
inlineSettings: boolean;
tools: boolean;
};
}
export type ClaudePreflightCommandResult =
| { ok: true; args: string[]; omittedFlags: string[] }
@ -29,7 +29,7 @@ export function buildClaudeWorkspaceTrustPreflightArgs(input: {
...(input.capabilities ?? {}),
};
const requiredProtectedFlags: Array<keyof ClaudePreflightCommandCapabilities> = [
const requiredProtectedFlags: (keyof ClaudePreflightCommandCapabilities)[] = [
'strictMcpConfig',
'mcpConfig',
'settingSources',

View file

@ -2,17 +2,17 @@ import { buildClaudeWorkspaceTrustPreflightArgs } from './ClaudePreflightCommand
import { runPtyDialogEngine } from './PtyDialogEngine';
import { detectClaudeStartupState, normalizeTerminalText } from './StartupDialogRules';
import type { WorkspaceTrustDiagnosticStrategyResult, WorkspaceTrustWorkspace } from '../domain';
import type {
ProviderStateProbe,
PtyProcessPort,
TerminalSnapshot,
TempEmptyMcpConfigStore,
TerminalSnapshot,
} from './ports';
import type { WorkspaceTrustDiagnosticStrategyResult, WorkspaceTrustWorkspace } from '../domain';
const WORKSPACE_TRUST_RAW_TAIL_LIMIT = 4096;
export type ClaudePtyWorkspaceTrustStrategyInput = {
export interface ClaudePtyWorkspaceTrustStrategyInput {
claudePath: string;
workspaces: WorkspaceTrustWorkspace[];
env: Record<string, string | undefined>;
@ -22,7 +22,7 @@ export type ClaudePtyWorkspaceTrustStrategyInput = {
isCancelled(): boolean;
timeoutMs?: number;
pollIntervalMs?: number;
};
}
function toPtyEnv(env: Record<string, string | undefined>): Record<string, string> {
const output: Record<string, string> = {};

View file

@ -30,7 +30,7 @@ export type PtyDialogEngineResult =
lastSnapshot?: TerminalSnapshot;
};
export type PtyDialogEngineInput = {
export interface PtyDialogEngineInput {
session: PtySessionPort;
detect(snapshotText: string): StartupReadinessState;
isCancelled(): boolean;
@ -43,7 +43,7 @@ export type PtyDialogEngineInput = {
actions: PtyKeyAction[];
snapshot: TerminalSnapshot;
}) => Promise<{ action: 'continue' } | { action: 'stop'; reason: string }>;
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));

View file

@ -47,7 +47,7 @@ function hasClaudeWorkspaceTrustPrompt(lower: string, compact: string): boolean
/trust this folder/,
]) ||
(/(quicksafetycheck|projectyoucreated|workspacetrust)/.test(compact) &&
/trustthisfolder/.test(compact));
compact.includes('trustthisfolder'));
if (knownPrompt) {
return true;
}

View file

@ -8,23 +8,24 @@ import {
type WorkspaceTrustWorkspace,
} from '../domain';
import type { ClaudePtyWorkspaceTrustStrategy } from './ClaudePtyWorkspaceTrustStrategy';
import {
WorkspaceTrustLockCancelledError,
WorkspaceTrustLockRegistry,
WorkspaceTrustLockTimeoutError,
} from './WorkspaceTrustLocks';
export type WorkspaceTrustArgsOnlyPlanRequest = {
import type { ClaudePtyWorkspaceTrustStrategy } from './ClaudePtyWorkspaceTrustStrategy';
export interface WorkspaceTrustArgsOnlyPlanRequest {
providers: WorkspaceTrustProvider[];
workspaces: WorkspaceTrustWorkspace[];
targetSurfaces?: WorkspaceTrustLaunchArgTargetSurface[];
featureFlags: WorkspaceTrustFeatureFlags;
};
}
export type WorkspaceTrustArgsOnlyPlanResult = {
export interface WorkspaceTrustArgsOnlyPlanResult {
launchArgPatches: WorkspaceTrustLaunchArgPatch[];
};
}
export type WorkspaceTrustFullPlanRequest = WorkspaceTrustArgsOnlyPlanRequest;
@ -32,13 +33,13 @@ export type WorkspaceTrustFullPlanResult = WorkspaceTrustArgsOnlyPlanResult & {
workspaces: WorkspaceTrustWorkspace[];
};
export type WorkspaceTrustExecutionPlan = {
export interface WorkspaceTrustExecutionPlan {
claudePath: string;
workspaces: WorkspaceTrustWorkspace[];
env: Record<string, string | undefined>;
featureFlags: WorkspaceTrustFeatureFlags;
isCancelled(): boolean;
};
}
export type WorkspaceTrustExecutionResult = Awaited<
ReturnType<ClaudePtyWorkspaceTrustStrategy['execute']>

View file

@ -12,11 +12,11 @@ export class WorkspaceTrustLockCancelledError extends Error {
}
}
export type WorkspaceTrustLockOptions = {
export interface WorkspaceTrustLockOptions {
timeoutMs: number;
pollIntervalMs?: number;
isCancelled(): boolean;
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));

View file

@ -1,7 +1,7 @@
export * from './ClaudePreflightCommand';
export * from './ClaudePtyWorkspaceTrustStrategy';
export type * from './ports';
export * from './PtyDialogEngine';
export * from './StartupDialogRules';
export * from './WorkspaceTrustCoordinator';
export * from './WorkspaceTrustLocks';
export type * from './ports';

View file

@ -1,17 +1,17 @@
import type { WorkspaceTrustWorkspace } from '../domain';
export type TerminalSnapshot = {
export interface TerminalSnapshot {
text: string;
capturedAtMs: number;
};
}
export type PtyKeyAction = {
export interface PtyKeyAction {
id: string;
label: string;
sequence: string;
};
}
export type PtySpawnInput = {
export interface PtySpawnInput {
command: string;
args: string[];
cwd: string;
@ -19,7 +19,7 @@ export type PtySpawnInput = {
cols?: number;
rows?: number;
name?: string;
};
}
export type PtySpawnResult =
| { ok: true; session: PtySessionPort }
@ -44,10 +44,10 @@ export interface ProviderStateProbe {
readTrustState(workspace: WorkspaceTrustWorkspace): Promise<ProviderTrustState>;
}
export type TempEmptyMcpConfigHandle = {
export interface TempEmptyMcpConfigHandle {
path: string;
cleanup(): Promise<void>;
};
}
export interface TempEmptyMcpConfigStore {
create(): Promise<TempEmptyMcpConfigHandle>;

View file

@ -9,13 +9,13 @@ export const CODEX_WORKSPACE_TRUST_CONFIG_OVERRIDES_KEY = 'config_overrides';
const CODEX_WORKSPACE_TRUST_OVERRIDE_PATTERN =
/^projects\."(?:[^"\\\x00-\x1F]|\\["\\bfnrt]|\\u[0-9a-fA-F]{4}|\\U[0-9a-fA-F]{8})+"\.trust_level="trusted"$/;
export type CodexWorkspaceTrustSettingsObject = {
export interface CodexWorkspaceTrustSettingsObject {
codex: {
agent_teams_workspace_trust: {
config_overrides: string[];
};
};
};
}
function toHex(value: number, width: number): string {
return value.toString(16).padStart(width, '0').toUpperCase();

View file

@ -17,12 +17,12 @@ export type WorkspaceTrustLaunchArgPatchSkipReason =
| 'empty_patch'
| 'malformed_patch_settings';
export type WorkspaceTrustLaunchArgPatchApplication = {
export interface WorkspaceTrustLaunchArgPatchApplication {
args: string[];
appliedPatchIds: string[];
skippedPatches: Array<{ id: string; reason: WorkspaceTrustLaunchArgPatchSkipReason }>;
skippedPatches: { id: string; reason: WorkspaceTrustLaunchArgPatchSkipReason }[];
addedWorkspaceTrustOverrideCount: number;
};
}
function parseJsonObject(value: string): Record<string, unknown> | null {
try {

View file

@ -1,15 +1,15 @@
import type {
WorkspaceTrustDiagnosticStrategyResult,
WorkspaceTrustDiagnosticsManifest,
WorkspaceTrustDiagnosticStrategyResult,
} from './WorkspaceTrustTypes';
export type WorkspaceTrustDiagnosticsBudgetLimits = {
export interface WorkspaceTrustDiagnosticsBudgetLimits {
maxStrategyResults: number;
maxWorkspaceIdsPerResult: number;
maxEvidencePerResult: number;
maxEvidenceLength: number;
maxRawTailLength: number;
};
}
export const DEFAULT_WORKSPACE_TRUST_DIAGNOSTICS_BUDGET: WorkspaceTrustDiagnosticsBudgetLimits = {
maxStrategyResults: 20,

View file

@ -8,9 +8,9 @@ import type {
export type WorkspaceTrustPathPlatform = 'posix' | 'win32';
export type WorkspaceTrustPathOptions = {
export interface WorkspaceTrustPathOptions {
platform?: WorkspaceTrustPathPlatform;
};
}
export type BuildWorkspaceTrustPathCandidatesInput = WorkspaceTrustPathOptions & {
cwd: string;

View file

@ -11,7 +11,7 @@ export type WorkspaceTrustNonPersistableReason =
| 'filesystem_root'
| 'unavailable';
export type WorkspaceTrustWorkspace = {
export interface WorkspaceTrustWorkspace {
id: string;
displayCwd: string;
cwd: string;
@ -23,15 +23,15 @@ export type WorkspaceTrustWorkspace = {
memberId?: string;
persistable: boolean;
nonPersistableReason?: WorkspaceTrustNonPersistableReason;
};
}
export type WorkspaceTrustFeatureFlags = {
export interface WorkspaceTrustFeatureFlags {
enabled: boolean;
claudePty: boolean;
codexArgs: boolean;
retry: boolean;
fileLock: boolean;
};
}
export type WorkspaceTrustLaunchArgTargetSurface =
| 'primary_provider_args'
@ -44,7 +44,7 @@ export type WorkspaceTrustLaunchArgDialect =
| 'claude-codex-runtime-settings'
| 'codex-direct-cli-config';
export type WorkspaceTrustLaunchArgPatch = {
export interface WorkspaceTrustLaunchArgPatch {
id: string;
owner: 'workspace-trust';
targetProvider: WorkspaceTrustProvider;
@ -54,11 +54,11 @@ export type WorkspaceTrustLaunchArgPatch = {
dedupeKey: string;
sourceWorkspaceIds: string[];
reason: string;
};
}
export type WorkspaceTrustExecutionStatus = 'ok' | 'soft_failed' | 'blocked' | 'cancelled';
export type WorkspaceTrustDiagnosticStrategyResult = {
export interface WorkspaceTrustDiagnosticStrategyResult {
id: string;
provider: WorkspaceTrustProvider;
status: WorkspaceTrustExecutionStatus | 'skipped';
@ -70,11 +70,11 @@ export type WorkspaceTrustDiagnosticStrategyResult = {
errorCode?: string;
errorMessage?: string;
rawTail?: string;
};
}
export type WorkspaceTrustDiagnosticsManifest = {
export interface WorkspaceTrustDiagnosticsManifest {
attempt: number;
featureFlags: WorkspaceTrustFeatureFlags;
strategyResults: WorkspaceTrustDiagnosticStrategyResult[];
omittedCounts?: Record<string, number>;
};
}

View file

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

View file

@ -1,7 +1,5 @@
import { createLogger } from '@shared/utils/logger';
import type { IPty } from 'node-pty';
import type * as NodePty from 'node-pty';
import type {
PtyKeyAction,
PtyProcessPort,
@ -10,6 +8,8 @@ import type {
PtySpawnResult,
TerminalSnapshot,
} from '../../../core/application';
import type { IPty } from 'node-pty';
import type * as NodePty from 'node-pty';
const logger = createLogger('WorkspaceTrustNodePtyProcessAdapter');
const MAX_TRANSCRIPT_CHARS = 64 * 1024;

View file

@ -1,6 +1,7 @@
export type * from '../core/application';
export {
ClaudePtyWorkspaceTrustStrategy,
buildClaudeWorkspaceTrustPreflightArgs,
ClaudePtyWorkspaceTrustStrategy,
runPtyDialogEngine,
} from '../core/application';
export {
@ -19,11 +20,10 @@ export {
normalizeWorkspaceTrustComparisonKey,
normalizeWorkspaceTrustConfigKey,
} from '../core/domain';
export type * from '../core/domain/WorkspaceTrustTypes';
export { FileClaudeStateProbe } from './adapters/output/ClaudeStateProbe';
export { NodePtyProcessAdapter } from './adapters/output/NodePtyProcessAdapter';
export { FileTempEmptyMcpConfigStore } from './adapters/output/TempEmptyMcpConfigStore';
export { createWorkspaceTrustCoordinator } from './composition/createWorkspaceTrustCoordinator';
export { resolveWorkspaceTrustFeatureFlags } from './infrastructure/WorkspaceTrustFeatureFlags';
export { buildWorkspaceTrustPreflightEnv } from './infrastructure/workspaceTrustPreflightEnv';
export type * from '../core/application';
export type * from '../core/domain/WorkspaceTrustTypes';

View file

@ -2588,10 +2588,13 @@ void app.whenReady().then(async () => {
// Sync Sentry telemetry opt-in flag from persisted config
syncTelemetryFlag(config.general.telemetryEnabled);
// Apply launch-at-login setting only in packaged builds.
// In dev, macOS may deny this (and Electron logs a noisy error to stderr).
// Also guard by platform: Electron only supports this on macOS/Windows.
if (app.isPackaged && (process.platform === 'darwin' || process.platform === 'win32')) {
// Apply launch-at-login only where Electron can persist it without noisy OS errors.
// Local packaged macOS smoke builds run outside /Applications and cannot set login items.
const canSyncLaunchAtLogin =
app.isPackaged &&
(process.platform === 'win32' ||
(process.platform === 'darwin' && app.isInApplicationsFolder()));
if (canSyncLaunchAtLogin) {
app.setLoginItemSettings({
openAtLogin: config.general.launchAtLogin,
});

View file

@ -33,10 +33,10 @@ function resolveRendererPath(): string | null {
candidates.unshift(
// Standalone: dist-standalone/index.cjs → ../out/renderer
join(__dirname, '../out/renderer'),
// Electron production (asar fallback): app.asar/out/renderer
join(__dirname, '../../out/renderer'),
// Electron production (asarUnpack): app.asar.unpacked/out/renderer (real filesystem)
join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked')
join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked'),
// Electron production fallback: app.asar/out/renderer
join(__dirname, '../../out/renderer')
);
}

View file

@ -556,8 +556,8 @@ import type {
TeamMember,
TeamProviderBackendId,
TeamProviderId,
TeamProvisioningPrepareIssue,
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareIssue,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamProvisioningState,
@ -1055,7 +1055,7 @@ function getRunRuntimeFailureLabel(run: ProvisioningRun): string {
}
if (providerIds.size === 1) {
return getProviderRuntimeFailureLabel([...providerIds][0]!);
return getProviderRuntimeFailureLabel([...providerIds][0]);
}
return getCliFlavorUiOptions(getConfiguredCliFlavor()).displayName;
@ -7773,6 +7773,50 @@ export class TeamProvisioningService {
return diagnostics.some((diagnostic) => isProbeTimeoutMessage(diagnostic));
}
private isOpenCodeRuntimeManifestWatermarkDeliveryFailure(
record: OpenCodePromptDeliveryLedgerRecord
): boolean {
return [record.lastReason, ...record.diagnostics].some(
(reason) =>
typeof reason === 'string' &&
reason.toLowerCase().includes('runtime manifest high watermark is stale')
);
}
private async requeueOpenCodeRuntimeManifestWatermarkDeliveryIfNeeded(input: {
ledger: OpenCodePromptDeliveryLedgerStore;
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
if (
input.ledgerRecord.status !== 'failed_terminal' ||
input.ledgerRecord.inboxReadCommittedAt ||
!this.isOpenCodeRuntimeManifestWatermarkDeliveryFailure(input.ledgerRecord)
) {
return input.ledgerRecord;
}
const scheduledAt = nowIso();
const requeued = await input.ledger.markNextAttemptScheduled({
id: input.ledgerRecord.id,
status: 'retry_scheduled',
nextAttemptAt: scheduledAt,
reason: 'opencode_prompt_delivery_requeued_after_runtime_manifest_high_watermark_fix',
scheduledAt,
});
logger.info(
'opencode_prompt_delivery_requeued_after_runtime_manifest_high_watermark_fix',
JSON.stringify({
teamName: requeued.teamName,
memberName: requeued.memberName,
laneId: requeued.laneId,
runId: requeued.runId,
inboxMessageId: requeued.inboxMessageId,
attempts: requeued.attempts,
})
);
return requeued;
}
private isOpenCodeDirectUserPromptDelivery(
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
): boolean {
@ -9606,7 +9650,7 @@ export class TeamProvisioningService {
}
if (recovered) {
runtimeRunId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId);
runtimeActive = true;
runtimeActive = await this.isOpenCodeRuntimeLaneIndexActive(teamName, laneIdentity.laneId);
}
}
if (
@ -9886,6 +9930,11 @@ export class TeamProvisioningService {
};
}
ledgerRecord = await this.requeueOpenCodeRuntimeManifestWatermarkDeliveryIfNeeded({
ledger,
ledgerRecord,
});
if (ledgerRecord.status === 'failed_terminal') {
this.logOpenCodePromptDeliveryEvent(
'opencode_prompt_delivery_terminal_failure',
@ -10893,6 +10942,21 @@ export class TeamProvisioningService {
});
}
private async tryRecoverOpenCodeRuntimeLaneForConfiguredMemberAndVerifyActive(input: {
teamName: string;
memberName: string;
laneId: string;
}): Promise<boolean> {
const recovered = await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery({
teamName: input.teamName,
memberName: input.memberName,
}).catch(() => false);
if (!recovered) {
return false;
}
return this.isOpenCodeRuntimeLaneIndexActive(input.teamName, input.laneId).catch(() => false);
}
private async tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog(
teamName: string,
options: { allowCommittedSessionRecoveryWithoutTeamRuntime?: boolean } = {}
@ -13258,9 +13322,10 @@ export class TeamProvisioningService {
() => null
);
if (laneIndex?.lanes[identity.laneId]?.state !== 'active') {
const recovered = await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery({
const recovered = await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberAndVerifyActive({
teamName: input.teamName,
memberName: identity.canonicalMemberName,
laneId: identity.laneId,
});
if (!recovered) {
return null;
@ -13391,7 +13456,14 @@ export class TeamProvisioningService {
if (!laneIndex) {
return { busy: true, reason: 'opencode_lane_index_unavailable', retryAfterIso };
}
if (laneIndex.lanes[identity.laneId]?.state !== 'active') {
if (
laneIndex.lanes[identity.laneId]?.state !== 'active' &&
!(await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberAndVerifyActive({
teamName: input.teamName,
memberName: identity.canonicalMemberName,
laneId: identity.laneId,
}).catch(() => false))
) {
return { busy: true, reason: 'opencode_no_active_lane', retryAfterIso };
}
@ -22486,7 +22558,7 @@ export class TeamProvisioningService {
.slice(0, 10);
for (const message of unread) {
const existingRecord = await promptLedger
let existingRecord = await promptLedger
.getByInboxMessage({
teamName,
memberName: memberIdentity.canonicalMemberName,
@ -22494,6 +22566,17 @@ export class TeamProvisioningService {
inboxMessageId: message.messageId,
})
.catch(() => null);
if (existingRecord?.status === 'failed_terminal') {
const requeuedRecord = await this.requeueOpenCodeRuntimeManifestWatermarkDeliveryIfNeeded(
{
ledger: promptLedger,
ledgerRecord: existingRecord,
}
);
if (requeuedRecord.status !== 'failed_terminal') {
existingRecord = requeuedRecord;
}
}
if (existingRecord?.status === 'failed_terminal') {
let recoveredRecord: OpenCodePromptDeliveryLedgerRecord | null = null;
let recoveredVisibleReply: OpenCodeVisibleReplyProof | null = null;
@ -22852,7 +22935,17 @@ export class TeamProvisioningService {
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), input.teamName).catch(
() => null
);
if (laneIndex?.lanes[identity.laneId]?.state !== 'active') {
if (!laneIndex) {
return bypassMessageIds;
}
const laneActive =
laneIndex.lanes[identity.laneId]?.state === 'active' ||
(await this.tryRecoverOpenCodeRuntimeLaneForConfiguredMemberAndVerifyActive({
teamName: input.teamName,
memberName: identity.canonicalMemberName,
laneId: identity.laneId,
}).catch(() => false));
if (!laneActive) {
return bypassMessageIds;
}

View file

@ -742,6 +742,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
capabilitySnapshotId: string | null;
manifest: RuntimeStoreManifestEvidence;
idempotencyKey: string;
enforceManifestHighWatermark?: boolean;
}): asserts input is {
result: OpenCodeBridgeSuccess<unknown>;
requestId: string;
@ -760,6 +761,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data);
if (
input.enforceManifestHighWatermark !== false &&
typeof resultManifestHighWatermark === 'number' &&
resultManifestHighWatermark < input.manifest.highWatermark
) {

View file

@ -14,13 +14,13 @@ import {
stableHash,
validateOpenCodeBridgeHandshake,
} from './OpenCodeBridgeCommandContract';
import { OpenCodeBridgeCommandLeaseError } from './OpenCodeBridgeCommandLedgerStore';
import type {
OpenCodeBridgeCommandLeaseStore,
OpenCodeBridgeCommandLease,
OpenCodeBridgeCommandLeaseStore,
OpenCodeBridgeCommandLedger,
} from './OpenCodeBridgeCommandLedgerStore';
import { OpenCodeBridgeCommandLeaseError } from './OpenCodeBridgeCommandLedgerStore';
const DEFAULT_COMMAND_LEASE_ACQUIRE_TIMEOUT_MS = 10_000;
const DEFAULT_COMMAND_LEASE_ACQUIRE_RETRY_DELAY_MS = 100;
@ -117,11 +117,17 @@ export class OpenCodeStateChangingBridgeCommandService {
}): Promise<OpenCodeBridgeResult<TData>> {
const normalizedLaneId = input.laneId ?? null;
const manifest = await this.manifestReader.read(input.teamName, normalizedLaneId);
const enforceManifestHighWatermark = commandRequiresRuntimeStoreManifestPrecondition(
input.command
);
const expectedManifestHighWatermark = enforceManifestHighWatermark
? manifest.highWatermark
: null;
const handshake = await this.handshakePort.handshake({
requiredCommand: input.command,
expectedRunId: input.runId,
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
expectedManifestHighWatermark: manifest.highWatermark,
expectedManifestHighWatermark,
cwd: input.cwd,
});
const handshakeValidation = validateOpenCodeBridgeHandshake({
@ -129,7 +135,7 @@ export class OpenCodeStateChangingBridgeCommandService {
expectedClient: this.expectedClientIdentity,
requiredCommand: input.command,
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
expectedManifestHighWatermark: manifest.highWatermark,
expectedManifestHighWatermark,
expectedRunId: input.runId,
requiresDeliveryAcceptanceContract: requiresOpenCodeDeliveryAcceptanceContract(
input.command,
@ -164,7 +170,7 @@ export class OpenCodeStateChangingBridgeCommandService {
expectedRunId: input.runId,
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
expectedBehaviorFingerprint: input.behaviorFingerprint,
expectedManifestHighWatermark: manifest.highWatermark,
expectedManifestHighWatermark,
commandLeaseId: lease.leaseId,
idempotencyKey,
});
@ -183,7 +189,7 @@ export class OpenCodeStateChangingBridgeCommandService {
runId: input.runId,
capabilitySnapshotId: input.capabilitySnapshotId,
behaviorFingerprint: input.behaviorFingerprint,
manifestHighWatermark: manifest.highWatermark,
manifestHighWatermark: expectedManifestHighWatermark,
body: input.body,
}),
});
@ -238,6 +244,7 @@ export class OpenCodeStateChangingBridgeCommandService {
capabilitySnapshotId: input.capabilitySnapshotId,
manifest,
idempotencyKey,
enforceManifestHighWatermark,
});
} catch (error) {
await this.ledger.markFailed({
@ -357,6 +364,12 @@ function requiresOpenCodeDeliveryAcceptanceContract(
return body.settlementMode === 'acceptance';
}
function commandRequiresRuntimeStoreManifestPrecondition(
command: OpenCodeBridgeCommandName
): boolean {
return command !== 'opencode.sendMessage';
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,10 +1,10 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver';
import { TeamTaskReader } from '../../TeamTaskReader';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper';

View file

@ -57,7 +57,7 @@ function parseTimestampMs(value: string | null | undefined): number {
return Number.isFinite(parsed) ? parsed : 0;
}
function minTimestampIso(values: Array<string | null | undefined>): string | undefined {
function minTimestampIso(values: (string | null | undefined)[]): string | undefined {
const times = values.map(parseTimestampMs).filter((value) => Number.isFinite(value) && value > 0);
if (times.length === 0) {
return undefined;
@ -65,7 +65,7 @@ function minTimestampIso(values: Array<string | null | undefined>): string | und
return new Date(Math.min(...times)).toISOString();
}
function maxTimestampIso(values: Array<string | null | undefined>): string | undefined {
function maxTimestampIso(values: (string | null | undefined)[]): string | undefined {
const times = values.map(parseTimestampMs).filter((value) => Number.isFinite(value) && value > 0);
if (times.length === 0) {
return undefined;
@ -192,7 +192,7 @@ async function mapWithConcurrency<TInput, TOutput>(
while (index < inputs.length) {
const currentIndex = index;
index += 1;
results[currentIndex] = await mapper(inputs[currentIndex] as TInput);
results[currentIndex] = await mapper(inputs[currentIndex]);
}
})
);

View file

@ -147,6 +147,7 @@ type ProvisioningDetailSummary =
| 'Selected model unavailable'
| 'Selected model verification timed out'
| 'Selected model check failed'
| 'Selected model ping not confirmed'
| 'Ready with notes'
| 'Needs attention';
@ -161,7 +162,8 @@ function isFormattedModelDetail(lower: string): boolean {
lower.includes(' - available for launch') ||
lower.includes(' - compatible, deep verification pending') ||
lower.includes(' - unavailable') ||
lower.includes(' - check failed')
lower.includes(' - check failed') ||
lower.includes(' - ping not confirmed')
);
}
@ -257,6 +259,9 @@ function summarizeDetail(
if (lower.includes(' - check failed -')) {
return 'Selected model check failed';
}
if (lower.includes(' - ping not confirmed')) {
return 'Selected model ping not confirmed';
}
if (status === 'notes') {
return 'Ready with notes';
@ -274,6 +279,7 @@ function getModelDetailSummary(details: string[]): string | null {
let unavailableCount = 0;
let timedOutCount = 0;
let checkFailedCount = 0;
let pingNotConfirmedCount = 0;
let checkingCount = 0;
for (const detail of details) {
@ -321,6 +327,10 @@ function getModelDetailSummary(details: string[]): string | null {
checkFailedCount += 1;
continue;
}
if (lower.includes(' - ping not confirmed')) {
pingNotConfirmedCount += 1;
continue;
}
if (lower.includes(' - checking...')) {
checkingCount += 1;
}
@ -336,6 +346,9 @@ function getModelDetailSummary(details: string[]): string | null {
if (timedOutCount > 0) {
parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
}
if (pingNotConfirmedCount > 0) {
parts.push(`${pingNotConfirmedCount} ping not confirmed`);
}
if (compatibilityPendingCount > 0) {
parts.push(`${compatibilityPendingCount} compatible, deep verification pending`);
}
@ -397,6 +410,9 @@ function getDetailTone(
if (summary === 'Selected model verification timed out') {
return 'neutral';
}
if (summary === 'Selected model ping not confirmed') {
return 'neutral';
}
if (
summary === 'Selected model unavailable' ||
summary === 'Selected model check failed' ||

View file

@ -45,6 +45,8 @@ export interface ProviderPrepareDiagnosticsResult {
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
}
type TeamProvisioningPrepareIssue = NonNullable<TeamProvisioningPrepareResult['issues']>[number];
export function buildReusableProviderPrepareModelResults(
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>
): Record<string, ProviderPrepareDiagnosticsModelResult> {
@ -235,18 +237,88 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined
);
}
function getBlockingProviderIssue(
providerId: TeamProviderId,
result: TeamProvisioningPrepareResult
): TeamProvisioningPrepareIssue | null {
return (
result.issues?.find(
(entry) =>
entry.scope === 'provider' &&
entry.severity === 'blocking' &&
(!entry.providerId || entry.providerId === providerId) &&
entry.message.trim().length > 0
) ?? null
);
}
function getBlockingProviderIssueMessage(
providerId: TeamProviderId,
result: TeamProvisioningPrepareResult
): string | null {
const issue = result.issues?.find(
(entry) =>
entry.scope === 'provider' &&
entry.severity === 'blocking' &&
(!entry.providerId || entry.providerId === providerId) &&
entry.message.trim().length > 0
return getBlockingProviderIssue(providerId, result)?.message.trim() ?? null;
}
function isAdvisoryOpenCodeDeepVerificationIssue(
issue: TeamProvisioningPrepareIssue | null,
reason: string | null | undefined
): boolean {
if (issue?.code?.trim().toLowerCase() !== 'unknown_error') {
return false;
}
const lower = [issue.message, reason]
.map((entry) => entry?.trim() ?? '')
.filter(Boolean)
.join('\n')
.toLowerCase();
if (!lower) {
return false;
}
const hasHardRuntimeMarker =
lower.includes('mcp_unavailable') ||
lower.includes('not_authenticated') ||
lower.includes('authentication') ||
lower.includes('credential') ||
lower.includes('api key') ||
lower.includes('/experimental/tool') ||
lower.includes('runtime store') ||
lower.includes('opencode cli');
if (hasHardRuntimeMarker) {
return false;
}
return (
lower.includes('unable to connect') ||
lower.includes('timed out') ||
lower.includes('timeout') ||
lower.includes('aborted') ||
lower.includes('econnreset') ||
lower.includes('econnrefused') ||
lower.includes('fetch failed') ||
lower.includes('socket hang up') ||
lower.includes('networkerror')
);
return issue?.message.trim() ?? null;
}
function buildOpenCodeAdvisoryDeepVerificationWarning(reason: string | null | undefined): string {
const normalizedReason =
normalizeModelReason(reason?.trim() ?? '') || 'Model ping was not confirmed';
return `OpenCode model ping was not confirmed. ${normalizedReason}`;
}
function createOpenCodeAdvisoryDeepVerificationModelResult(
providerId: TeamProviderId,
modelId: string
): ProviderPrepareDiagnosticsModelResult {
const line = `${getModelLabel(providerId, modelId)} - ping not confirmed`;
return {
status: 'notes',
line,
warningLine: line,
};
}
function getResultReason(modelId: string, result: TeamProvisioningPrepareResult): string | null {
@ -1036,32 +1108,54 @@ export async function runProviderPrepareDiagnostics({
compatibilityPassedModelIds,
batchedModelResult
);
const structuredProviderScopedFailure = getBlockingProviderIssueMessage(
const structuredProviderScopedIssue = getBlockingProviderIssue(
providerId,
batchedModelResult
);
const structuredProviderScopedFailure =
structuredProviderScopedIssue?.message.trim() ?? null;
let handledAdvisoryDeepFailure = false;
if (structuredProviderScopedFailure || providerScopedFailure) {
return {
status: 'failed',
details: [
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
],
warnings: [],
modelResultsById: {},
};
const failureReason =
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed';
if (
isAdvisoryOpenCodeDeepVerificationIssue(structuredProviderScopedIssue, failureReason)
) {
hasNotes = true;
runtimeDetailLines = [];
runtimeWarnings = uniquePrepareLines([
...runtimeWarnings,
buildOpenCodeAdvisoryDeepVerificationWarning(failureReason),
]);
for (const modelId of compatibilityPassedModelIds) {
recordTerminalModelResult(
modelId,
createOpenCodeAdvisoryDeepVerificationModelResult(providerId, modelId)
);
}
handledAdvisoryDeepFailure = true;
} else {
return {
status: 'failed',
details: [failureReason],
warnings: [],
modelResultsById: {},
};
}
}
if (
shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
!handledAdvisoryDeepFailure &&
(shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
result: batchedModelResult,
modelIds: compatibilityPassedModelIds,
modelScopedEntriesPresent: hasModelScopedEntries,
runtimeDetailLines,
runtimeWarnings,
}) ||
(!batchedModelResult.ready &&
!hasModelScopedEntries &&
(compatibilityPassedModelIds.length > 1 ||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))
(!batchedModelResult.ready &&
!hasModelScopedEntries &&
(compatibilityPassedModelIds.length > 1 ||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))))
) {
return {
status: 'failed',
@ -1073,21 +1167,27 @@ export async function runProviderPrepareDiagnostics({
modelResultsById: {},
};
}
if (!hasModelScopedEntries && compatibilityPassedModelIds.length === 1) {
if (
!handledAdvisoryDeepFailure &&
!hasModelScopedEntries &&
compatibilityPassedModelIds.length === 1
) {
runtimeDetailLines = [];
runtimeWarnings = [];
}
for (const modelId of compatibilityPassedModelIds) {
recordTerminalModelResult(
modelId,
resolveModelResultFromBatch(
providerId,
if (!handledAdvisoryDeepFailure) {
for (const modelId of compatibilityPassedModelIds) {
recordTerminalModelResult(
modelId,
batchedModelResult,
compatibilityPassedModelIds.length === 1
)
);
resolveModelResultFromBatch(
providerId,
modelId,
batchedModelResult,
compatibilityPassedModelIds.length === 1
)
);
}
}
} catch (error) {
hasNotes = true;

View file

@ -1438,7 +1438,7 @@ a[href],
.team-row-zebra-card,
.project-row-zebra-card {
--row-card-bg: color-mix(in srgb, var(--color-surface) 50%, var(--color-surface-raised) 50%);
--row-zebra-bg: color-mix(in srgb, var(--color-surface-raised) 96%, var(--color-text) 4%);
--row-zebra-bg: color-mix(in srgb, var(--color-surface-raised) 99%, var(--color-text) 1%);
--row-zebra-hover-bg: color-mix(in srgb, var(--row-zebra-bg) 97%, var(--color-text) 3%);
background-color: var(--row-card-bg);
}

View file

@ -125,6 +125,49 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
expect(bridge.calls).toHaveLength(1);
});
it('does not apply runtime-store high watermark preconditions to sendMessage delivery', async () => {
clientIdentity.bridgeProtocol.supportedCommands.push('opencode.sendMessage');
const server = peerIdentity('agent_teams_orchestrator', {
runtimeStoreManifestHighWatermark: 0,
});
server.bridgeProtocol.supportedCommands.push('opencode.sendMessage');
server.bridgeProtocol.opencodeDeliveryAcceptanceContractVersion =
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION;
handshakePort.nextHandshake = buildHandshakeWithAcceptedCommands(
{ client: clientIdentity, server },
['opencode.launchTeam', 'opencode.stopTeam', 'opencode.sendMessage']
);
bridge.resultFactory = ({ body, command, options }) =>
bridgeSuccess({
requestId: options.requestId,
command,
data: {
runId: 'run-1',
idempotencyKey: body.preconditions.idempotencyKey,
runtimeStoreManifestHighWatermark: 0,
},
});
const service = createService();
await expect(service.execute(buildSendInput('acceptance'))).resolves.toMatchObject({
ok: true,
});
expect(bridge.calls).toHaveLength(1);
expect(bridge.calls[0].body.preconditions).toMatchObject({
expectedManifestHighWatermark: null,
idempotencyKey: expect.stringMatching(
/^opencode:opencode\.sendMessage:team-a:secondary_opencode_bob:run-1:/
),
});
await expect(ledger.getByIdempotencyKey(bridge.calls[0].body.preconditions.idempotencyKey))
.resolves.toMatchObject({
requestId: 'cmd-1',
status: 'completed',
retryable: false,
});
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
});
it('adds preconditions, commits ledger, and releases lease on success', async () => {
bridge.resultFactory = ({ body, options }) =>
bridgeSuccess({

View file

@ -5501,6 +5501,39 @@ describe('TeamProvisioningService', () => {
);
});
it('does not deliver direct OpenCode messages when recovery leaves the lane inactive', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
runtimePromptMessageId: 'msg_prompt_direct_lane',
runtimePid: 456,
diagnostics: [],
}));
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
(svc as any).deleteSecondaryRuntimeRun('team-a', 'secondary:opencode:bob');
vi.spyOn(svc as any, 'tryRecoverOpenCodeRuntimeLaneBeforeDelivery').mockResolvedValue(false);
const committedRecoverySpy = vi
.spyOn(svc as any, 'tryRecoverOpenCodeRuntimeLaneFromCommittedSessionBeforeDelivery')
.mockResolvedValue(true);
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'hello bob',
messageId: 'msg-1',
})
).resolves.toMatchObject({
delivered: false,
reason: 'opencode_runtime_not_active',
});
expect(committedRecoverySpy).toHaveBeenCalled();
expect(sendMessageToMember).not.toHaveBeenCalled();
});
it('persists verified OpenCode bridge runtime pids so member cards can show memory', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({

View file

@ -2833,6 +2833,96 @@ Messages:
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]);
});
it('retries failed-terminal OpenCode rows caused by stale runtime manifest watermark', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
hoisted.files.set(
`/mock/teams/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack');
expect(identity.ok).toBe(true);
const staleRecord = {
id: 'ledger-terminal-stale-manifest',
teamName,
memberName: 'jack',
laneId: 'secondary:opencode:jack',
runId: 'run-1',
status: 'failed_terminal',
responseState: 'reconcile_failed',
attempts: 3,
maxAttempts: 3,
inboxMessageId: 'opencode-terminal-stale-manifest',
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
source: 'watcher',
lastReason:
'opencode_message_delivery_exception: Bridge server runtime manifest high watermark is stale',
diagnostics: [
'opencode_message_delivery_exception: Bridge server runtime manifest high watermark is stale',
],
};
const markNextAttemptScheduled = vi.fn(async (input: Record<string, unknown>) => ({
...staleRecord,
status: input.status,
nextAttemptAt: input.nextAttemptAt,
lastReason: input.reason,
}));
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
getByInboxMessage: vi.fn(async (input: { inboxMessageId: string }) =>
input.inboxMessageId === 'opencode-terminal-stale-manifest' ? staleRecord : null
),
markNextAttemptScheduled,
});
seedMemberInbox(teamName, 'jack', [
{
from: 'bob',
to: 'jack',
text: 'Old stale manifest row.',
timestamp: '2026-02-23T17:00:00.000Z',
read: false,
messageId: 'opencode-terminal-stale-manifest',
},
{
from: 'bob',
to: 'jack',
text: 'New row should wait behind the retried old row.',
timestamp: '2026-02-23T17:00:02.000Z',
read: false,
messageId: 'opencode-terminal-new',
},
]);
const deliverSpy = vi
.spyOn(service, 'deliverOpenCodeMemberMessage')
.mockResolvedValue({ delivered: true, diagnostics: [] });
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
expect(markNextAttemptScheduled).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ledger-terminal-stale-manifest',
status: 'retry_scheduled',
reason: 'opencode_prompt_delivery_requeued_after_runtime_manifest_high_watermark_fix',
})
);
expect(relay).toMatchObject({ relayed: 1, attempted: 1, delivered: 1, failed: 0 });
expect(deliverSpy).toHaveBeenCalledTimes(1);
expect(deliverSpy).toHaveBeenCalledWith(
teamName,
expect.objectContaining({ messageId: 'opencode-terminal-stale-manifest' })
);
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, false]);
});
it('fails OpenCode secondary rows with missing attachment payloads terminally without text-only delivery', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
@ -3653,11 +3743,23 @@ Messages:
canonicalMemberName: memberName,
laneId,
}));
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
version: 1,
updatedAt: '2026-02-23T17:30:00.000Z',
lanes: {},
});
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex')
.mockResolvedValueOnce({
version: 1,
updatedAt: '2026-02-23T17:30:00.000Z',
lanes: {},
})
.mockResolvedValue({
version: 1,
updatedAt: '2026-02-23T17:30:01.000Z',
lanes: {
[laneId]: {
laneId,
state: 'active',
updatedAt: '2026-02-23T17:30:01.000Z',
},
},
});
const recoverySpy = vi
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
.mockResolvedValue(true);
@ -3696,6 +3798,192 @@ Messages:
);
});
it('does not use an active OpenCode prompt ledger when recovery leaves the lane inactive', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const memberName = 'jack';
const laneId = 'secondary:opencode:jack';
const teamsBasePath = getTeamsBasePath();
hoisted.files.set(
`${teamsBasePath}/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: memberName, role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
hoisted.files.set(
`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`,
JSON.stringify([
{
from: 'team-lead',
to: memberName,
text: 'New task assigned to you.',
timestamp: '2026-02-23T17:31:00.000Z',
read: false,
messageId: 'foreground-message-1',
messageKind: 'default',
},
])
);
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
ok: true,
canonicalMemberName: memberName,
laneId,
}));
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
version: 1,
updatedAt: '2026-02-23T17:30:00.000Z',
lanes: {},
});
const recoverySpy = vi
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
.mockResolvedValue(true);
const wakeSpy = vi
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
.mockImplementation(() => undefined);
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
getActiveForMember: vi.fn(async () => ({
id: 'ledger-foreground-message-1',
teamName,
memberName,
laneId,
inboxMessageId: 'foreground-message-1',
messageKind: 'default',
status: 'pending',
responseState: 'not_observed',
nextAttemptAt: '2026-02-23T17:33:00.000Z',
})),
});
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName,
nowIso: '2026-02-23T17:31:10.000Z',
workSyncIntent: 'agenda_sync',
});
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName });
expect(busy).toMatchObject({
busy: true,
reason: 'opencode_foreground_inbox_unread',
activeMessageId: 'foreground-message-1',
});
expect(wakeSpy).toHaveBeenCalledWith({
teamName,
memberName,
messageId: 'foreground-message-1',
delayMs: 500,
});
});
it('recovers a missing OpenCode lane before treating work-sync delivery as unavailable', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const memberName = 'jack';
const laneId = 'secondary:opencode:jack';
const teamsBasePath = getTeamsBasePath();
hoisted.files.set(
`${teamsBasePath}/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: memberName, role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`, JSON.stringify([]));
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
ok: true,
canonicalMemberName: memberName,
laneId,
}));
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex')
.mockResolvedValueOnce({
version: 1,
updatedAt: '2026-02-23T17:30:00.000Z',
lanes: {},
})
.mockResolvedValue({
version: 1,
updatedAt: '2026-02-23T17:30:01.000Z',
lanes: {
[laneId]: {
laneId,
state: 'active',
updatedAt: '2026-02-23T17:30:01.000Z',
},
},
});
const recoverySpy = vi
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
.mockResolvedValue(true);
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
getActiveForMember: vi.fn(async () => null),
});
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName,
nowIso: '2026-02-23T17:31:10.000Z',
workSyncIntent: 'agenda_sync',
});
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName });
expect(busy).toEqual({ busy: false });
});
it('keeps OpenCode work-sync busy when recovery reports success but lane index is still inactive', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const memberName = 'jack';
const laneId = 'secondary:opencode:jack';
const teamsBasePath = getTeamsBasePath();
hoisted.files.set(
`${teamsBasePath}/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: memberName, role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`, JSON.stringify([]));
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
ok: true,
canonicalMemberName: memberName,
laneId,
}));
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
version: 1,
updatedAt: '2026-02-23T17:30:00.000Z',
lanes: {},
});
const recoverySpy = vi
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
.mockResolvedValue(true);
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName,
nowIso: '2026-02-23T17:31:10.000Z',
workSyncIntent: 'agenda_sync',
});
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName });
expect(busy).toMatchObject({
busy: true,
reason: 'opencode_no_active_lane',
});
});
it('does not let proof-missing recovery get blocked by its original unread message', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
@ -3843,6 +4131,128 @@ Messages:
expect(busy).toEqual({ busy: false });
});
it('allows OpenCode agenda-sync proof-missing bypass after recovering a missing lane index', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const laneId = 'secondary:opencode:jack';
const teamsBasePath = getTeamsBasePath();
const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' };
hoisted.files.set(
`${teamsBasePath}/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
hoisted.files.set(
`${teamsBasePath}/${teamName}/inboxes/jack.json`,
JSON.stringify([
{
from: 'team-lead',
to: 'jack',
text: 'Please continue task #task1234.',
timestamp: '2026-02-23T17:31:00.000Z',
read: false,
messageId: 'proof-missing-message-1',
messageKind: 'default',
taskRefs: [taskRef],
},
])
);
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
ok: true,
canonicalMemberName: 'jack',
laneId,
}));
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex')
.mockResolvedValueOnce({
version: 1,
updatedAt: '2026-02-23T17:30:00.000Z',
lanes: {},
})
.mockResolvedValue({
version: 1,
updatedAt: '2026-02-23T17:30:01.000Z',
lanes: {
[laneId]: {
laneId,
state: 'active',
updatedAt: '2026-02-23T17:30:01.000Z',
},
},
});
const recoverySpy = vi
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
.mockResolvedValue(true);
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
list: vi.fn(async () => [
buildOpenCodeProofMissingRecord({
teamName,
memberName: 'jack',
laneId,
inboxMessageId: 'proof-missing-message-1',
taskRefs: [taskRef],
}),
]),
getActiveForMember: vi.fn(async () => null),
});
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName: 'jack',
nowIso: '2026-02-23T17:32:00.000Z',
workSyncIntent: 'agenda_sync',
taskRefs: [taskRef],
});
expect(recoverySpy).toHaveBeenCalledWith({ teamName, memberName: 'jack' });
expect(busy).toEqual({ busy: false });
});
it('keeps OpenCode agenda-sync proof-missing bypass disabled when lane index is unreadable', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const laneId = 'secondary:opencode:jack';
const taskRef = { teamName, taskId: 'task-1234', displayId: 'task1234' };
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
ok: true,
canonicalMemberName: 'jack',
laneId,
}));
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockRejectedValue(
new Error('temporary read failure')
);
const recoverySpy = vi
.spyOn(service as any, 'tryRecoverOpenCodeRuntimeLaneForConfiguredMemberBeforeDelivery')
.mockResolvedValue(true);
const bypass = await (service as any).getOpenCodeAgendaSyncRecoveryBypassMessageIds({
teamName,
memberName: 'jack',
workSyncIntent: 'agenda_sync',
taskRefs: [taskRef],
foregroundMessages: [
{
from: 'team-lead',
to: 'jack',
text: 'Please continue task #task1234.',
timestamp: '2026-02-23T17:31:00.000Z',
read: false,
messageId: 'proof-missing-message-1',
messageKind: 'default',
taskRefs: [taskRef],
},
],
});
expect(recoverySpy).not.toHaveBeenCalled();
expect(bypass).toEqual(new Set());
});
it('allows OpenCode agenda-sync recovery for legacy proof-missing foreground ids', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';

View file

@ -165,6 +165,43 @@ describe('ProvisioningProviderStatusList', () => {
});
});
it('summarizes OpenCode advisory ping misses without failure wording', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ProvisioningProviderStatusList, {
checks: [
{
providerId: 'opencode',
status: 'notes',
backendSummary: 'OpenCode CLI',
details: ['big-pickle - ping not confirmed'],
},
],
})
);
await Promise.resolve();
});
expect(host.textContent).toContain(
'OpenCode (OpenCode CLI): Selected model checks - 1 ping not confirmed'
);
expect(host.textContent).not.toContain('model check failed');
expect(host.textContent).not.toContain('Needs attention');
const detailLines = Array.from(host.querySelectorAll('p'));
expect(detailLines[0]?.className).toContain('text-[var(--color-text-muted)]');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not count generic one-shot diagnostic timeouts as model timeouts', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -626,6 +626,121 @@ describe('runProviderPrepareDiagnostics', () => {
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
});
it('keeps transient OpenCode deep ping failures advisory after compatibility passed', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: 'compatibility' | 'deep'
) => Promise<TeamProvisioningPrepareResult>
>((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => {
if (modelVerificationMode === 'compatibility') {
expect(selectedModels).toEqual(['opencode/big-pickle']);
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch',
details: [
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
],
});
}
expect(modelVerificationMode).toBe('deep');
expect(selectedModels).toEqual(['opencode/big-pickle']);
return Promise.resolve({
ready: false,
message: 'Unable to connect. Is the computer able to access the url?',
details: ['Unable to connect. Is the computer able to access the url?'],
issues: [
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'unknown_error',
message: 'Unable to connect. Is the computer able to access the url?',
},
],
});
});
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'opencode',
selectedModelIds: ['opencode/big-pickle'],
prepareProvisioning,
});
expect(result.status).toBe('notes');
expect(result.details).toEqual(['big-pickle - ping not confirmed']);
expect(result.warnings).toEqual([
'OpenCode model ping was not confirmed. Unable to connect. Is the computer able to access the url?',
'big-pickle - ping not confirmed',
]);
expect(result.modelResultsById).toEqual({
'opencode/big-pickle': {
status: 'notes',
line: 'big-pickle - ping not confirmed',
warningLine: 'big-pickle - ping not confirmed',
},
});
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
});
it('keeps hard OpenCode deep provider issues blocking after compatibility passed', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: 'compatibility' | 'deep'
) => Promise<TeamProvisioningPrepareResult>
>((_cwd, _providerId, _providerIds, selectedModels, _limitContext, modelVerificationMode) => {
if (modelVerificationMode === 'compatibility') {
expect(selectedModels).toEqual(['opencode/big-pickle']);
return Promise.resolve({
ready: true,
message: 'CLI is ready to launch',
details: [
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
],
});
}
expect(modelVerificationMode).toBe('deep');
expect(selectedModels).toEqual(['opencode/big-pickle']);
return Promise.resolve({
ready: false,
message: 'OpenCode: mcp_unavailable',
issues: [
{
providerId: 'opencode',
scope: 'provider',
severity: 'blocking',
code: 'mcp_unavailable',
message: 'OpenCode: mcp_unavailable',
},
],
});
});
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'opencode',
selectedModelIds: ['opencode/big-pickle'],
prepareProvisioning,
});
expect(result.status).toBe('failed');
expect(result.details).toEqual(['OpenCode: mcp_unavailable']);
expect(result.modelResultsById).toEqual({});
expect(prepareProvisioning).toHaveBeenCalledTimes(2);
});
it('keeps OpenCode deep selected-model failures scoped to the selected model', async () => {
const prepareProvisioning = vi.fn<
(

View file

@ -873,6 +873,215 @@ describe('GraphMemberLogPreviewHud', () => {
});
});
it('shows delayed OpenCode delivery as a distinct empty state', async () => {
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'idle',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'alice',
{
memberName: 'alice',
items: [],
coverage: [
{
provider: 'opencode_runtime',
status: 'skipped',
reason: 'opencode_delivery_delayed',
},
],
warnings: [
{
code: 'opencode_delivery_delayed',
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
},
],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:00:00.000Z',
},
],
]);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('OpenCode logs delayed');
expect(host.textContent).not.toContain('Logs unavailable');
expect(host.textContent).not.toContain('No recent logs');
act(() => {
root.unmount();
});
});
it('shows a loader while refreshing a stale empty OpenCode runtime failure', async () => {
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'idle',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
mockedLoading = true;
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'alice',
{
memberName: 'alice',
items: [],
coverage: [
{
provider: 'opencode_runtime',
status: 'skipped',
reason: 'opencode_runtime_timeout',
},
],
warnings: [
{
code: 'opencode_runtime_timeout',
message: 'OpenCode runtime preview timed out.',
},
],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:00:00.000Z',
},
],
]);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('Loading logs');
expect(host.textContent).not.toContain('Logs unavailable');
expect(host.querySelector('button[aria-busy="true"]')).not.toBeNull();
act(() => {
root.unmount();
});
});
it('shows a loader while refreshing stale delayed OpenCode delivery', async () => {
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'idle',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
mockedLoading = true;
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'alice',
{
memberName: 'alice',
items: [],
coverage: [
{
provider: 'opencode_runtime',
status: 'skipped',
reason: 'opencode_delivery_delayed',
},
],
warnings: [
{
code: 'opencode_delivery_delayed',
message: 'OpenCode logs are delayed while message delivery is being confirmed.',
},
],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:00:00.000Z',
},
],
]);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('Loading logs');
expect(host.textContent).not.toContain('OpenCode logs delayed');
expect(host.querySelector('button[aria-busy="true"]')).not.toBeNull();
act(() => {
root.unmount();
});
});
it('shows loading only before a member preview has been loaded', async () => {
const quietNode: GraphNode = {
id: 'member:alpha-team:quiet-dev',