feat(docs): restructure VitePress IA, improve onboarding/troubleshooting docs

- Restructure sidebar: Start → Guide → Operations → Developers → Reference
- Fix EN/RU sidebar order (Installation before Quickstart)
- Expand troubleshooting with diagnostics commands and task-log triage
- Improve quickstart with prerequisites, pitfalls, and contributor links
- Expand installation docs with verification commands
- Add cyberpunk hero theme to landing page
- Add atomicFile utility with tests and stage-runtime script
- Harden team provisioning with better error handling and progress output
- Add cross-team communication, kanban, and workSync improvements
This commit is contained in:
777genius 2026-05-15 23:20:40 +03:00
parent f882970b6c
commit d018002c3e
66 changed files with 7395 additions and 845 deletions

View file

@ -166,10 +166,10 @@ jobs:
include:
- arch: arm64
runner: macos-14
dist_command: pnpm dist:mac:arm64
dist_command: pnpm pack:mac:arm64
- arch: x64
runner: macos-15-intel
dist_command: pnpm dist:mac:x64
dist_command: pnpm pack:mac:x64
runs-on: ${{ matrix.runner }}
steps:
@ -206,32 +206,18 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION"
- name: Resolve runtime asset name (macOS ${{ matrix.arch }})
if: startsWith(github.ref, 'refs/tags/v')
id: runtime-asset
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.arch }}" == "arm64" ]]; then
platform="darwin-arm64"
else
platform="darwin-x64"
fi
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name "$platform")" >> "$GITHUB_OUTPUT"
- name: Stage bundled runtime (macOS ${{ matrix.arch }})
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
rm -rf .runtime-download resources/runtime
mkdir -p .runtime-download resources/runtime
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
tar -xzf ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -C .runtime-download
cp -R .runtime-download/runtime/. resources/runtime/
node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}" --release-tag "$TAG"
else
node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}"
fi
- name: Build app (macOS ${{ matrix.arch }})
env:
@ -248,9 +234,7 @@ jobs:
test -f dist-electron/preload/index.js
test -f out/renderer/index.html
test -f mcp-server/dist/index.js
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
test -f resources/runtime/VERSION
fi
- name: Package (macOS ${{ matrix.arch }})
env:
@ -321,28 +305,18 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION"
- name: Resolve runtime asset name (Windows)
if: startsWith(github.ref, 'refs/tags/v')
id: runtime-asset
shell: bash
run: |
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name win32-x64)" >> "$GITHUB_OUTPUT"
- name: Stage bundled runtime (Windows)
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
if ($env:GITHUB_REF -like 'refs/tags/v*') {
$tag = $env:GITHUB_REF.Replace('refs/tags/', '')
Remove-Item .runtime-download -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item resources/runtime/* -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path .runtime-download | Out-Null
New-Item -ItemType Directory -Force -Path resources/runtime | Out-Null
gh release download $tag --repo $env:GITHUB_REPOSITORY --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
Expand-Archive -Path ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -DestinationPath .runtime-download/unpacked -Force
Copy-Item .runtime-download/unpacked/runtime/* resources/runtime -Recurse -Force
node ./scripts/stage-runtime.mjs --platform win32-x64 --release-tag $tag
} else {
node ./scripts/stage-runtime.mjs --platform win32-x64
}
- name: Build app (Windows)
env:
@ -360,14 +334,12 @@ jobs:
test -f dist-electron/preload/index.js
test -f out/renderer/index.html
test -f mcp-server/dist/index.js
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
test -f resources/runtime/VERSION
fi
- name: Package (Windows)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm dist:win --publish never
run: pnpm pack:win --publish never
- name: Validate packaged bundle (Windows)
shell: bash
@ -435,26 +407,18 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION"
- name: Resolve runtime asset name (Linux)
if: startsWith(github.ref, 'refs/tags/v')
id: runtime-asset
shell: bash
run: |
echo "asset_name=$(node ./scripts/runtime-lock.mjs asset-name linux-x64)" >> "$GITHUB_OUTPUT"
- name: Stage bundled runtime (Linux)
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
rm -rf .runtime-download resources/runtime
mkdir -p .runtime-download resources/runtime
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ steps.runtime-asset.outputs.asset_name }}" --dir .runtime-download
tar -xzf ".runtime-download/${{ steps.runtime-asset.outputs.asset_name }}" -C .runtime-download
cp -R .runtime-download/runtime/. resources/runtime/
node ./scripts/stage-runtime.mjs --platform linux-x64 --release-tag "$TAG"
else
node ./scripts/stage-runtime.mjs --platform linux-x64
fi
- name: Build app (Linux)
env:
@ -471,14 +435,12 @@ jobs:
test -f dist-electron/preload/index.js
test -f out/renderer/index.html
test -f mcp-server/dist/index.js
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
test -f resources/runtime/VERSION
fi
- name: Package (Linux)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm dist:linux --publish never
run: pnpm pack:linux --publish never
- name: Validate packaged bundle (Linux)
run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64

View file

@ -300,9 +300,13 @@ pnpm dist:mac:arm64 # macOS Apple Silicon (.dmg)
pnpm dist:mac:x64 # macOS Intel (.dmg)
pnpm dist:win # Windows (.exe)
pnpm dist:linux # Linux (AppImage/.deb/.rpm/.pacman)
pnpm dist # macOS + Windows + Linux
pnpm dist # Current platform
```
Distribution scripts run the production build and stage the bundled multimodel runtime from
`runtime.lock.json` before packaging. Use `pnpm clean:runtime` to remove staged runtime files after
local packaging.
### Scripts
| Command | Description |

View file

@ -0,0 +1,90 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const RENAME_MAX_ATTEMPTS = 8;
const RENAME_RETRY_BASE_DELAY_MS = 40;
const RENAME_RETRY_MAX_DELAY_MS = 250;
const RENAME_RETRY_JITTER_MS = 25;
const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function getRenameRetryDelayMs(attempt) {
const backoff = Math.min(RENAME_RETRY_BASE_DELAY_MS * attempt, RENAME_RETRY_MAX_DELAY_MS);
return backoff + Math.floor(Math.random() * (RENAME_RETRY_JITTER_MS + 1));
}
function fsyncFileBestEffort(filePath) {
let fd = null;
try {
fd = fs.openSync(filePath, 'r+');
fs.fsyncSync(fd);
} catch {
// Best effort only. Some filesystems do not support fsync for these files.
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
// Best effort only.
}
}
}
}
function renameWithRetrySync(tempPath, filePath) {
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt += 1) {
try {
fs.renameSync(tempPath, filePath);
return;
} catch (error) {
if (error && error.code === 'EXDEV') {
fs.copyFileSync(tempPath, filePath);
try {
fs.rmSync(tempPath, { force: true });
} catch {
// Best effort cleanup after cross-device fallback.
}
return;
}
if (error && RETRYABLE_RENAME_CODES.has(error.code) && attempt < RENAME_MAX_ATTEMPTS) {
sleepSync(getRenameRetryDelayMs(attempt));
continue;
}
throw error;
}
}
}
function atomicWriteFileSync(filePath, data, options) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = path.join(path.dirname(filePath), `.tmp.${crypto.randomUUID()}`);
try {
fs.writeFileSync(tempPath, data, options);
fsyncFileBestEffort(tempPath);
renameWithRetrySync(tempPath, filePath);
} catch (error) {
try {
fs.rmSync(tempPath, { force: true });
} catch {
// Cleanup is best effort. Preserve the original write error.
}
throw error;
}
}
function writeJsonFileSync(filePath, value, options = {}) {
const suffix = options.trailingNewline === true ? '\n' : '';
atomicWriteFileSync(filePath, `${JSON.stringify(value, null, 2)}${suffix}`, 'utf8');
}
module.exports = {
atomicWriteFileSync,
writeJsonFileSync,
};

View file

@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { writeJsonFileSync } = require('./atomicFile.js');
const { createControllerContext } = require('./context.js');
const { withFileLockSync } = require('./fileLock.js');
const messageStore = require('./messageStore.js');
@ -25,10 +26,7 @@ function readJson(filePath, fallbackValue) {
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
writeJsonFileSync(filePath, value);
}
function normalizeMetaMembers(rawMembers) {

View file

@ -1,6 +1,6 @@
const fs = require('fs');
const path = require('path');
const taskStore = require('./taskStore.js');
const { writeJsonFileSync } = require('./atomicFile.js');
function nowIso() {
return new Date().toISOString();
@ -15,10 +15,7 @@ function readJson(filePath, fallbackValue) {
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
writeJsonFileSync(filePath, value);
}
function getDefaultState(teamName) {

View file

@ -1,15 +1,12 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { writeJsonFileSync } = require('./atomicFile.js');
function nowIso() {
return new Date().toISOString();
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readJson(filePath, fallbackValue) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@ -19,10 +16,7 @@ function readJson(filePath, fallbackValue) {
}
function writeJson(filePath, value) {
ensureDir(path.dirname(filePath));
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
writeJsonFileSync(filePath, value);
}
function getInboxPath(paths, memberName) {

View file

@ -1,7 +1,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { writeJsonFileSync } = require('./atomicFile.js');
const runtimeHelpers = require('./runtimeHelpers.js');
function nowIso() {
@ -17,10 +17,7 @@ function readJson(filePath, fallbackValue) {
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
writeJsonFileSync(filePath, value);
}
function readProcesses(paths) {

View file

@ -49,6 +49,18 @@ function uniqueNonEmpty(items) {
return [...new Set(items.filter((item) => typeof item === 'string' && item.trim()))];
}
function describeControlApiLookup(context, flags, stateFileUrl, envUrl) {
const explicit =
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
(typeof flags['control-url'] === 'string' && flags['control-url'].trim()) ||
'';
return [
`explicit=${explicit ? 'set' : 'missing'}`,
`stateFile=${stateFileUrl ? 'set' : `missing:${getControlApiStatePath(context)}`}`,
`env=${envUrl ? 'set' : 'missing:CLAUDE_TEAM_CONTROL_URL'}`,
].join(', ');
}
function resolveControlBaseUrls(context, flags = {}) {
const explicit =
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
@ -63,7 +75,12 @@ function resolveControlBaseUrls(context, flags = {}) {
if (candidates.length === 0) {
throw new Error(
'Team control API is unavailable. Start the desktop app team runtime first so it can publish CLAUDE_TEAM_CONTROL_URL.'
`Team control API is unavailable. Start the desktop app team runtime first so it can publish CLAUDE_TEAM_CONTROL_URL. Lookup: ${describeControlApiLookup(
context,
flags,
stateFileUrl,
envUrl
)}.`
);
}

View file

@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { writeJsonFileSync } = require('./atomicFile.js');
const reviewStateHelpers = require('./reviewState.js');
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
@ -24,10 +25,7 @@ function readJson(filePath, fallbackValue) {
}
function writeJson(filePath, value) {
ensureDir(path.dirname(filePath));
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
writeJsonFileSync(filePath, value);
}
function getTaskPath(paths, taskId) {

View file

@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const runtimeHelpers = require('./runtimeHelpers.js');
const { writeJsonFileSync } = require('./atomicFile.js');
const { withFileLockSync } = require('./fileLock.js');
const DEFAULT_WAIT_TIMEOUT_MS = 10000;
@ -30,6 +31,20 @@ function readControlApiState(context) {
}
}
function describeControlApiLookup(context, flags, stateFileUrl, envUrl) {
const explicit =
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
(typeof flags['control-url'] === 'string' && flags['control-url'].trim()) ||
'';
return [
`explicit=${explicit ? 'set' : 'missing'}`,
`stateFile=${
stateFileUrl ? 'set' : `missing:${path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE)}`
}`,
`env=${envUrl ? 'set' : 'missing:CLAUDE_TEAM_CONTROL_URL'}`,
].join(', ');
}
function resolveControlBaseUrls(context, flags = {}) {
const explicit =
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
@ -43,7 +58,12 @@ function resolveControlBaseUrls(context, flags = {}) {
const candidates = [...new Set([explicit, stateFileUrl, envUrl].filter(Boolean))];
if (candidates.length === 0) {
throw new Error(
'Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports.'
`Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports. Lookup: ${describeControlApiLookup(
context,
flags,
stateFileUrl,
envUrl
)}.`
);
}
return candidates;
@ -139,7 +159,9 @@ function buildPendingIntentId(body) {
: [];
const payload = {
teamName: body.teamName,
memberName: String(body.memberName || '').trim().toLowerCase(),
memberName: String(body.memberName || '')
.trim()
.toLowerCase(),
state: body.state,
agendaFingerprint: body.agendaFingerprint,
reportToken: body.reportToken || '',
@ -176,10 +198,7 @@ function readPendingReportFile(filePath) {
}
function writePendingReportFile(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, filePath);
writeJsonFileSync(filePath, data, { trailingNewline: true });
}
function appendPendingReportIntent(context, body, reason) {

View file

@ -0,0 +1,94 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { writeJsonFileSync } = require('../src/internal/atomicFile.js');
function listTempFiles(dir) {
return fs.readdirSync(dir).filter((name) => name.includes('.tmp.'));
}
function withMockedRenameSync(mockRenameSync, callback) {
const originalRenameSync = fs.renameSync;
fs.renameSync = (from, to) => mockRenameSync(from, to, originalRenameSync);
try {
callback();
} finally {
fs.renameSync = originalRenameSync;
}
}
describe('atomic file writes', () => {
['EPERM', 'EACCES', 'EBUSY'].forEach((code) => {
it(`retries transient ${code} rename failures before publishing JSON`, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
const filePath = path.join(dir, 'state.json');
let attempts = 0;
withMockedRenameSync(
(from, to, originalRenameSync) => {
attempts += 1;
if (attempts < 3) {
const error = new Error(`simulated transient ${code}`);
error.code = code;
throw error;
}
return originalRenameSync.call(fs, from, to);
},
() => {
writeJsonFileSync(filePath, { ok: true });
}
);
expect(attempts).toBe(3);
expect(JSON.parse(fs.readFileSync(filePath, 'utf8'))).toEqual({ ok: true });
expect(listTempFiles(dir)).toEqual([]);
});
});
it('does not retry ENOENT rename failures and removes the temp file', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
const filePath = path.join(dir, 'state.json');
let attempts = 0;
withMockedRenameSync(
() => {
attempts += 1;
const error = new Error('missing target directory');
error.code = 'ENOENT';
throw error;
},
() => {
expect(() => writeJsonFileSync(filePath, { ok: true })).toThrow('missing target directory');
}
);
expect(attempts).toBe(1);
expect(fs.existsSync(filePath)).toBe(false);
expect(listTempFiles(dir)).toEqual([]);
});
it('removes the temp file after retryable rename failures are exhausted', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
const filePath = path.join(dir, 'state.json');
let attempts = 0;
withMockedRenameSync(
() => {
attempts += 1;
const error = new Error('transient lock stayed active');
error.code = 'EBUSY';
throw error;
},
() => {
expect(() => writeJsonFileSync(filePath, { ok: true })).toThrow(
'transient lock stayed active'
);
}
);
expect(attempts).toBe(8);
expect(fs.existsSync(filePath)).toBe(false);
expect(listTempFiles(dir)).toEqual([]);
});
});

View file

@ -0,0 +1,283 @@
# Будущий оркестратор: что взять из Gas City, GoClaw и Gas Town
**Дата**: 2026-05-15
**Статус**: research note для нового проекта оркестратора
**Контекст**: не план миграции текущего Electron-приложения, а список идей, которые стоит заложить в новый фундамент.
## Короткий вердикт
RabbitMQ, Kafka и gRPC решают транспорт и обмен сообщениями, но не решают главную боль оркестратора: где находится каноническое состояние, кто владеет lifecycle агента, как восстанавливаться после падений, как доказывать что агент действительно сделал работу, и как не превратить UI, файлы, процессы и очереди в четыре разных источника правды.
Из изученных проектов лучший архитектурный reference - **Gas City**. Самые полезные backend-идеи по reliability - **GoClaw**. Самые полезные UX и team-workflow идеи - **Gas Town**, но его tmux/file substrate не стоит копировать как control plane.
## Топ-3 источника идей
| Источник | Что взять | Оценка | Примерный объём для MVP |
|----------|-----------|--------|--------------------------|
| **Gas City / gascity** | Provider interface, typed HTTP API, SSE events, async `request_id`, `event_cursor`, session state machine, trust boundaries, разделение supervisor/city | 🎯 9 🛡️ 8 🧠 8 | ~3000-9000 LOC |
| **GoClaw / goclaw** | Durable task lifecycle, scheduler lanes, periodic recovery, stale/orphan repair, явные failure domains | 🎯 8 🛡️ 8 🧠 7 | ~2500-7000 LOC |
| **Gas Town / gastown** | Роли агентов, mail/nudge модель, operator UX, recover/doctor mindset, простота запуска команды | 🎯 8 🛡️ 5.5 🧠 7 | ~1500-5000 LOC, если брать выборочно |
Оценки:
- 🎯 - уверенность, что идея полезна для нас.
- 🛡️ - надёжность паттерна как основы продукта.
- 🧠 - сложность внедрения, где 10 значит сложно.
## Что взять в новый оркестратор
### 1. Runtime Provider Contract
Из Gas City стоит взять идею строгого runtime-контракта для каждого агента: Claude, Codex, OpenCode и будущие adapters не должны жить внутри одного огромного сервиса.
Минимальный контракт:
```ts
interface RuntimeProvider {
kind: 'claude' | 'codex' | 'opencode' | string;
startSession(input: StartSessionInput): Promise<StartSessionResult>;
sendInput(input: SendInputInput): Promise<SendInputResult>;
cancelSession(input: CancelSessionInput): Promise<void>;
inspectSession(input: InspectSessionInput): Promise<RuntimeSessionSnapshot>;
recoverSession(input: RecoverSessionInput): Promise<RecoverSessionResult>;
}
```
Ключевая мысль: adapter - это не просто `spawn(command)`. Он должен уметь сообщать состояние, восстанавливаться, подтверждать readiness, отдавать evidence и нормально завершаться.
### 2. Typed Control Plane
Gas City хорош тем, что у него есть явный API-слой, а не скрытая связка UI -> process manager -> files -> CLI.
Для нового оркестратора стоит делать:
- gRPC для внутренних сервисов и adapter workers.
- HTTP/OpenAPI для внешней интеграции, CLI и dashboard.
- SSE или WebSocket для UI projection.
- Асинхронные команды в стиле `202 Accepted + request_id + event_cursor`.
Пример:
```txt
POST /runs
-> 202 Accepted
-> { request_id, run_id, event_cursor }
GET /events?cursor=...
-> append-only stream of state changes
```
Это лучше, чем блокирующий запуск агента, потому что запуск CLI, auth, checkout, bootstrap и readiness могут занимать непредсказуемое время.
### 3. Durable Task Lifecycle
Из GoClaw стоит взять явную task model и scheduler, где task не является просто строкой в prompt или карточкой в UI.
Состояния должны быть формализованы:
```txt
queued -> assigned -> starting -> running -> blocked -> review -> completed
-> failed
-> cancelled
-> stale
```
Важно:
- Все переходы идут через один доменный сервис.
- Каждый переход пишет событие.
- UI только читает projection.
- Adapter не решает сам, что задача завершена, он присылает signal/evidence, а orchestrator принимает transition.
### 4. Recovery And Reconciliation Loops
У GoClaw сильная идея periodic recovery ticker: система не верит, что всё всегда завершится идеально.
Для нового оркестратора нужны фоновые reconciler-процессы:
- Найти runs, которые `starting` слишком долго.
- Найти agents без heartbeat.
- Найти task, где process умер, но state всё ещё `running`.
- Найти orphan workspace/process/session.
- Повторить idempotent-команды.
- Сформировать launch-failure artifact с redacted логами.
Это должно быть частью core, а не набором debug scripts.
### 5. Inter-Agent Mail And Nudge
Из Gas Town стоит взять product-идею: агенты не только выполняют task, но и умеют общаться, просить review, пинговать teammate, отдавать status и handoff.
Но реализация должна быть не через tmux pane и не через ad hoc text capture.
Правильнее:
- `messages` table как durable inbox/outbox.
- Типизированные сообщения: `question`, `status`, `handoff`, `review_request`, `decision`, `blocker`.
- Causal links: `task_id`, `run_id`, `parent_message_id`.
- Delivery state: `created`, `delivered`, `seen_by_adapter`, `acknowledged`.
- UI строит thread projection из canonical store.
### 6. Trust Boundaries
Из Gas City нужно взять идею явных trust boundaries: orchestrator, adapter, workspace, user secret store и UI не должны иметь одинаковые права.
Практические правила:
- Adapter worker получает только нужный workspace и scoped credentials.
- UI не исполняет команды и не пишет напрямую в runtime state.
- CLI tools не получают прямой доступ к master database без API boundary.
- Secrets не идут через event stream.
- Все dangerous operations требуют policy decision: filesystem, shell, git push, network, secrets, browser.
### 7. Evidence Model
Для больших проектов на сотни тысяч строк важно не просто “агент сказал done”.
Нужно хранить evidence:
- terminal tail
- diff summary
- changed files snapshot
- test command result
- lint/typecheck result
- agent final answer
- tool calls summary
- review verdict
Идея: task completion без evidence - это не completion, а claim.
### 8. UI As Projection, Not Source Of Truth
Текущая боль обычно появляется, когда Electron UI, IPC handlers, JSON files, process registry и runtime adapter все частично владеют состоянием.
Для нового проекта:
- Canonical truth: Postgres.
- Event truth: append-only outbox/events.
- UI truth: read model/projection.
- Process truth: runtime snapshots, которые reconciler сверяет с canonical state.
UI должен уметь умереть и открыться заново без потери смысла происходящего.
## Что не копировать
### Не копировать tmux как control plane
Gas Town полезен как workflow reference, но tmux/send-keys/capture-pane не должен быть foundation для нового мощного оркестратора.
Проблемы:
- readiness часто эвристический
- сложно гарантировать доставку сообщений
- сложно делать typed state transitions
- сложно изолировать права
- сложно масштабировать на distributed workers
tmux может быть optional local development view, но не backend truth.
### Не делать CLI as RPC
CLI можно поддерживать как adapter transport, но нельзя строить весь control plane на парсинге stdout/stderr.
Правильнее:
- CLI adapter нормализует output в typed events.
- Core orchestrator не знает про ANSI, panes, terminal prompt и escape sequences.
- Provider-specific parsing живёт внутри provider adapter.
### Не использовать in-memory event bus как critical truth
GoClaw показывает полезный event-bus mindset, но bounded in-process bus не должен быть источником истины.
Для side effects - нормально.
Для lifecycle, audit и recovery - нужен durable outbox.
### Не смешивать Kafka и RabbitMQ без причины
Kafka и RabbitMQ решают разные задачи.
Рекомендуемый baseline:
- RabbitMQ или NATS JetStream для commands/work queue.
- Kafka только если нужен большой durable event log, analytics, replay и интеграции.
- Для MVP чаще достаточно Postgres outbox + worker polling или NATS.
Иначе получится сложность распределённой системы до того, как появится понятная domain model.
## Целевая архитектура для нового проекта
```mermaid
flowchart LR
UI["Electron/Web UI"] --> API["Control Plane API"]
CLI["CLI / SDK"] --> API
API --> DB["Postgres canonical state"]
API --> OUTBOX["Durable outbox"]
OUTBOX --> EVENTS["SSE/WebSocket projections"]
OUTBOX --> WORKQ["NATS/RabbitMQ work queue"]
API --> WF["Temporal workflows"]
WF --> WORKQ
WORKQ --> A1["Claude adapter worker"]
WORKQ --> A2["Codex adapter worker"]
WORKQ --> A3["OpenCode adapter worker"]
A1 --> WS["Workspace / git worktree"]
A2 --> WS
A3 --> WS
REC["Reconciler"] --> DB
REC --> A1
REC --> A2
REC --> A3
```
Рекомендуемый стек:
- **Postgres** - canonical state, locks, task lifecycle, sessions, messages, evidence.
- **Temporal** - long-running workflows: launch, cancel, retry, review, recovery.
- **gRPC** - internal adapter protocol.
- **HTTP/OpenAPI** - public control plane.
- **SSE/WebSocket** - live UI events.
- **NATS JetStream или RabbitMQ** - work queue.
- **Kafka** - позже, если нужен global event log, replay и analytics.
## MVP vertical slice
Чтобы не построить “идеальную” систему на год вперёд, первый MVP должен доказать один полный путь:
1. Создать `run` и `task` в Postgres.
2. Запустить одного adapter worker через typed provider contract.
3. Получить live events в UI через cursor-based stream.
4. Сохранить evidence: output, diff summary, test result.
5. Убить worker и проверить, что reconciler переводит state в `failed` или восстанавливает run.
Если этот путь работает, тогда добавлять:
- multi-agent team planning
- inter-agent mail
- review workflow
- multi-provider adapters
- distributed workers
- Kafka/event lake
## Практический вывод для нас
Главная ошибка была бы думать, что “микросервисы + RabbitMQ + Kafka + gRPC” автоматически делают систему грамотной. Грамотной её делают:
- один canonical state
- typed provider boundary
- durable lifecycle
- recovery loops
- strict trust boundaries
- UI как projection
- evidence-based completion
Транспорт можно заменить. Потерянную domain model потом очень дорого чинить.
## Источники
- [Gas City repository](https://github.com/gastownhall/gascity)
- [Gas City API reference](https://raw.githubusercontent.com/gastownhall/gascity/main/docs/reference/api.md)
- [Gas City trust boundaries](https://raw.githubusercontent.com/gastownhall/gascity/main/docs/reference/trust-boundaries.md)
- [Gas City events reference](https://raw.githubusercontent.com/gastownhall/gascity/main/docs/reference/events.md)
- [Gas City runtime provider interface](https://raw.githubusercontent.com/gastownhall/gascity/main/internal/runtime/runtime.go)
- [Gas City session state machine](https://raw.githubusercontent.com/gastownhall/gascity/main/internal/session/state_machine.go)
- [Gas Town provider integration](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md)
- [GoClaw repository](https://github.com/nextlevelbuilder/goclaw)
- [GoClaw task ticker](https://raw.githubusercontent.com/nextlevelbuilder/goclaw/dev/internal/tasks/task_ticker.go)
- [GoClaw scheduler](https://raw.githubusercontent.com/nextlevelbuilder/goclaw/dev/internal/scheduler/scheduler.go)
- [GoClaw domain event bus](https://raw.githubusercontent.com/nextlevelbuilder/goclaw/dev/internal/eventbus/domain_event_bus.go)
## 📌 Summary
Для нового мощного оркестратора стоит брать не готовый проект целиком, а паттерны:
- Gas City - control plane, provider contract, events, trust boundaries.
- GoClaw - task lifecycle, scheduler, recovery loops.
- Gas Town - team UX, mail/nudge, operator workflow.
Не стоит копировать tmux/file-first подход как фундамент. База должна быть durable: Postgres + workflows + typed adapters + event stream.

View file

@ -3,6 +3,8 @@
**Дата**: 2026-03-25
**Вопрос**: стоит ли взять готовый multi-agent оркестратор и посадить наш Electron UI сверху, вместо того чтобы развивать собственный TeamProvisioningService?
**Связанная заметка**: [будущий оркестратор: паттерны из Gas City, GoClaw и Gas Town](future-orchestrator-competitor-patterns.md)
---
## 1. Что мы бы заменяли (наш текущий стек)

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
@use "./cyberpunk-hero";
@import "./brand-tokens.css";
:root {

View file

@ -10,7 +10,7 @@ const { baseURL } = useRuntimeConfig().app;
class="app-logo__img"
width="36"
height="36"
/>
>
<span class="app-logo__text">Agent Teams</span>
</NuxtLink>
</template>

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
import { HERO_SCENE_VIEWBOX, type HeroConnection } from "~/data/heroScene";
defineProps<{
connections: readonly HeroConnection[];
activeConnectionId?: string | null;
reducedMotion?: boolean;
}>();
</script>
<template>
<svg
class="cyber-connectors"
:viewBox="`0 0 ${HERO_SCENE_VIEWBOX.width} ${HERO_SCENE_VIEWBOX.height}`"
aria-hidden="true"
>
<g class="cyber-connectors__paths">
<template v-for="connection in connections" :key="connection.id">
<path
class="cyber-connectors__path-glow"
:class="[
`cyber-connectors__path-glow--${connection.accent}`,
{ 'cyber-connectors__path-glow--active': activeConnectionId === connection.id },
]"
:d="connection.pathDesktop"
vector-effect="non-scaling-stroke"
/>
<path
:id="`cyber-path-${connection.id}`"
class="cyber-connectors__path"
:class="[
`cyber-connectors__path--${connection.accent}`,
{ 'cyber-connectors__path--active': activeConnectionId === connection.id },
]"
:d="connection.pathDesktop"
vector-effect="non-scaling-stroke"
/>
</template>
</g>
<g v-if="!reducedMotion" class="cyber-connectors__packets">
<circle
v-for="connection in connections"
:key="`packet-${connection.id}`"
class="cyber-connectors__packet"
:class="[
`cyber-connectors__packet--${connection.accent}`,
{ 'cyber-connectors__packet--active': activeConnectionId === connection.id },
]"
r="4"
>
<animateMotion
:dur="`${connection.packetDurationMs}ms`"
repeatCount="indefinite"
:begin="`${connection.packetDelayMs}ms`"
>
<mpath :href="`#cyber-path-${connection.id}`" />
</animateMotion>
</circle>
</g>
</svg>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import {
mdiRobotOutline,
mdiViewDashboardOutline,
mdiCodeBraces,
mdiShieldCheckOutline,
mdiMonitorDashboard,
} from "@mdi/js";
import { heroFeatureRail } from "~/data/heroScene";
const icons = [
mdiRobotOutline,
mdiViewDashboardOutline,
mdiCodeBraces,
mdiShieldCheckOutline,
mdiMonitorDashboard,
] as const;
</script>
<template>
<div class="cyber-feature-rail cyber-panel">
<div
v-for="(feature, index) in heroFeatureRail"
:key="feature.id"
class="cyber-feature-rail__item"
>
<div class="cyber-feature-rail__icon">
<v-icon :icon="icons[index]" size="28" />
</div>
<div class="cyber-feature-rail__copy">
<div class="cyber-feature-rail__title">{{ feature.title }}</div>
<div class="cyber-feature-rail__text">{{ feature.text }}</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import type { HeroMessage } from "~/data/heroScene";
const props = defineProps<{
message: HeroMessage | null;
phase: "sender" | "packet" | "receiver" | "cooldown";
reducedMotion?: boolean;
}>();
const senderStyle = computed(() => ({
"--bubble-x": props.message ? String(props.message.fromX) : "0",
"--bubble-y": props.message ? String(props.message.fromY) : "0",
}));
const receiverStyle = computed(() => ({
"--bubble-x": props.message ? String(props.message.toX) : "0",
"--bubble-y": props.message ? String(props.message.toY) : "0",
}));
const showSender = computed(() => props.message && (props.phase === "sender" || props.phase === "packet"));
const showReceiver = computed(() => props.message && props.phase === "receiver");
</script>
<template>
<div class="cyber-messages" aria-hidden="true">
<Transition name="cyber-bubble">
<div
v-if="showSender && message && !reducedMotion"
class="cyber-message cyber-message--sender cyber-panel"
:style="senderStyle"
>
{{ message.text }}
</div>
</Transition>
<Transition name="cyber-bubble">
<div
v-if="showReceiver && message && !reducedMotion"
class="cyber-message cyber-message--receiver cyber-panel"
:style="receiverStyle"
>
{{ message.response }}
</div>
</Transition>
<div v-if="reducedMotion" class="cyber-message cyber-message--static cyber-panel">
Agents coordinate work automatically.
</div>
</div>
</template>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import type { HeroAgent, HeroAgentRole } from "~/data/heroScene";
const props = defineProps<{
agent: HeroAgent;
activeSender?: HeroAgentRole | null;
activeReceiver?: HeroAgentRole | "video" | null;
}>();
const isSender = computed(() => props.activeSender === props.agent.id);
const isReceiver = computed(() => props.activeReceiver === props.agent.id);
const imageLoading = computed(() => (props.agent.priority ? "eager" : "lazy"));
const imageFetchPriority = computed(() => (props.agent.priority ? "high" : "auto"));
const rootStyle = computed(() => ({
"--agent-x": String(props.agent.desktop.x),
"--agent-y": String(props.agent.desktop.y),
"--agent-scale": String(props.agent.desktop.scale),
"--agent-depth": String(props.agent.desktop.depth),
"--agent-tablet-x": String(props.agent.tablet.x),
"--agent-tablet-y": String(props.agent.tablet.y),
"--agent-tablet-scale": String(props.agent.tablet.scale),
"--agent-tablet-depth": String(props.agent.tablet.depth),
}));
</script>
<template>
<div
class="cyber-agent"
:class="[
`cyber-agent--${agent.accent}`,
`cyber-agent--card-${agent.desktop.card}`,
{
'cyber-agent--sending': isSender,
'cyber-agent--receiving': isReceiver,
'cyber-agent--mobile-visible': agent.mobile.visible,
},
]"
:data-agent="agent.id"
:style="rootStyle"
aria-hidden="true"
>
<div class="cyber-agent__float">
<div class="cyber-agent__contact" />
<img
class="cyber-agent__image"
:src="agent.asset"
alt=""
:loading="imageLoading"
:fetchpriority="imageFetchPriority"
decoding="async"
draggable="false"
>
<div class="cyber-agent__eyes" />
</div>
<div class="cyber-agent__card cyber-panel">
<div class="cyber-agent__label">{{ agent.label }}</div>
<ul class="cyber-agent__tasks">
<li v-for="task in agent.tasks" :key="task">{{ task }}</li>
</ul>
<div class="cyber-agent__status">
<span>Status:</span>
<strong>{{ agent.status }}</strong>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import {
heroAgents,
heroConnections,
heroMessages,
type HeroAgentRole,
} from "~/data/heroScene";
type MessagePhase = "sender" | "packet" | "receiver" | "cooldown";
const activeMessageIndex = ref(0);
const phase = ref<MessagePhase>("cooldown");
const isVisible = ref(false);
const reducedMotion = ref(false);
const sceneRef = ref<HTMLElement | null>(null);
let timers: number[] = [];
let observer: IntersectionObserver | null = null;
let motionQuery: MediaQueryList | null = null;
const activeMessage = computed(() => heroMessages[activeMessageIndex.value] ?? null);
const activeConnectionId = computed(() => (phase.value === "cooldown" ? null : activeMessage.value?.connectionId ?? null));
const activeSender = computed<HeroAgentRole | null>(() => (phase.value === "cooldown" ? null : activeMessage.value?.from ?? null));
const activeReceiver = computed<HeroAgentRole | "video" | null>(() => (
phase.value === "receiver" ? activeMessage.value?.to ?? null : null
));
function clearTimers() {
timers.forEach(window.clearTimeout);
timers = [];
}
function setTimer(callback: () => void, delay: number) {
const id = window.setTimeout(callback, delay);
timers.push(id);
}
function runCycle() {
clearTimers();
if (!isVisible.value || reducedMotion.value) {
phase.value = "cooldown";
return;
}
phase.value = "sender";
setTimer(() => {
phase.value = "packet";
}, 900);
setTimer(() => {
phase.value = "receiver";
}, 2200);
setTimer(() => {
phase.value = "cooldown";
}, 3900);
setTimer(() => {
activeMessageIndex.value = (activeMessageIndex.value + 1) % heroMessages.length;
runCycle();
}, 4700);
}
function syncMotion() {
reducedMotion.value = Boolean(motionQuery?.matches);
runCycle();
}
function onVisibilityChange() {
if (document.hidden) {
clearTimers();
phase.value = "cooldown";
return;
}
runCycle();
}
onMounted(() => {
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion.value = motionQuery.matches;
motionQuery.addEventListener("change", syncMotion);
document.addEventListener("visibilitychange", onVisibilityChange);
observer = new IntersectionObserver(
([entry]) => {
isVisible.value = entry.isIntersecting;
runCycle();
},
{ threshold: 0.15 },
);
if (sceneRef.value) observer.observe(sceneRef.value);
});
onUnmounted(() => {
clearTimers();
observer?.disconnect();
motionQuery?.removeEventListener("change", syncMotion);
document.removeEventListener("visibilitychange", onVisibilityChange);
});
</script>
<template>
<div ref="sceneRef" class="cyber-scene">
<div class="cyber-scene__floor" aria-hidden="true" />
<CyberHeroConnectors
class="cyber-scene__connectors"
:connections="heroConnections"
:active-connection-id="activeConnectionId"
:reduced-motion="reducedMotion"
/>
<CyberHeroVideoFrame class="cyber-scene__video" />
<div class="cyber-scene__robots">
<CyberHeroRobot
v-for="agent in heroAgents"
:key="agent.id"
:agent="agent"
:active-sender="activeSender"
:active-receiver="activeReceiver"
/>
</div>
<CyberHeroMessageBubbles
class="cyber-scene__messages"
:message="activeMessage"
:phase="phase"
:reduced-motion="reducedMotion"
/>
<div class="cyber-scene__foreground" aria-hidden="true" />
</div>
</template>

View file

@ -0,0 +1,31 @@
<template>
<div
id="hero-demo"
class="cyber-video-frame"
role="region"
aria-label="Watch Agent Teams demo"
>
<div class="cyber-video-frame__bezel" aria-hidden="true" />
<div class="cyber-video-frame__status" aria-hidden="true">
<span>Team command feed</span>
<span>Live demo</span>
</div>
<div class="cyber-video-frame__content">
<ClientOnly>
<Suspense>
<LazyHeroDemoVideo />
<template #fallback>
<div class="cyber-video-frame__fallback" />
</template>
</Suspense>
<template #fallback>
<div class="cyber-video-frame__fallback" />
</template>
</ClientOnly>
</div>
<div class="cyber-video-frame__corner cyber-video-frame__corner--tl" aria-hidden="true" />
<div class="cyber-video-frame__corner cyber-video-frame__corner--tr" aria-hidden="true" />
<div class="cyber-video-frame__corner cyber-video-frame__corner--bl" aria-hidden="true" />
<div class="cyber-video-frame__corner cyber-video-frame__corner--br" aria-hidden="true" />
</div>
</template>

View file

@ -22,7 +22,9 @@ const navItems = computed(() => [
<template>
<header class="app-header">
<v-container class="app-header__inner">
<div class="app-header__brand-frame">
<AppLogo />
</div>
<nav class="app-header__nav">
<v-btn v-for="item in navItems" :key="item.href" variant="text" :href="item.href">
{{ item.label }}
@ -90,67 +92,224 @@ const navItems = computed(() => [
<style scoped>
.app-header {
--header-cyan: var(--cyber-cyan);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--at-z-header);
height: 64px;
height: 92px;
display: flex;
align-items: center;
backdrop-filter: blur(var(--at-blur-md));
-webkit-backdrop-filter: blur(var(--at-blur-md));
border-bottom: 1px solid var(--at-c-border);
background:
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)),
rgba(2, 5, 13, 0.72);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.26);
}
.app-header::before,
.app-header::after {
display: none;
}
.v-theme--light .app-header {
background: rgba(255, 255, 255, 0.9);
border-bottom-color: var(--at-c-border);
background:
linear-gradient(180deg, rgba(244, 250, 255, 0.96), rgba(244, 250, 255, 0.8) 74%, rgba(244, 250, 255, 0.2)),
rgba(244, 250, 255, 0.86);
border-bottom-color: rgba(0, 168, 204, 0.34);
}
.v-theme--dark .app-header {
background: rgba(10, 10, 15, 0.9);
background:
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)),
rgba(2, 5, 13, 0.72);
}
.app-header__inner {
position: relative;
display: flex;
align-items: center;
flex-wrap: nowrap;
width: min(1680px, 100vw);
max-width: none !important;
height: 100%;
padding-inline: 0 !important;
}
.app-header__brand-frame {
position: relative;
display: flex;
align-items: center;
align-self: center;
isolation: isolate;
height: 76px;
min-width: 358px;
padding: 0 74px 0 52px;
background: transparent;
border: 0;
clip-path: none;
box-shadow: none;
}
.app-header__brand-frame::before,
.app-header__brand-frame::after {
content: "";
position: absolute;
pointer-events: none;
clip-path: polygon(0 0, calc(100% - 54px) 0, 100% 50%, calc(100% - 54px) 100%, 0 100%, 0 0);
}
.app-header__brand-frame::before {
inset: 0;
z-index: -2;
background: linear-gradient(110deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.5) 58%, rgba(0, 234, 255, 0.82));
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.42));
}
.app-header__brand-frame::after {
inset: 1px;
z-index: -1;
background:
linear-gradient(110deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.95) 64%, rgba(0, 234, 255, 0.08)),
rgba(2, 6, 16, 0.96);
}
.app-header__brand-frame :deep(.app-logo) {
position: relative;
z-index: 1;
gap: 12px;
min-width: 0;
}
.app-header__brand-frame :deep(.app-logo__img) {
width: 44px;
height: 44px;
border-radius: 12px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
0 0 22px rgba(139, 92, 255, 0.36);
}
.app-header__brand-frame :deep(.app-logo__text) {
font-size: 20px;
font-weight: 800;
letter-spacing: 0.02em;
white-space: nowrap;
}
.app-header__nav {
position: relative;
display: flex;
align-self: stretch;
align-items: stretch;
margin-left: 48px;
flex: 1 1 auto;
align-self: center;
align-items: center;
justify-content: flex-start;
gap: clamp(22px, 2.7vw, 46px);
height: 76px;
margin-left: -28px;
padding: 0 clamp(34px, 4.4vw, 74px) 0 clamp(70px, 5.6vw, 104px);
}
.app-header__nav::before,
.app-header__nav::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 1px;
pointer-events: none;
background: linear-gradient(90deg, rgba(0, 234, 255, 0.6), rgba(0, 234, 255, 0.24) 36%, rgba(139, 92, 255, 0.5) 58%, rgba(0, 234, 255, 0.58));
opacity: 0.86;
box-shadow: 0 0 14px rgba(0, 234, 255, 0.18);
}
.app-header__nav::before {
top: 8px;
}
.app-header__nav::after {
bottom: 7px;
}
.app-header__nav :deep(.v-btn) {
height: 100% !important;
height: 48px !important;
border-radius: 0;
color: rgba(244, 247, 255, 0.9) !important;
font-family: var(--at-font-mono);
font-size: 13px !important;
font-weight: 700 !important;
letter-spacing: 0.08em !important;
text-transform: uppercase !important;
}
.app-header__nav :deep(.v-btn:hover) {
color: var(--header-cyan) !important;
background: linear-gradient(180deg, transparent, rgba(0, 234, 255, 0.08)) !important;
}
.app-header__spacer {
flex: 1;
display: none;
}
.app-header__desktop-actions {
position: relative;
display: flex;
gap: 8px;
gap: 12px;
align-items: center;
align-self: center;
justify-content: flex-end;
isolation: isolate;
height: 76px;
min-width: 328px;
padding: 0 32px 0 58px;
border: 0;
background: transparent;
clip-path: none;
box-shadow: none;
}
.app-header__desktop-actions::before,
.app-header__desktop-actions::after {
content: "";
position: absolute;
pointer-events: none;
clip-path: polygon(42px 0, 100% 0, 100% 100%, 42px 100%, 0 50%);
}
.app-header__desktop-actions::before {
inset: 0;
z-index: -2;
background: linear-gradient(250deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.46) 48%, rgba(0, 234, 255, 0.72));
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.34));
}
.app-header__desktop-actions::after {
inset: 1px;
z-index: -1;
background:
linear-gradient(250deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.94) 68%, rgba(0, 234, 255, 0.08)),
rgba(2, 6, 16, 0.96);
}
.app-header__github-btn {
border-color: var(--at-c-border-strong) !important;
color: var(--at-c-cyan) !important;
font-weight: 600 !important;
min-height: 36px !important;
border-color: rgba(0, 234, 255, 0.58) !important;
color: var(--header-cyan) !important;
font-family: var(--at-font-mono);
font-weight: 800 !important;
font-size: 12px !important;
letter-spacing: 0.02em !important;
letter-spacing: 0.08em !important;
text-transform: uppercase !important;
box-shadow: 0 0 16px rgba(0, 234, 255, 0.12);
}
.app-header__github-btn:hover {
border-color: var(--at-c-focus) !important;
background: rgba(0, 240, 255, 0.06) !important;
border-color: rgba(0, 234, 255, 0.86) !important;
background: rgba(0, 234, 255, 0.08) !important;
box-shadow: 0 0 22px rgba(0, 234, 255, 0.2);
}
.app-header__mobile-actions {
@ -158,6 +317,32 @@ const navItems = computed(() => [
}
@media (max-width: 959px) {
.app-header {
height: 64px;
}
.app-header__inner {
width: min(100% - 32px, 680px);
}
.app-header__brand-frame {
min-width: 0;
flex: 1;
align-self: center;
height: 48px;
padding: 0 42px 0 12px;
}
.app-header__brand-frame :deep(.app-logo__img) {
width: 34px;
height: 34px;
}
.app-header__brand-frame :deep(.app-logo__text) {
font-size: 12px;
letter-spacing: 0.04em;
}
.app-header__nav {
display: none;
}
@ -168,6 +353,13 @@ const navItems = computed(() => [
.app-header__mobile-actions {
display: flex;
margin-left: 10px;
}
.app-header__mobile-actions :deep(.v-btn) {
color: rgba(244, 247, 255, 0.92) !important;
border: 1px solid rgba(0, 234, 255, 0.28);
background: rgba(2, 6, 16, 0.72);
}
}
@ -175,7 +367,9 @@ const navItems = computed(() => [
position: fixed;
inset: 0;
z-index: 9999;
background: rgb(var(--v-theme-surface));
background:
radial-gradient(circle at 20% 10%, rgba(0, 234, 255, 0.12), transparent 34%),
rgba(2, 5, 13, 0.96);
}
.mobile-menu {
@ -207,14 +401,16 @@ const navItems = computed(() => [
align-items: center;
padding: 12px 16px;
font-size: 1rem;
color: rgb(var(--v-theme-on-surface));
color: rgba(244, 247, 255, 0.9);
text-decoration: none;
border-radius: 8px;
border: 1px solid transparent;
border-radius: 6px;
transition: background-color 0.15s;
}
.mobile-menu__link:hover {
background: rgba(var(--v-theme-on-surface), 0.06);
border-color: rgba(0, 234, 255, 0.34);
background: rgba(0, 234, 255, 0.08);
}
.mobile-menu__actions {

View file

@ -48,6 +48,9 @@ const currentFlagIcon = computed(() => {
const iconMenuOpen = ref(false);
const searchQuery = ref("");
const searchInputRef = ref<HTMLInputElement | null>(null);
type RuntimeI18n = {
setLocale?: (code: LocaleCode) => Promise<void> | void;
};
const filteredDropdownItems = computed(() => {
const q = searchQuery.value.toLowerCase().trim();
@ -73,8 +76,9 @@ const onChange = async (value: string | LocaleCode) => {
iconMenuOpen.value = false;
trackLanguageSwitch(locale.value as string, nextLocale);
localeStore.setLocale(nextLocale, true);
if ((nuxtApp.$i18n as any)?.setLocale) {
await (nuxtApp.$i18n as any).setLocale(nextLocale);
const runtimeI18n = nuxtApp.$i18n as RuntimeI18n | undefined;
if (runtimeI18n?.setLocale) {
await runtimeI18n.setLocale(nextLocale);
} else {
locale.value = nextLocale;
}
@ -102,7 +106,7 @@ const onChange = async (value: string | LocaleCode) => {
class="language-switcher__search-input"
:placeholder="t('language.search')"
@keydown.esc="iconMenuOpen = false"
/>
>
</div>
<v-list density="compact" class="language-switcher__menu-list">
<v-list-item
@ -119,7 +123,7 @@ const onChange = async (value: string | LocaleCode) => {
</v-list-item>
<v-list-item v-if="filteredDropdownItems.length === 0" disabled>
<template #title>
<span class="language-switcher__no-results"></span>
<span class="language-switcher__no-results">-</span>
</template>
</v-list-item>
</v-list>
@ -138,7 +142,6 @@ const onChange = async (value: string | LocaleCode) => {
hide-details
auto-select-first
:menu-props="{ contentClass: 'language-switcher__dropdown' }"
@update:model-value="onChange"
:style="props.fullWidth ? { maxWidth: '100%', width: '100%' } : { maxWidth: '220px' }"
:class="{
'language-switcher--full': props.fullWidth,
@ -146,6 +149,7 @@ const onChange = async (value: string | LocaleCode) => {
}"
:aria-label="t('language.label')"
:single-line="props.compact"
@update:model-value="onChange"
>
<template #selection>
<Icon :name="currentFlagIcon" class="language-switcher__flag-icon" />

View file

@ -9,8 +9,12 @@ const downloadStore = useDownloadStore();
const { data: releaseData, resolve } = useReleaseDownloads();
const { trackDownloadClick } = useAnalytics();
const { repoUrl, releaseDownloadUrl } = useGithubRepo();
const isMounted = ref(false);
onMounted(() => downloadStore.init());
onMounted(() => {
isMounted.value = true;
downloadStore.init();
});
const platformIcons: Record<string, string> = {
macos: mdiApple,
@ -45,6 +49,7 @@ const visibleAssets = computed(() => {
});
const getDownloadUrl = (asset: { os: string; arch: string; fileName: string }) => {
if (!isMounted.value) return releaseDownloadUrl(asset.fileName);
const arch = (asset.os === 'macos' ? downloadStore.macArch : asset.arch) as DownloadArch;
return resolve(asset.os as DownloadOs, arch)?.url || releaseDownloadUrl(asset.fileName);
};
@ -149,7 +154,7 @@ const devBranchNote = computed(() =>
{{ devBranchNote }}
</a>
<p v-if="releaseVersion" class="download-section__release-info">
<p v-if="isMounted && releaseVersion" class="download-section__release-info">
v{{ releaseVersion }} · {{ releaseDate }}
</p>
</v-container>

View file

@ -59,7 +59,7 @@ const faqIcons = [
</div>
</v-expansion-panel-title>
<v-expansion-panel-text class="faq-section__panel-text">
<div class="faq-section__answer" v-html="item.answer" />
<div class="faq-section__answer">{{ item.answer }}</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>

View file

@ -1,29 +1,30 @@
<script setup lang="ts">
import {
mdiBookOpenPageVariantOutline,
mdiRobotOutline,
mdiViewDashboardOutline,
mdiOpenSourceInitiative,
} from '@mdi/js';
mdiDownload,
mdiPlayCircleOutline,
} from "@mdi/js";
const { content } = useLandingContent();
const { t, locale } = useI18n();
const { baseURL } = useRuntimeConfig().app;
const workflowVideoSrc = 'https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42';
const heroRef = ref<HTMLElement | null>(null);
const downloadStore = useDownloadStore();
const { resolve, data: releaseData } = useReleaseDownloads();
const { repoUrl, latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
const withBase = (path: string) => `${baseURL.replace(/\/?$/, "/")}${path.replace(/^\/+/, "")}`;
useCyberHeroParallax(heroRef);
const releaseVersion = computed(() => releaseData.value?.version || null);
const releaseDate = computed(() => {
const raw = releaseData.value?.pubDate;
if (!raw) return null;
return new Date(raw).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
return new Date(raw).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
});
@ -32,620 +33,93 @@ onMounted(() => downloadStore.init());
const heroDownloadUrl = computed(() => {
const asset = downloadStore.selectedAsset;
if (!asset) return latestReleaseUrl.value;
const arch = asset.os === 'macos' ? downloadStore.macArch : asset.arch;
const arch = asset.os === "macos" ? downloadStore.macArch : asset.arch;
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
});
const devBranchUrl = computed(() => `${repoUrl.value}/tree/dev`);
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
const docsHref = computed(() => withBase(locale.value === "ru" ? "docs/ru/" : "docs/"));
const devBranchNote = computed(() =>
locale.value === 'ru'
? 'Самая свежая версия в ветке dev - можно развернуть локально.'
: 'Freshest version is on the dev branch - clone and run it locally.',
locale.value === "ru"
? "Самая свежая версия в ветке dev - можно развернуть локально."
: "Freshest version is on the dev branch - clone and run it locally.",
);
</script>
<template>
<section id="hero" class="hero-section section anchor-offset">
<div class="hero-section__video-bg" aria-hidden="true">
<video
class="hero-section__video-bg-player"
autoplay
muted
loop
playsinline
preload="metadata"
:poster="`${baseURL}screenshots/1.jpg`"
>
<source :src="workflowVideoSrc" type="video/mp4">
</video>
<div class="hero-section__video-bg-wash" />
<div class="hero-section__video-bg-edge" />
</div>
<section id="hero" ref="heroRef" class="hero-section cyber-hero section anchor-offset" data-cyber-hero>
<div class="cyber-hero__background" aria-hidden="true" />
<div class="cyber-hero__wash" aria-hidden="true" />
<div class="cyber-hero__gridlines" aria-hidden="true" />
<div class="cyber-hero__scanlines" aria-hidden="true" />
<v-container class="hero-section__container">
<v-row align="center" justify="space-between">
<!-- Left: Text content -->
<v-col cols="12" md="7" class="hero-section__content">
<h1 class="hero-section__title">
<img
:src="`${baseURL}logo-192.png`"
alt=""
class="hero-section__logo"
width="56"
height="56"
/>
{{ content.hero.title }}
<v-container class="cyber-hero__container">
<div class="cyber-hero__layout">
<div class="cyber-hero__copy">
<h1 class="cyber-hero__title">
<span>Agent{{ " " }}</span>
<span class="cyber-hero__title-accent">Teams</span>
</h1>
<p class="hero-section__subtitle">
<p class="cyber-hero__slogan cyber-panel">
YOU'RE THE CTO, AGENTS ARE YOUR TEAM.
</p>
<p class="cyber-hero__description">
{{ content.hero.subtitle }}
</p>
<div class="hero-section__actions">
<div class="cyber-hero__actions">
<v-btn
variant="flat"
size="large"
:href="heroDownloadUrl"
target="_blank"
class="hero-section__btn-primary"
class="cyber-hero__action cyber-hero__action--primary"
:prepend-icon="mdiDownload"
>
{{ t('hero.downloadNow') }}
{{ t("hero.downloadNow") }}
</v-btn>
<v-btn
variant="outlined"
size="large"
href="#hero-demo"
class="cyber-hero__action cyber-hero__action--watch"
:prepend-icon="mdiPlayCircleOutline"
>
{{ t("hero.watchDemo") }}
</v-btn>
<v-btn
variant="outlined"
size="large"
:href="docsHref"
class="hero-section__btn-docs"
class="cyber-hero__action cyber-hero__action--docs"
:prepend-icon="mdiBookOpenPageVariantOutline"
>
{{ t('hero.ctaDocs') }}
</v-btn>
<v-btn
variant="outlined"
size="large"
href="#comparison"
class="hero-section__btn-secondary"
>
{{ t('hero.ctaSecondary') }}
{{ t("hero.ctaDocs") }}
</v-btn>
</div>
<a
class="hero-section__dev-note"
class="cyber-hero__terminal-note cyber-panel"
:href="devBranchUrl"
target="_blank"
rel="noopener"
>
{{ devBranchNote }}
<span class="cyber-hero__terminal-lines">
<span>&gt; {{ devBranchNote }}</span>
<span>&gt; Team ready. What shall we build today?</span>
</span>
<span v-if="releaseVersion" class="cyber-hero__release">
v{{ releaseVersion }}<template v-if="releaseDate"> - {{ releaseDate }}</template>
</span>
</a>
<!-- Release version badge -->
<div v-if="releaseVersion" class="hero-section__release-badge">
v{{ releaseVersion }}<template v-if="releaseDate"> · {{ releaseDate }}</template>
</div>
<!-- Trust indicators -->
<div class="hero-section__trust">
<div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiRobotOutline" />
<span>{{ t('hero.trust.agentTeams') }}</span>
<CyberHeroScene class="cyber-hero__scene" />
</div>
<div class="hero-section__trust-divider" />
<div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiViewDashboardOutline" />
<span>{{ t('hero.trust.kanban') }}</span>
</div>
<div class="hero-section__trust-divider" />
<div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiOpenSourceInitiative" />
<span>{{ t('hero.trust.openSource') }}</span>
</div>
</div>
</v-col>
<!-- Right: Demo video -->
<v-col cols="12" md="5" class="hero-section__demo-col">
<div class="hero-section__preview">
<div class="hero-section__preview-glow" />
<ClientOnly>
<Suspense>
<LazyHeroDemoVideo />
<template #fallback>
<div class="hero-demo-fallback" />
</template>
</Suspense>
<template #fallback>
<div class="hero-demo-fallback" />
</template>
</ClientOnly>
</div>
</v-col>
</v-row>
<CyberHeroFeatureStrip class="cyber-hero__feature-strip" />
</v-container>
</section>
</template>
<style scoped>
.hero-section {
position: relative;
min-height: 85vh;
display: flex;
align-items: center;
isolation: isolate;
}
.hero-section__video-bg {
position: absolute;
inset: -120px 0 -110px;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.hero-section__video-bg-player {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(1px) saturate(1.22) contrast(1.08);
opacity: 0.95;
mix-blend-mode: normal;
transform: scale(1.04);
}
.hero-section__video-bg-wash {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgb(var(--v-theme-background)) 0%, rgba(var(--v-theme-background), 0.82) 34%, rgba(var(--v-theme-background), 0.08) 64%, rgba(var(--v-theme-background), 0.34) 100%),
linear-gradient(180deg, rgba(var(--v-theme-background), 0.28) 0%, rgba(var(--v-theme-background), 0.52) 58%, rgb(var(--v-theme-background)) 96%);
}
.hero-section__video-bg-edge {
position: absolute;
inset: auto 0 0;
height: 42%;
background: linear-gradient(180deg, transparent, rgb(var(--v-theme-background)));
}
.v-theme--light .hero-section__video-bg-player {
mix-blend-mode: multiply;
}
.v-theme--light .hero-section__video-bg-wash {
background:
linear-gradient(90deg, rgb(var(--v-theme-background)) 0%, rgba(var(--v-theme-background), 0.86) 34%, rgba(var(--v-theme-background), 0.16) 64%, rgba(var(--v-theme-background), 0.36) 100%),
linear-gradient(180deg, rgba(var(--v-theme-background), 0.36) 0%, rgba(var(--v-theme-background), 0.54) 58%, rgb(var(--v-theme-background)) 96%);
}
.hero-section__container {
position: relative;
z-index: 2;
}
.hero-section__content {
animation: heroFadeIn 0.8s ease both;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
}
.v-theme--light .hero-section__content {
text-shadow: 0 1px 16px rgba(255, 255, 255, 0.9);
}
/* ─── Title ─── */
.hero-section__title {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.1;
margin-bottom: 20px;
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 50%, #ff00ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.2s;
display: flex;
align-items: center;
gap: 16px;
white-space: nowrap;
}
.v-theme--light .hero-section__title {
background: linear-gradient(135deg, #185f73 0%, #009fb0 48%, #6448d8 100%);
-webkit-background-clip: text;
background-clip: text;
}
.hero-section__logo {
width: 56px;
height: 56px;
border-radius: 14px;
flex-shrink: 0;
object-fit: contain;
-webkit-text-fill-color: initial;
background: none;
-webkit-background-clip: initial;
background-clip: initial;
}
/* ─── Subtitle ─── */
.hero-section__subtitle {
font-size: 1.2rem;
line-height: 1.7;
color: #aeb8d4;
opacity: 0.96;
max-width: 480px;
margin-bottom: 36px;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.3s;
}
.v-theme--light .hero-section__subtitle {
color: #34405e;
opacity: 1;
font-weight: 500;
}
/* ─── Actions ─── */
.hero-section__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.4s;
}
.hero-section__actions :deep(.v-btn) {
min-width: 0 !important;
height: 44px !important;
padding-inline: 18px !important;
font-size: 0.92rem !important;
}
.hero-section__dev-note {
display: inline-flex;
max-width: 460px;
margin-bottom: 14px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.74rem;
line-height: 1.55;
color: #00f0ff;
opacity: 0.78;
text-decoration: none;
transition:
color 0.2s ease,
opacity 0.2s ease;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.43s;
}
.hero-section__dev-note:hover {
color: #39ff14;
opacity: 1;
}
.v-theme--light .hero-section__dev-note {
color: #007c8b;
opacity: 1;
}
.v-theme--light .hero-section__dev-note:hover {
color: #0b6f32;
}
/* ─── Release badge ─── */
.hero-section__release-badge {
font-size: 0.78rem;
font-weight: 500;
color: #8892b0;
opacity: 0.7;
font-family: 'JetBrains Mono', monospace;
margin-bottom: 24px;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.45s;
}
.v-theme--light .hero-section__release-badge {
color: #34405e;
opacity: 1;
font-weight: 700;
text-shadow: 0 1px 12px rgba(255, 255, 255, 0.95);
}
.hero-section__btn-primary {
background: linear-gradient(135deg, #00f0ff, #ff00ff) !important;
color: #0a0a0f !important;
font-weight: 700 !important;
letter-spacing: 0.02em !important;
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.3) !important;
transition: all 0.3s ease !important;
}
.hero-section__btn-primary:hover {
box-shadow: 0 6px 30px rgba(0, 240, 255, 0.5) !important;
transform: translateY(-1px) !important;
}
.hero-section__btn-secondary {
border-color: rgba(0, 240, 255, 0.3) !important;
color: #00f0ff !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
}
.hero-section__btn-secondary:hover {
border-color: rgba(0, 240, 255, 0.5) !important;
background: rgba(0, 240, 255, 0.06) !important;
}
.v-theme--light .hero-section__btn-secondary {
border-color: rgba(0, 128, 144, 0.34) !important;
color: #007c8b !important;
background: rgba(255, 255, 255, 0.5) !important;
}
.v-theme--light .hero-section__btn-secondary:hover {
border-color: rgba(0, 128, 144, 0.58) !important;
color: #005c66 !important;
background: rgba(255, 255, 255, 0.72) !important;
}
.hero-section__btn-docs {
border-color: rgba(57, 255, 20, 0.38) !important;
color: #d6ffe1 !important;
font-weight: 700 !important;
letter-spacing: 0.02em !important;
background: rgba(57, 255, 20, 0.05) !important;
box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.06) !important;
transition: all 0.3s ease !important;
}
.hero-section__btn-docs:hover {
border-color: rgba(57, 255, 20, 0.62) !important;
color: #39ff14 !important;
background: rgba(57, 255, 20, 0.09) !important;
transform: translateY(-1px) !important;
}
.v-theme--light .hero-section__btn-docs {
color: #0d5f2c !important;
border-color: rgba(13, 95, 44, 0.32) !important;
background: rgba(255, 255, 255, 0.6) !important;
}
/* ─── Trust indicators ─── */
.hero-section__trust {
display: flex;
align-items: center;
gap: 16px;
animation: heroFadeIn 0.8s ease both;
animation-delay: 0.5s;
}
.hero-section__trust-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 500;
color: #8892b0;
}
.hero-section__trust-icon {
color: #00f0ff;
opacity: 0.8;
}
.hero-section__trust-divider {
width: 1px;
height: 16px;
background: rgba(0, 240, 255, 0.2);
}
.v-theme--light .hero-section__trust-item {
color: #56617c;
}
.v-theme--light .hero-section__trust-icon {
color: #008ea0;
opacity: 1;
}
.v-theme--light .hero-section__trust-divider {
background: rgba(0, 128, 144, 0.26);
}
/* ─── Preview Card ─── */
.hero-section__preview {
position: relative;
width: 100%;
animation: heroSlideUp 0.9s ease both;
animation-delay: 0.3s;
}
.hero-section__preview-glow {
position: absolute;
inset: -2px;
border-radius: 22px;
background: linear-gradient(
135deg,
rgba(0, 240, 255, 0.2),
rgba(255, 0, 255, 0.2),
rgba(57, 255, 20, 0.1)
);
filter: blur(20px);
opacity: 0.4;
z-index: 0;
animation: glowPulse 4s ease-in-out infinite;
}
@keyframes glowPulse {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.02);
}
}
/* ─── SSR Fallback ─── */
.hero-demo-fallback {
border-radius: 16px;
background: #0a0a0f;
min-height: 330px;
border: 1px solid rgba(0, 240, 255, 0.1);
}
@media (max-width: 600px) {
.hero-demo-fallback {
min-height: 280px;
}
}
/* ─── Entrance animations ─── */
@keyframes heroFadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes heroSlideUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ─── Demo column ─── */
.hero-section__demo-col {
display: flex;
}
@media (max-width: 959px) {
.hero-section__demo-col {
margin-top: 32px;
justify-content: center;
}
}
/* ─── Responsive ─── */
@media (max-width: 960px) {
.hero-section {
min-height: auto;
padding-top: 40px;
}
.hero-section__video-bg {
inset: -90px 0 -90px;
}
.hero-section__video-bg-player {
opacity: 0.82;
}
.hero-section__video-bg-wash {
background:
linear-gradient(90deg, rgb(var(--v-theme-background)) 0%, rgba(var(--v-theme-background), 0.9) 50%, rgba(var(--v-theme-background), 0.54) 100%),
linear-gradient(180deg, rgba(var(--v-theme-background), 0.42) 0%, rgba(var(--v-theme-background), 0.72) 58%, rgb(var(--v-theme-background)) 96%);
}
.hero-section__title {
font-size: 2rem;
white-space: nowrap;
}
.hero-section__logo {
width: 44px;
height: 44px;
border-radius: 12px;
}
.hero-section__subtitle {
font-size: 1.05rem;
}
.hero-section__trust {
flex-wrap: wrap;
gap: 12px;
}
.hero-section__preview {
margin-top: 40px;
}
}
@media (max-width: 600px) {
.hero-section__content {
flex: 0 0 calc(100vw - 48px);
max-width: calc(100vw - 48px);
}
.hero-section__title {
font-size: 1.6rem;
white-space: nowrap;
gap: 12px;
}
.hero-section__logo {
width: 36px;
height: 36px;
border-radius: 10px;
}
.hero-section__subtitle {
font-size: 0.95rem;
margin-bottom: 28px;
width: 100%;
max-width: 330px;
word-break: normal;
overflow-wrap: normal;
hyphens: none;
}
.hero-section__actions {
flex-direction: column;
align-items: stretch;
max-width: 320px;
margin-bottom: 12px;
}
.hero-section__actions :deep(.v-btn) {
width: 100%;
}
.hero-section__dev-note {
max-width: 320px;
margin-bottom: 20px;
font-size: 0.7rem;
}
.hero-section__trust {
gap: 10px;
}
.hero-section__trust-divider {
display: none;
}
.hero-section__trust-item {
font-size: 0.75rem;
}
}
</style>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { ref, onMounted, onUnmounted } from 'vue';
import { register } from 'swiper/element/bundle';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiArrowExpand } from '@mdi/js';
import { screenshots as screenshotData } from '~/data/screenshots';
@ -11,6 +11,16 @@ register();
const publicPath = (path: string) => `${baseURL}${path.replace(/^\//, '')}`;
type SwiperApi = {
slidePrev: () => void;
slideNext: () => void;
};
type SwiperContainerElement = HTMLElement & {
initialize: () => void;
swiper?: SwiperApi;
};
const screenshots = screenshotData.map((s) => ({
src: publicPath(s.path),
alt: s.alt,
@ -18,7 +28,7 @@ const screenshots = screenshotData.map((s) => ({
height: s.height,
}));
const swiperRef = ref<HTMLElement | null>(null);
const swiperRef = ref<SwiperContainerElement | null>(null);
const swiperReady = ref(false);
const lightboxOpen = ref(false);
const lightboxIndex = ref(0);
@ -107,7 +117,7 @@ onMounted(() => {
},
},
});
(swiperRef.value as any).initialize();
swiperRef.value.initialize();
swiperReady.value = true;
}
});
@ -120,11 +130,11 @@ onUnmounted(() => {
});
function slidePrev() {
(swiperRef.value as any)?.swiper?.slidePrev();
swiperRef.value?.swiper?.slidePrev();
}
function slideNext() {
(swiperRef.value as any)?.swiper?.slideNext();
swiperRef.value?.swiper?.slideNext();
}
</script>
@ -161,7 +171,7 @@ function slideNext() {
class="screenshots-section__img"
loading="lazy"
decoding="async"
/>
>
<div class="screenshots-section__card-overlay">
<v-icon :icon="mdiArrowExpand" size="24" />
</div>
@ -208,7 +218,7 @@ function slideNext() {
:alt="screenshots[lightboxIndex].alt"
class="screenshots-lightbox__img"
decoding="async"
/>
>
<div class="screenshots-lightbox__counter">
{{ lightboxIndex + 1 }} / {{ screenshots.length }}
</div>

View file

@ -164,12 +164,13 @@ onUnmounted(() => {
class="hero-video__player"
:class="{ 'hero-video__player--loaded': isLoaded }"
preload="auto"
poster="/screenshots/2.jpg"
muted
playsinline
@timeupdate="onTimeUpdate"
@click="togglePlay"
>
<source :src="videoSrc" type="video/mp4" />
<source :src="videoSrc" type="video/mp4">
</video>
<!-- Play overlay (when paused) -->
@ -203,17 +204,17 @@ onUnmounted(() => {
</div>
<div class="hero-video__controls-row">
<button class="hero-video__control-btn" @click.stop="togglePlay" :aria-label="isPlaying ? 'Pause' : 'Play'">
<button class="hero-video__control-btn" :aria-label="isPlaying ? 'Pause' : 'Play'" @click.stop="togglePlay">
<v-icon :icon="isPlaying ? mdiPause : mdiPlay" size="18" />
</button>
<button class="hero-video__control-btn" @click.stop="toggleMute" :aria-label="isMuted ? 'Unmute' : 'Mute'">
<button class="hero-video__control-btn" :aria-label="isMuted ? 'Unmute' : 'Mute'" @click.stop="toggleMute">
<v-icon :icon="isMuted ? mdiVolumeOff : mdiVolumeHigh" size="18" />
</button>
<div class="hero-video__spacer" />
<button class="hero-video__control-btn" @click.stop="toggleFullscreen" aria-label="Fullscreen">
<button class="hero-video__control-btn" aria-label="Fullscreen" @click.stop="toggleFullscreen">
<v-icon :icon="mdiFullscreen" size="18" />
</button>
</div>
@ -262,19 +263,46 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
border-radius: 16px;
background: rgba(10, 10, 15, 0.95);
background: rgba(6, 10, 18, 0.96);
z-index: 2;
}
.hero-video__skeleton::before,
.hero-video__skeleton::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
}
.hero-video__skeleton::before {
background:
linear-gradient(90deg, rgba(2, 6, 16, 0.18), rgba(2, 6, 16, 0.36)),
linear-gradient(180deg, rgba(0, 234, 255, 0.08), rgba(255, 43, 255, 0.08)),
url("/screenshots/2.jpg") center / cover;
opacity: 0.82;
filter: saturate(0.98) contrast(1.14) brightness(0.72);
transform: scale(1.035);
}
.hero-video__skeleton::after {
background:
linear-gradient(90deg, transparent 0 48%, rgba(0, 234, 255, 0.14) 48.2% 48.6%, transparent 48.8%),
repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 4px);
mix-blend-mode: screen;
opacity: 0.34;
}
.hero-video__skeleton-pulse {
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
rgba(0, 240, 255, 0.03) 0%,
rgba(255, 0, 255, 0.03) 50%,
rgba(0, 240, 255, 0.03) 100%
rgba(0, 240, 255, 0.12) 0%,
rgba(255, 0, 255, 0.08) 50%,
rgba(0, 240, 255, 0.1) 100%
);
mix-blend-mode: screen;
animation: skeletonPulse 2s ease-in-out infinite;
}
@ -282,25 +310,31 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
gap: 12px;
z-index: 1;
}
.hero-video__skeleton-spinner {
width: 40px;
height: 40px;
width: 58px;
height: 58px;
border-radius: 50%;
border: 2px solid rgba(0, 240, 255, 0.15);
border-top-color: rgba(0, 240, 255, 0.7);
border: 2px solid rgba(0, 240, 255, 0.28);
border-top-color: rgba(0, 240, 255, 0.92);
background: rgba(2, 8, 18, 0.56);
box-shadow:
0 0 0 1px rgba(0, 240, 255, 0.14) inset,
0 0 28px rgba(0, 240, 255, 0.34);
animation: spinnerRotate 0.8s linear infinite;
}
.hero-video__skeleton-label {
font-size: 13px;
font-weight: 600;
color: rgba(0, 240, 255, 0.6);
font-weight: 800;
color: rgba(0, 240, 255, 0.88);
font-family: "JetBrains Mono", monospace;
letter-spacing: 0.05em;
text-transform: uppercase;
text-shadow: 0 0 16px rgba(0, 240, 255, 0.42);
}
.hero-video__skeleton-bar {

View file

@ -1,13 +1,19 @@
import { computed, watch, onUnmounted } from "vue";
import type { Ref } from "vue";
import { useThemeStore } from "~/stores/theme";
type VuetifyThemeInstance = {
global: {
name: Ref<string>;
current: Ref<unknown>;
};
change?: (name: string) => void;
};
export const useBrowserTheme = () => {
const themeStore = useThemeStore();
const { $vuetifyTheme } = useNuxtApp();
const vuetifyTheme = $vuetifyTheme as {
global: { name: import("vue").Ref<string>; current: import("vue").Ref<any> };
change: (name: string) => void;
} | null;
const vuetifyTheme = $vuetifyTheme as VuetifyThemeInstance | null;
let mediaQueryHandler: ((event: MediaQueryListEvent) => void) | null = null;
let mediaQuery: MediaQueryList | null = null;
@ -26,12 +32,12 @@ export const useBrowserTheme = () => {
};
const initTheme = () => {
if (!process.client) return;
if (!import.meta.client) return;
const initialTheme = themeStore.getInitialTheme();
themeStore.setTheme(initialTheme, false);
applyVuetifyTheme(initialTheme);
if (process.client && !themeStore.userSelected) {
if (!themeStore.userSelected) {
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQueryHandler = (event: MediaQueryListEvent) => {
if (!themeStore.userSelected) {

View file

@ -0,0 +1,125 @@
import type { Ref } from "vue";
import { nextTick, onMounted, onUnmounted } from "vue";
type PointerState = {
x: number;
y: number;
};
export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
let rafId = 0;
let bounds: DOMRect | null = null;
let reduceMotion: MediaQueryList | null = null;
let canHover: MediaQueryList | null = null;
let observer: IntersectionObserver | null = null;
let isVisible = true;
const pointer: PointerState = { x: 0, y: 0 };
let scrollOffset = 0;
const shouldRun = () => {
if (reduceMotion?.matches) return false;
if (canHover && !canHover.matches) return false;
return window.innerWidth >= 768 && isVisible;
};
const writeVars = () => {
rafId = 0;
const root = rootRef.value;
if (!root) return;
if (!shouldRun()) {
root.style.setProperty("--hero-pointer-x", "0");
root.style.setProperty("--hero-pointer-y", "0");
root.style.setProperty("--hero-scroll", "0");
root.style.setProperty("--hero-tilt-x", "0");
root.style.setProperty("--hero-tilt-y", "0");
return;
}
root.style.setProperty("--hero-pointer-x", pointer.x.toFixed(4));
root.style.setProperty("--hero-pointer-y", pointer.y.toFixed(4));
root.style.setProperty("--hero-scroll", scrollOffset.toFixed(2));
root.style.setProperty("--hero-tilt-x", pointer.x.toFixed(4));
root.style.setProperty("--hero-tilt-y", pointer.y.toFixed(4));
};
const requestWrite = () => {
if (rafId) return;
rafId = requestAnimationFrame(writeVars);
};
const updateBounds = () => {
bounds = rootRef.value?.getBoundingClientRect() ?? null;
};
const onPointerMove = (event: PointerEvent) => {
if (!shouldRun()) return;
if (!bounds) updateBounds();
if (!bounds) return;
const nextX = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
const nextY = ((event.clientY - bounds.top) / bounds.height) * 2 - 1;
pointer.x = Math.max(-1, Math.min(1, nextX));
pointer.y = Math.max(-1, Math.min(1, nextY));
requestWrite();
};
const onPointerLeave = () => {
pointer.x = 0;
pointer.y = 0;
requestWrite();
};
const onScroll = () => {
const root = rootRef.value;
if (!root || !shouldRun()) return;
const rect = root.getBoundingClientRect();
scrollOffset = Math.max(-600, Math.min(600, -rect.top));
requestWrite();
};
const onResize = () => {
updateBounds();
requestWrite();
};
onMounted(async () => {
await nextTick();
const root = rootRef.value;
if (!root) return;
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
canHover = window.matchMedia("(hover: hover) and (pointer: fine)");
observer = new IntersectionObserver(
([entry]) => {
isVisible = entry.isIntersecting;
requestWrite();
},
{ threshold: 0.05 },
);
observer.observe(root);
updateBounds();
root.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerleave", onPointerLeave, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize, { passive: true });
reduceMotion.addEventListener("change", requestWrite);
canHover.addEventListener("change", requestWrite);
requestWrite();
});
onUnmounted(() => {
const root = rootRef.value;
if (rafId) cancelAnimationFrame(rafId);
observer?.disconnect();
root?.removeEventListener("pointermove", onPointerMove);
root?.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
reduceMotion?.removeEventListener("change", requestWrite);
canHover?.removeEventListener("change", requestWrite);
});
}

View file

@ -8,7 +8,7 @@ export const useLocation = () => {
const cookie = useCookie("i18n_redirected", { default: () => "" });
const getBrowserLocale = () => {
if (!process.client) return "en";
if (!import.meta.client) return "en";
const browserLocale = navigator.language || "en";
const normalized = browserLocale.split("-")[0].toLowerCase();
const supported: readonly string[] = supportedLocales.map((item) => item.code);

347
landing/data/heroScene.ts Normal file
View file

@ -0,0 +1,347 @@
import robotAmber from "~/assets/images/hero/robots/robot-amber-v1.webp";
import robotCyan from "~/assets/images/hero/robots/robot-cyan-v1.webp";
import robotMagenta from "~/assets/images/hero/robots/robot-magenta-v1.webp";
export const HERO_SCENE_VIEWBOX = {
width: 1600,
height: 900,
} as const;
export const HERO_SCENE_BREAKPOINTS = {
desktop: 1200,
tablet: 768,
} as const;
export type HeroAgentRole =
| "planner"
| "lead"
| "reviewer"
| "developer"
| "tester"
| "researcher"
| "docs"
| "ops"
| "security"
| "fixer";
export type HeroAccent = "cyan" | "magenta" | "violet" | "amber" | "red";
export type HeroCardSide = "left" | "right" | "bottom";
export type HeroAgentPosition = {
x: number;
y: number;
scale: number;
depth: number;
card: HeroCardSide;
};
export type HeroAgent = {
id: HeroAgentRole;
label: string;
asset: string;
accent: HeroAccent;
priority?: boolean;
desktop: HeroAgentPosition;
tablet: HeroAgentPosition;
mobile: {
visible: boolean;
order?: number;
compactLabel?: string;
};
status: string;
tasks: string[];
};
export type HeroConnection = {
id: string;
from: HeroAgentRole | "video";
to: HeroAgentRole | "video";
accent: Extract<HeroAccent, "cyan" | "magenta" | "amber">;
pathDesktop: string;
packetDelayMs: number;
packetDurationMs: number;
};
export type HeroMessage = {
id: string;
from: HeroAgentRole;
to: HeroAgentRole | "video";
connectionId: string;
text: string;
response: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
};
export const heroAgents: readonly HeroAgent[] = [
{
id: "planner",
label: "Planner",
asset: robotCyan,
accent: "cyan",
priority: true,
desktop: { x: 34, y: 12, scale: 0.66, depth: 0.35, card: "right" },
tablet: { x: 18, y: 11, scale: 0.55, depth: 0.22, card: "bottom" },
mobile: { visible: true, order: 1, compactLabel: "Plan" },
status: "Planning",
tasks: ["Analyze requirements", "Break down tasks", "Create plan"],
},
{
id: "lead",
label: "Lead",
asset: robotCyan,
accent: "cyan",
priority: true,
desktop: { x: 55, y: 9, scale: 0.62, depth: 0.32, card: "right" },
tablet: { x: 50, y: 8, scale: 0.52, depth: 0.2, card: "bottom" },
mobile: { visible: true, order: 2, compactLabel: "Lead" },
status: "Leading",
tasks: ["Define architecture", "Set priorities", "Coordinate team"],
},
{
id: "reviewer",
label: "Reviewer",
asset: robotMagenta,
accent: "magenta",
priority: true,
desktop: { x: 75, y: 13, scale: 0.58, depth: 0.34, card: "left" },
tablet: { x: 82, y: 12, scale: 0.48, depth: 0.22, card: "bottom" },
mobile: { visible: true, order: 3, compactLabel: "Review" },
status: "Reviewing",
tasks: ["Review code", "Check quality", "Request changes"],
},
{
id: "researcher",
label: "Researcher",
asset: robotCyan,
accent: "violet",
desktop: { x: 27, y: 39, scale: 0.48, depth: 0.45, card: "right" },
tablet: { x: 16, y: 45, scale: 0.44, depth: 0.25, card: "bottom" },
mobile: { visible: false },
status: "Researching",
tasks: ["Research options", "Compare solutions", "Summarize findings"],
},
{
id: "developer",
label: "Developer",
asset: robotCyan,
accent: "cyan",
desktop: { x: 74, y: 34, scale: 0.5, depth: 0.52, card: "left" },
tablet: { x: 88, y: 44, scale: 0.42, depth: 0.26, card: "bottom" },
mobile: { visible: false },
status: "Coding",
tasks: ["Write code", "Implement feature", "Commit changes"],
},
{
id: "tester",
label: "Tester",
asset: robotMagenta,
accent: "magenta",
desktop: { x: 72, y: 59, scale: 0.48, depth: 0.58, card: "left" },
tablet: { x: 76, y: 77, scale: 0.4, depth: 0.28, card: "bottom" },
mobile: { visible: false },
status: "Testing",
tasks: ["Write tests", "Run tests", "Report issues"],
},
{
id: "docs",
label: "Docs",
asset: robotMagenta,
accent: "violet",
desktop: { x: 30, y: 64, scale: 0.43, depth: 0.55, card: "right" },
tablet: { x: 25, y: 78, scale: 0.36, depth: 0.28, card: "bottom" },
mobile: { visible: false },
status: "Writing",
tasks: ["Write docs", "API reference", "Examples"],
},
{
id: "ops",
label: "Ops",
asset: robotAmber,
accent: "amber",
desktop: { x: 43, y: 84, scale: 0.46, depth: 0.7, card: "right" },
tablet: { x: 42, y: 83, scale: 0.38, depth: 0.34, card: "bottom" },
mobile: { visible: false },
status: "Deploying",
tasks: ["Deploy services", "Monitor health", "Manage infra"],
},
{
id: "security",
label: "Security",
asset: robotAmber,
accent: "red",
desktop: { x: 63, y: 85, scale: 0.42, depth: 0.68, card: "right" },
tablet: { x: 60, y: 82, scale: 0.34, depth: 0.32, card: "bottom" },
mobile: { visible: false },
status: "Secure",
tasks: ["Scan dependencies", "Check permissions", "Security review"],
},
{
id: "fixer",
label: "Fixer",
asset: robotAmber,
accent: "amber",
desktop: { x: 69, y: 83, scale: 0.42, depth: 0.72, card: "left" },
tablet: { x: 90, y: 82, scale: 0.36, depth: 0.34, card: "bottom" },
mobile: { visible: false },
status: "Fixing",
tasks: ["Fix issues", "Refactor code", "Optimize"],
},
] as const;
export const heroConnections: readonly HeroConnection[] = [
{
id: "planner-lead",
from: "planner",
to: "lead",
accent: "cyan",
pathDesktop: "M 545 195 C 680 210, 735 185, 860 190",
packetDelayMs: 0,
packetDurationMs: 4200,
},
{
id: "lead-reviewer",
from: "lead",
to: "reviewer",
accent: "magenta",
pathDesktop: "M 950 205 C 1050 185, 1130 190, 1265 220",
packetDelayMs: 700,
packetDurationMs: 3900,
},
{
id: "developer-reviewer",
from: "developer",
to: "reviewer",
accent: "magenta",
pathDesktop: "M 1390 370 C 1325 320, 1305 270, 1260 230",
packetDelayMs: 500,
packetDurationMs: 3400,
},
{
id: "researcher-video",
from: "researcher",
to: "video",
accent: "cyan",
pathDesktop: "M 520 425 C 625 410, 680 405, 755 420",
packetDelayMs: 1100,
packetDurationMs: 4400,
},
{
id: "video-tester",
from: "video",
to: "tester",
accent: "magenta",
pathDesktop: "M 1290 540 C 1365 555, 1410 575, 1480 615",
packetDelayMs: 1300,
packetDurationMs: 4100,
},
{
id: "tester-lead",
from: "tester",
to: "lead",
accent: "cyan",
pathDesktop: "M 1450 625 C 1365 650, 1170 642, 1030 630 C 940 620, 880 585, 850 515",
packetDelayMs: 1800,
packetDurationMs: 5200,
},
{
id: "ops-security",
from: "ops",
to: "security",
accent: "amber",
pathDesktop: "M 745 740 C 835 725, 910 725, 1000 742",
packetDelayMs: 2200,
packetDurationMs: 4600,
},
{
id: "security-fixer",
from: "security",
to: "fixer",
accent: "amber",
pathDesktop: "M 1100 745 C 1185 725, 1270 730, 1375 755",
packetDelayMs: 2600,
packetDurationMs: 4600,
},
] as const;
export const heroMessages: readonly HeroMessage[] = [
{
id: "code-review",
from: "developer",
to: "reviewer",
connectionId: "developer-reviewer",
text: "Code ready. Request review.",
response: "Review started.",
fromX: 78,
fromY: 43,
toX: 73,
toY: 20,
},
{
id: "tests-passed",
from: "tester",
to: "lead",
connectionId: "tester-lead",
text: "Tests passed. Looks good.",
response: "Ship it.",
fromX: 78,
fromY: 62,
toX: 58,
toY: 21,
},
{
id: "research-ready",
from: "researcher",
to: "video",
connectionId: "researcher-video",
text: "Findings ready.",
response: "Plan updated.",
fromX: 32,
fromY: 45,
toX: 50,
toY: 53,
},
{
id: "ops-secure",
from: "ops",
to: "security",
connectionId: "ops-security",
text: "Deployed to staging.",
response: "Dependencies checked.",
fromX: 44,
fromY: 72,
toX: 62,
toY: 74,
},
] as const;
export const heroFeatureRail = [
{
id: "autonomous",
title: "Autonomous Team",
text: "Specialized agents coordinate work together.",
},
{
id: "kanban",
title: "Kanban at Lightspeed",
text: "Tasks move as agents build, review, and test.",
},
{
id: "developers",
title: "Built for Developers",
text: "Open source, extensible, and API-first.",
},
{
id: "secure",
title: "Secure by Default",
text: "Your code and data stay protected.",
},
{
id: "local",
title: "Local First",
text: "Runs on your machine. Your data stays yours.",
},
] as const;

View file

@ -0,0 +1,64 @@
# Cyberpunk Robots Hero Reference
Date: 2026-05-15
Primary visual reference:
- PNG: `landing/assets/images/references/cyberpunk-robots-hero-reference-2026-05-15.png`
- WebP: `landing/assets/images/references/cyberpunk-robots-hero-reference-2026-05-15.webp`
## Locked Direction
- Style: black cyberpunk HUD, cyan and magenta neon, angular frames, rainy city depth, wet reflections, subtle scanlines.
- Hero slogan: `YOU'RE THE CTO, AGENTS ARE YOUR TEAM.`
- Remove the top-left status metrics block from the reference. No `Agents Online`, `Tasks Running`, `Reviews`, `12/12`, or similar hero status strip.
- The large center-right framed block is a video/demo frame, not a static dashboard.
- Robots must make the product concept obvious: many autonomous AI agents coordinate, message, review, build, test, document, and deploy together.
- Target robot roles: Planner, Lead, Reviewer, Developer, Tester, Researcher, Docs, Ops, Security, Fixer.
- Keep the page usable as a real landing page: readable nav, clear CTA buttons, responsive hierarchy, and a visible next-section hint.
## Implementation Shape
- Use WebP assets for the city/background atmosphere and robot art.
- Render HUD frames, buttons, feature strip, text, video frame, neon glows, and connector lines in Vue/CSS/SVG.
- Use the existing demo video inside the central neon frame.
- Keep robots as separate positioned layers so they can animate independently.
- Add `prefers-reduced-motion` support and reduce all movement to static glow states when requested.
## Parallax Plan
- Background city layer: slow pointer movement and slow scroll offset.
- Mid HUD/video layer: medium movement with slight perspective tilt.
- Robot layer: stronger pointer response, small independent idle movement.
- Foreground neon connector layer: tiny offset plus pulsing path strokes.
- Feature strip: minimal movement so it stays stable and readable.
## Robot Life Animations
- Idle bob: 3-6px vertical movement with staggered durations.
- Eye blink: short opacity/scale pulse on robot face screens.
- Screen pulse: soft cyan/magenta glow on tablets and role cards.
- Micro turn: small rotate/translate on hover or when a message passes nearby.
- Status glow: role cards pulse based on activity state.
- Optional layered assets: separate face/screen/arm layers only for the 2-3 most visible robots.
## Message Passing Options
1. SVG packet travel along connector paths - 🎯 9 🛡️ 9 🧠 6 - about 120-220 lines.
Best default. Draw stable SVG paths between robots and video frame, animate small glowing packets with `offset-path` or SVG `animateMotion`. Looks like real coordination and stays responsive.
2. Chat bubble handoff between robots - 🎯 8 🛡️ 8 🧠 5 - about 90-160 lines.
Short messages appear near one robot, fade, then appear near the receiver. Easier and very readable, but less visually premium if overused.
3. Data shard relay through hub nodes - 🎯 8 🛡️ 7 🧠 7 - about 180-300 lines.
Small neon diamonds jump between intermediate nodes around the video frame. More cyberpunk, but more tuning needed to avoid clutter.
Default choice: combine option 1 for constant background coordination and option 2 for occasional readable moments like `Code ready`, `Review requested`, `Tests passed`.
## Performance Guardrails
- Prefer CSS transforms and opacity for animation.
- Keep animated SVG path count limited on mobile.
- Use compressed WebP for hero background and robots.
- Lazy-load lower hero/detail assets when possible.
- Avoid canvas unless SVG/CSS becomes too expensive.

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,7 @@ const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
export default defineNuxtConfig({
compatibilityDate: "2026-01-19",
devtools: { enabled: false },
ssr: true,
app: {
baseURL,

View file

@ -38,24 +38,24 @@ const rootGuide: DefaultTheme.SidebarItem[] = [
{
text: "Start",
items: [
{ text: "Installation", link: "/guide/installation" },
{ text: "Quickstart", link: "/guide/quickstart" },
{ text: "Installation", link: "/guide/installation" }
{ text: "Runtime setup", link: "/guide/runtime-setup" }
]
},
{
text: "Workflows",
items: [
{ text: "Runtime setup", link: "/guide/runtime-setup" },
{ text: "Agent workflow", link: "/guide/agent-workflow" },
{ text: "MCP integration", link: "/guide/mcp-integration" },
{ text: "Code review", link: "/guide/code-review" }
]
},
{
text: "Team Management",
text: "Guide",
items: [
{ text: "Create a team", link: "/guide/create-team" },
{ text: "Team brief examples", link: "/guide/team-brief-examples" },
{ text: "Agent workflow", link: "/guide/agent-workflow" },
{ text: "Code review", link: "/guide/code-review" },
{ text: "MCP integration", link: "/guide/mcp-integration" },
{ text: "Team brief examples", link: "/guide/team-brief-examples" }
]
},
{
text: "Operations",
items: [
{ text: "Git and worktree strategy", link: "/guide/git-worktree-strategy" },
{ text: "Troubleshooting", link: "/guide/troubleshooting" }
]
@ -81,25 +81,25 @@ const ruGuide: DefaultTheme.SidebarItem[] = [
{
text: "Старт",
items: [
{ text: "Установка", link: "/ru/guide/installation" },
{ text: "Быстрый старт", link: "/ru/guide/quickstart" },
{ text: "Установка", link: "/ru/guide/installation" }
{ text: "Настройка рантайма", link: "/ru/guide/runtime-setup" }
]
},
{
text: "Рабочие процессы",
items: [
{ text: "Настройка рантайма", link: "/ru/guide/runtime-setup" },
{ text: "Работа агентов", link: "/ru/guide/agent-workflow" },
{ text: "MCP integration", link: "/ru/guide/mcp-integration" },
{ text: "Код-ревью", link: "/ru/guide/code-review" }
]
},
{
text: "Управление командами",
text: "Руководство",
items: [
{ text: "Создание команды", link: "/ru/guide/create-team" },
{ text: "Team brief examples", link: "/ru/guide/team-brief-examples" },
{ text: "Git and worktree strategy", link: "/ru/guide/git-worktree-strategy" },
{ text: "Работа агентов", link: "/ru/guide/agent-workflow" },
{ text: "Код-ревью", link: "/ru/guide/code-review" },
{ text: "Интеграция MCP", link: "/ru/guide/mcp-integration" },
{ text: "Примеры брифов", link: "/ru/guide/team-brief-examples" }
]
},
{
text: "Операции",
items: [
{ text: "Стратегия Git и worktree", link: "/ru/guide/git-worktree-strategy" },
{ text: "Диагностика", link: "/ru/guide/troubleshooting" }
]
},
@ -154,6 +154,7 @@ export default defineConfig({
description: SITE_DESCRIPTION,
base,
cleanUrls: true,
ignoreDeadLinks: [/\/download/],
lastUpdated: true,
sitemap: {
hostname: docsUrl,

View file

@ -12,6 +12,7 @@ Use this page when you want to change Agent Teams itself, debug a team launch, o
| Need | Go to |
| --- | --- |
| Repo overview, scripts, and source setup | [README.md](https://github.com/777genius/agent-teams-ai/blob/main/README.md) |
| Agent navigation and architecture index | [AGENTS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENTS.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) |

View file

@ -95,6 +95,18 @@ Task-specific logs isolate runtime output, actions, and messages for one assignm
- Did it ask another teammate for help?
- Which task produced this diff?
### Validation checklist
When a task looks stuck or its diff looks detached, verify the lifecycle in this order:
1. The task has the expected owner and moved to `in_progress`.
2. The owner posted a task comment with the plan or first progress update.
3. Task logs show runtime activity inside the task window.
4. File changes are linked to the same task, owner, and session.
5. The final task comment includes the verification command and result.
For deeper debugging, use the persisted evidence commands in [Troubleshooting](/guide/troubleshooting#task-log-triage). The UI is the working surface, but persisted task files, inboxes, and runtime evidence are the source for hard launch or attribution bugs.
## Parallel work patterns
Teammates can work on independent tasks at the same time. You can also create dependency links (`blocked-by`) so that one task waits until another is complete. Watch the board for blocked lanes and reassign owners if one teammate is idle while another is overloaded.

View file

@ -7,6 +7,14 @@ description: Download and install Agent Teams for macOS, Windows, or Linux. Cove
Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
::: tip Shortest path
1. Download the build for your platform below
2. Launch the app — it detects runtimes and guides provider auth from the UI
3. Start the [quickstart](/guide/quickstart) to create your first team
Desktop app startup: run `pnpm dev` for the Electron app. Do not start the browser/web dev mode for normal use.
:::
## Download builds
Use the <a href="/download/" target="_self">download page</a> or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app:
@ -55,8 +63,30 @@ pnpm install
pnpm dev
```
`pnpm dev` starts the desktop Electron app with hot reload. This is the default development target — do not start a browser web dev server for normal development. The browser path lacks the full desktop IPC, terminal, provider auth, and team lifecycle behavior.
The `main` branch carries the latest stable development. Switch to feature branches only if you need a specific unreleased change.
## Verify the setup
After installing, confirm the build is healthy:
```bash
# Check that the desktop app compiles and starts
pnpm typecheck
# Verify the VitePress documentation site builds
pnpm --dir landing docs:build
```
If `pnpm typecheck` reports type errors, check for a newer version of dependencies or pinned TypeScript. If `pnpm --dir landing docs:build` fails, inspect `landing/product-docs/` for syntax errors in markdown or config.
If you are editing these docs, run the build to verify your changes:
```bash
pnpm --dir landing docs:build
```
## Auto-updates
The packaged app checks for updates automatically on launch and periodically while running. When an update is available, the app prompts you to download and install it. You can also check manually from the app menu.
@ -74,8 +104,23 @@ git pull
pnpm install
```
After updating, verify the build and docs:
```bash
pnpm typecheck
pnpm --dir landing docs:build
```
Always use `pnpm dev` (Electron) — not the browser dev server — for normal development.
## Next steps
- [Quickstart](/guide/quickstart) — from install to first running team
- [Runtime setup](/guide/runtime-setup) — provider auth and model selection per runtime
- [Create a team](/guide/create-team) — recommended team shapes and brief writing
### For contributors
- [AGENTS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENTS.md) — repo navigation and architecture pointers
- [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) — working conventions and project rules
- [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) — hard implementation guardrails

View file

@ -7,17 +7,58 @@ description: Get from a fresh install to a running AI agent team in a few minute
This guide gets you from a fresh install to a running team in a few minutes.
## 1. Install Agent Teams
## Shortest path
Download the latest release for your platform from the <a href="/download/" target="_self">download page</a> or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
```bash
# 1. Install prerequisites
node --version # need 20+
pnpm --version # need 10+
::: tip
The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details.
:::
# 2. Clone and install
git clone https://github.com/777genius/agent-teams-ai.git
cd agent-teams-ai
pnpm install
::: 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.
:::
# 3. Start the desktop app (default workflow)
pnpm dev
# 4. Verify a docs-only change
pnpm --dir landing docs:build
```
The desktop Electron app (`pnpm dev`) is the primary target — do not use the browser/web dev server for normal development. The browser path lacks desktop IPC, terminal, provider auth, and team lifecycle behavior.
## Before you begin
You need:
- **A computer** running macOS, Windows, or Linux
- **(Recommended) A Git-tracked project** — worktree isolation and diff review rely on Git
- **(Optional) Provider access** — runtime setup detects available providers from the UI, but some paths need existing auth (Anthropic, OpenAI, etc.)
If a step below does not work, check the [troubleshooting guide](/guide/troubleshooting#team-does-not-launch) for common fixes.
For project conventions and architecture guidance, refer to these canonical files before making changes:
- [AGENTS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENTS.md) — repo navigation and architecture pointers
- [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) — working conventions and project rules
- [Feature architecture standard](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) — structure for new features
- [Debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) — launch and teammate diagnostics
## 1. Run from source or download
**Download the packaged app** for macOS, Windows, or Linux from the <a href="/download/" target="_self">download page</a> — no prerequisites needed. The app guides runtime detection and provider authentication from the UI.
**Or run from source** for development:
```bash
git clone https://github.com/777genius/agent-teams-ai.git
cd agent-teams-ai
pnpm install
pnpm dev
```
`pnpm dev` starts the desktop Electron app with hot reload. This is the default development target. Do not start a browser web dev server for normal development — the browser path lacks the full desktop IPC, terminal, provider auth, and team lifecycle behavior.
## 2. Open or create a project
@ -51,15 +92,20 @@ Gemini is available as a supported provider path. See [Providers and runtimes](/
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
To verify the selected runtime outside the app, run its version command:
To verify the selected runtime outside the app, check the binary and test auth:
```bash
claude --version
codex --version
opencode --version
# Check that the runtime is installed and on PATH
command -v claude && claude --version
command -v codex && codex --version
command -v opencode && opencode --version
```
If the command fails in your terminal, fix the runtime installation or `PATH` first. Team prompts cannot work around a missing binary or missing provider auth.
If the command fails, fix the runtime installation or `PATH` first. Team prompts cannot work around a missing binary or missing provider auth.
::: tip
If the binary is found but the app reports "not logged in", the environment may differ between your terminal and the app. See the [auth diagnostic log](/guide/troubleshooting#auth-diagnostic-log) to compare them.
:::
## 4. Create your first team
@ -93,6 +139,10 @@ Improve the docs quickstart. Keep edits inside landing/product-docs. Add practic
Avoid vague prompts such as "make the app better" for the first run. The lead can break down large goals, but better input produces smaller tasks and cleaner review.
::: tip
If the team launches but no tasks appear, check whether the lead received your prompt. See [agent replies are missing](/guide/troubleshooting#agent-replies-are-missing) for diagnostics.
:::
The lead creates tasks, assigns work, and coordinates teammates. You can watch progress on the kanban board and intervene with comments or direct messages at any time.
## 6. Review results
@ -107,8 +157,34 @@ Before approving the first task, check three things:
2. The changed files match the task scope
3. The verification result is visible in the task comment or logs
## Common pitfalls
| Symptom | Likely cause | Check |
| --- | --- | --- |
| App does not detect a runtime | Binary not on `PATH`, or app and terminal see different environments | Run `command -v <runtime>` in a terminal, then use the same terminal env to launch the app |
| Team launch hangs | Missing provider auth, wrong model string, or runtime binary not found | See [Troubleshooting](/guide/troubleshooting#team-does-not-launch) |
| OpenCode lane stuck on `registered` | Lane evidence not committed yet, or model string mismatch | Inspect `~/.claude/teams/<team>/.opencode-runtime/lanes/` |
| Agent replies missing | Runtime delivery retry, parsing, or task attribution issue | Open task logs and check the delivery ledger |
| Provider returns 429s | Rate limit reached | Wait for reset or switch model/provider |
## Next steps
- [Create a team](/guide/create-team) — recommended team shapes and brief writing
- [Runtime setup](/guide/runtime-setup) — provider auth and model selection
- [Code review](/guide/code-review) — review, approve, or request changes
### For contributors
If you are modifying Agent Teams or these docs, start with the canonical project files at the repo root:
- [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) — working conventions and project rules
- [AGENTS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENTS.md) — navigation layer for architecture and implementation guidance
- [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) — hard implementation guardrails
- [Feature architecture standard](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) — structure for new features
- [Agent team debugging runbook](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) — launch, bootstrap, and teammate diagnostics
To verify this documentation site builds correctly:
```bash
pnpm --dir landing docs:build
```

View file

@ -7,6 +7,27 @@ description: Fix team launch issues, missing agent replies, rate limits, CLI aut
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, and provider limits.
## Quick evidence setup
For any team lifecycle issue, define these variables first and reuse the same shell:
```bash
TEAM="<team-name>"
TEAM_DIR="$HOME/.claude/teams/$TEAM"
TASKS_DIR="$HOME/.claude/tasks/$TEAM"
```
Then confirm the expected files exist before interpreting UI state:
```bash
test -d "$TEAM_DIR" && find "$TEAM_DIR" -maxdepth 2 -type f | sort | sed -n '1,80p'
test -d "$TASKS_DIR" && find "$TASKS_DIR" -maxdepth 1 -name '*.json' | sort | sed -n '1,40p'
```
::: warning Evidence first
Do not fix prompts, provider settings, or process cleanup based only on a stuck badge. First correlate the UI with persisted files, launch artifacts, and runtime evidence.
:::
## Team does not launch
Check each item in order:
@ -30,10 +51,12 @@ Contributor/debugging details live in [Contributor Architecture](/reference/cont
Look at the newest launch failure artifact:
```bash
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
LATEST_FAILURE="$TEAM_DIR/launch-failure-artifacts/latest.json"
MANIFEST_PATH="$(jq -r '.manifestPath' "$LATEST_FAILURE")"
jq '.classification, .bootstrapTransportBreadcrumb, .memberSpawnStatuses' "$MANIFEST_PATH"
```
The manifest inside includes:
`latest.json` points to the newest packed artifact directory and its `manifest.json`. The manifest includes:
- `classification` — why the launch was considered a failure
- `bootstrapTransportBreadcrumb` — delivery path used
@ -43,8 +66,8 @@ The manifest inside includes:
Also check the lane manifest:
```bash
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json
jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane>/manifest.json
jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null
find "$TEAM_DIR/.opencode-runtime/lanes" -maxdepth 2 -name manifest.json -print -exec jq '.activeRunId, .entries' {} \; 2>/dev/null
```
::: tip Do not guess from the UI
@ -58,7 +81,7 @@ Start with persisted files on disk rather than the UI alone.
### Team root
```bash
~/.claude/teams/<team>/
printf '%s\n' "$TEAM_DIR"
```
Key files and what they tell you:
@ -70,8 +93,8 @@ Key files and what they tell you:
- `inboxes/*.json` and `sentMessages.json` — message delivery state
```bash
jq '.teamLaunchState, .summary, .members' ~/.claude/teams/<team>/launch-state.json
tail -80 ~/.claude/teams/<team>/bootstrap-journal.jsonl 2>/dev/null
jq '.teamLaunchState, .summary, .members' "$TEAM_DIR/launch-state.json"
tail -80 "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
```
### OpenCode runtime evidence
@ -85,8 +108,8 @@ For OpenCode teammates, session proof is in the lane runtime store:
Expected healthy state: lane state `active`, manifest has `activeRunId` with at least one evidence entry, member has `bootstrapConfirmed: true`.
```bash
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json 2>/dev/null
find ~/.claude/teams/<team>/.opencode-runtime -maxdepth 3 -type f | sort
jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null
find "$TEAM_DIR/.opencode-runtime" -maxdepth 3 -type f | sort
```
### Launch failure artifacts
@ -94,7 +117,9 @@ find ~/.claude/teams/<team>/.opencode-runtime -maxdepth 3 -type f | sort
When a launch is marked as a failure, inspect `latest.json`:
```bash
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
LATEST_FAILURE="$TEAM_DIR/launch-failure-artifacts/latest.json"
jq '.' "$LATEST_FAILURE"
jq '.' "$(jq -r '.manifestPath' "$LATEST_FAILURE")"
```
The manifest includes:
@ -114,6 +139,15 @@ Open task logs and teammate messages. Missing replies often come from:
Do not assume the model ignored the message until logs confirm it.
:::
Use the persisted message state to separate "not sent" from "sent but not rendered":
```bash
jq '.' "$TEAM_DIR/inboxes/user.json" 2>/dev/null
jq '.' "$TEAM_DIR/sentMessages.json" 2>/dev/null
```
Check `from`, `to`, `messageId`, `relayOfMessageId`, and `taskRefs`. For OpenCode teammates, also inspect runtime delivery evidence before assuming the model ignored the prompt.
## Tasks are not linked to changes
Use task-specific logs and code review links. If a diff appears detached:
@ -124,6 +158,26 @@ Use task-specific logs and code review links. If a diff appears detached:
For OpenCode teammates, the authoritative proof that a session belongs to a task is in `opencode-sessions.json` and the lane manifest entry, not only the UI message stream.
### Task log triage
When a task log looks incomplete, search by task id across task JSON, inboxes, and bootstrap events:
```bash
TASK="<short-or-full-task-id>"
rg -n "$TASK" "$TASKS_DIR" "$TEAM_DIR/inboxes" "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
```
Interpret the result carefully:
| Evidence | What it proves | What it does not prove |
| --- | --- | --- |
| Message delivered | The app wrote or relayed a prompt | The agent made progress |
| Task comment | The agent posted board-visible text | The comment is meaningful progress |
| Native tool rows | The runtime did work in a session | The work belongs to this task unless attribution matches |
| Change ledger entry | The app recorded file changes | The implementation is correct |
For OpenCode, a healthy task log usually includes native runtime rows like `read`, `bash`, `edit`, or `write` plus Agent Teams MCP rows. If you only see `agent-teams_*` rows, confirm task attribution and session bounds before widening log matching.
## Rate limits
If a provider reports a known reset time, Agent Teams can nudge the lead to continue after cooldown. If reset time is unknown, wait or switch provider/runtime path.
@ -190,6 +244,45 @@ CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
Use this to inspect interactive CLI behavior. Do not consider this fully equivalent to the process backend.
## Smoke checks
Use the desktop Electron app for normal validation. Browser/web dev mode does not include the full desktop runtime, IPC, provider auth, terminal, or team lifecycle behavior.
### Docs-only changes
From the repo root:
```bash
pnpm --dir landing docs:build
git diff --check -- landing/product-docs
```
### Team lifecycle changes
Start narrow, then expand:
```bash
pnpm test -- test/main/services/team/TeamProvisioningService.test.ts
pnpm test -- test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts
pnpm typecheck
git diff --check
```
### Live team smoke
Use a small team and a Git-tracked disposable project:
1. Start the desktop app with `pnpm dev`.
2. Create one lead plus one builder.
3. Ask for a tiny change with an explicit verification command.
4. Confirm the task moves `pending` -> `in_progress` -> `completed`.
5. Open task logs and verify tool rows, task comments, and file changes line up.
6. Stop only the smoke-owned team/processes when cleaning up.
::: warning Narrow cleanup only
Do not kill all OpenCode hosts, unrelated tmux panes, or user teams while cleaning up a smoke run.
:::
## Safe cleanup
When cleaning up stale processes:

View file

@ -56,6 +56,8 @@ pnpm install
pnpm dev
```
`pnpm dev` запускает desktop Electron-приложение с hot reload. Это основной режим разработки — не используйте браузерный dev-сервер. Браузерный режим не имеет полного desktop IPC, терминала, provider auth и lifecycle команд.
Ветка `main` содержит актуальную стабильную разработку. Переключайтесь на feature-ветки, только если нужна конкретная неопубликованная правка.
## Автообновления
@ -68,7 +70,7 @@ pnpm dev
## Обновление из исходников
Подтяните ветку `main` и повторите install, если поменялись зависимости:
Подтяните ветку `main` и повторите install, если поменялись зависимости. Всегда используйте `pnpm dev` (Electron) — не браузерный dev-сервер — для обычной разработки:
```bash
git pull

View file

@ -8,6 +8,17 @@ lang: ru-RU
Этот гайд проводит от свежей установки до первой запущенной команды за несколько минут.
## Предварительные требования
Перед началом убедитесь, что у вас есть:
- **macOS, Windows или Linux** машина
- **Git-репозиторий** в качестве проекта (рекомендуется для diff review и worktree isolation)
- Доступ хотя бы к одному провайдеру: Anthropic (Claude), OpenAI (Codex), OpenRouter (OpenCode) или Google (Gemini)
- Node.js 20+ и pnpm 10+ при запуске из исходников
Подробности и ссылки для скачивания — в разделе [Установка](/ru/guide/installation).
## 1. Установите Agent Teams
Скачайте последний релиз под вашу платформу на <a href="/ru/download/" target="_self">странице загрузок</a> или в [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
@ -62,6 +73,16 @@ opencode --version
Если команда падает в терминале, сначала исправьте runtime installation или `PATH`. Team prompts не смогут компенсировать отсутствующий binary или provider auth.
Также можно проверить, что бинарник доступен в `PATH`:
```bash
command -v claude
command -v codex
command -v opencode
```
Если `command -v` ничего не выводит, рантайм не установлен или отсутствует в `PATH`.
## 4. Создайте первую команду
Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума.
@ -108,8 +129,26 @@ Lead создаёт задачи, назначает работу и коорд
2. Изменённые файлы совпадают со scope задачи
3. Verification result виден в task comment или logs
## Частые проблемы
| Симптом | Вероятная причина | Что проверить |
| --- | --- | --- |
| Приложение не видит runtime | Бинарник не в `PATH` или разные окружения у приложения и терминала | Запустите `command -v <runtime>` в терминале |
| Запуск команды зависает | Нет provider auth, неверная модель или runtime не найден | Раздел [Диагностика](/ru/guide/troubleshooting#team-does-not-launch) |
| OpenCode lane в статусе `registered` | Lane evidence ещё не зафиксирован или несовпадение модели | Проверьте `~/.claude/teams/<team>/.opencode-runtime/lanes/` |
| Ответы агента не приходят | Runtime delivery retry, parsing или task attribution | Откройте task logs и проверьте delivery ledger |
| Провайдер возвращает 429 | Достигнут лимит запросов | Дождитесь сброса или смените модель/провайдера |
## Дальше
- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief
- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
- [Код-ревью](/ru/guide/code-review) — ревью, одобрение и запрос правок
### Для разработчиков
- [AGENTS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENTS.md) — навигация по репозиторию
- [CLAUDE.md](https://github.com/777genius/agent-teams-ai/blob/main/CLAUDE.md) — рабочие конвенции
- [AGENT_CRITICAL_GUARDRAILS.md](https://github.com/777genius/agent-teams-ai/blob/main/AGENT_CRITICAL_GUARDRAILS.md) — жёсткие правила
- [Feature architecture standard](https://github.com/777genius/agent-teams-ai/blob/main/docs/FEATURE_ARCHITECTURE_STANDARD.md) — структура фич
- [Runbook отладки](https://github.com/777genius/agent-teams-ai/blob/main/docs/team-management/debugging-agent-teams.md) — диагностика запуска

View file

@ -23,7 +23,7 @@ export const useDownloadStore = defineStore("download", {
},
actions: {
init() {
if (!process.client) return;
if (!import.meta.client) return;
const ua = navigator.userAgent;
const os = detectPlatform(ua);
this.os = os === "unknown" ? "unknown" : os;

View file

@ -9,7 +9,7 @@ export const useThemeStore = defineStore("theme", {
}),
actions: {
getInitialTheme(): ThemeName {
if (!process.client) return "dark";
if (!import.meta.client) return "dark";
const saved = localStorage.getItem("theme");
if (saved === "dark" || saved === "light") {
this.userSelected = true;
@ -22,7 +22,7 @@ export const useThemeStore = defineStore("theme", {
},
setTheme(theme: ThemeName, fromUser: boolean) {
this.current = theme;
if (process.client && fromUser) {
if (import.meta.client && fromUser) {
this.userSelected = true;
localStorage.setItem("theme", theme);
}

View file

@ -32,12 +32,19 @@
"smoke:codex-runtime-install": "tsx scripts/smoke/codex-runtime-install.ts",
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
"dist": "electron-builder --mac --win --linux",
"dist:mac": "electron-builder --mac",
"dist:mac:arm64": "electron-builder --mac --arm64",
"dist:mac:x64": "electron-builder --mac --x64",
"dist:win": "electron-builder --win",
"dist:linux": "electron-builder --linux",
"stage-runtime": "node ./scripts/stage-runtime.mjs",
"clean:runtime": "node ./scripts/stage-runtime.mjs --clean",
"pack:mac": "electron-builder --mac",
"pack:mac:arm64": "electron-builder --mac --arm64",
"pack:mac:x64": "electron-builder --mac --x64",
"pack:win": "electron-builder --win",
"pack:linux": "electron-builder --linux",
"dist": "pnpm build && node ./scripts/stage-runtime.mjs && electron-builder",
"dist:mac": "pnpm build && node ./scripts/stage-runtime.mjs && electron-builder --mac",
"dist:mac:arm64": "pnpm build && node ./scripts/stage-runtime.mjs --platform darwin-arm64 && electron-builder --mac --arm64",
"dist:mac:x64": "pnpm build && node ./scripts/stage-runtime.mjs --platform darwin-x64 && electron-builder --mac --x64",
"dist:win": "pnpm build && node ./scripts/stage-runtime.mjs --platform win32-x64 && electron-builder --win",
"dist:linux": "pnpm build && node ./scripts/stage-runtime.mjs --platform linux-x64 && electron-builder --linux",
"smoke:packaged": "node ./scripts/electron-builder/smokePackagedApp.cjs",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",

248
scripts/stage-runtime.mjs Normal file
View file

@ -0,0 +1,248 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const runtimeLockPath = path.join(repoRoot, 'runtime.lock.json');
const runtimeDir = path.join(repoRoot, 'resources', 'runtime');
const downloadRoot = path.join(repoRoot, '.runtime-download');
function printUsage() {
process.stdout.write(`Usage: node scripts/stage-runtime.mjs [options]
Options:
--platform <key> Runtime platform key. Defaults to the current platform.
--release-tag <tag> Release tag to download from. Defaults to runtime.lock.json.
--clean Remove staged runtime files and keep resources/runtime/.gitkeep.
--help Show this message.
`);
}
function parseArgs(argv) {
const parsed = {
platform: null,
releaseTag: null,
clean: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
continue;
}
if (arg === '--clean') {
parsed.clean = true;
continue;
}
if (arg === '--platform') {
parsed.platform = argv[index + 1] ?? null;
index += 1;
continue;
}
if (arg === '--release-tag') {
parsed.releaseTag = argv[index + 1] ?? null;
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return parsed;
}
function runOrThrow(command, args) {
const result = spawnSync(command, args, {
cwd: repoRoot,
stdio: 'inherit',
shell: false,
});
if (result.error) {
throw new Error(`Failed to run ${command}: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}
function readRuntimeLock() {
return JSON.parse(fs.readFileSync(runtimeLockPath, 'utf8'));
}
function getDefaultPlatformKey() {
const key = `${process.platform}-${process.arch}`;
if (
key === 'darwin-arm64' ||
key === 'darwin-x64' ||
key === 'linux-x64' ||
key === 'win32-x64'
) {
return key;
}
throw new Error(`No bundled runtime asset is configured for ${key}`);
}
function getReleaseTag(runtimeLock, override) {
const tag = override?.trim() || runtimeLock.releaseTag?.trim() || runtimeLock.sourceRef?.trim();
if (!tag) {
throw new Error('runtime.lock.json does not define releaseTag or sourceRef');
}
return tag;
}
function getReleaseAssetUrl(runtimeLock, releaseTag, asset) {
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
}
function cleanRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true });
for (const entry of fs.readdirSync(runtimeDir, { withFileTypes: true })) {
if (entry.name === '.gitkeep') {
continue;
}
fs.rmSync(path.join(runtimeDir, entry.name), { recursive: true, force: true });
}
}
async function downloadFile(url, destinationPath) {
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const response = await fetch(url, {
headers: {
'user-agent': 'agent-teams-runtime-stager',
...(process.env.GH_TOKEN ? { authorization: `Bearer ${process.env.GH_TOKEN}` } : {}),
},
redirect: 'follow',
});
if (!response.ok || !response.body) {
throw new Error(`Failed to download runtime asset: ${response.status} ${response.statusText}`);
}
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destinationPath));
}
function extractArchive(archivePath, extractDir, archiveKind) {
fs.mkdirSync(extractDir, { recursive: true });
if (archiveKind === 'tar.gz') {
runOrThrow('tar', ['-xzf', archivePath, '-C', extractDir]);
return;
}
if (archiveKind === 'zip') {
if (process.platform === 'win32') {
runOrThrow('powershell', [
'-NoProfile',
'-Command',
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractDir.replace(/'/g, "''")}' -Force`,
]);
return;
}
runOrThrow('unzip', ['-oq', archivePath, '-d', extractDir]);
return;
}
throw new Error(`Unsupported runtime archive kind: ${archiveKind}`);
}
function findRuntimePayloadDir(extractDir, binaryName) {
const candidates = [path.join(extractDir, 'runtime'), extractDir];
for (const candidate of candidates) {
if (
fs.existsSync(path.join(candidate, 'VERSION')) &&
fs.existsSync(path.join(candidate, binaryName))
) {
return candidate;
}
}
throw new Error(`Extracted runtime archive does not contain runtime/VERSION and ${binaryName}`);
}
function verifyStagedRuntime(runtimeLock, asset, platformKey) {
const versionPath = path.join(runtimeDir, 'VERSION');
const binaryPath = path.join(runtimeDir, asset.binaryName);
if (!fs.existsSync(versionPath)) {
throw new Error('Staged runtime is missing resources/runtime/VERSION');
}
if (!fs.existsSync(binaryPath)) {
throw new Error(`Staged runtime is missing resources/runtime/${asset.binaryName}`);
}
const versionText = fs.readFileSync(versionPath, 'utf8').trim();
if (!versionText.includes(runtimeLock.version)) {
throw new Error(
`Staged runtime version mismatch for ${platformKey}. Expected ${runtimeLock.version}, got ${versionText}`
);
}
}
async function stageRuntime(options) {
const runtimeLock = readRuntimeLock();
const platformKey = options.platform?.trim() || getDefaultPlatformKey();
const asset = runtimeLock.assets?.[platformKey];
if (!asset) {
throw new Error(`runtime.lock.json has no asset for ${platformKey}`);
}
const releaseTag = getReleaseTag(runtimeLock, options.releaseTag);
const workDir = path.join(downloadRoot, `stage-${platformKey}-${process.pid}-${Date.now()}`);
const archivePath = path.join(workDir, asset.file);
const extractDir = path.join(workDir, 'extracted');
fs.rmSync(workDir, { recursive: true, force: true });
fs.mkdirSync(workDir, { recursive: true });
try {
const url = getReleaseAssetUrl(runtimeLock, releaseTag, asset);
process.stdout.write(
`Downloading ${asset.file} from ${runtimeLock.releaseRepository}@${releaseTag}\n`
);
await downloadFile(url, archivePath);
process.stdout.write(`Extracting ${asset.file}\n`);
extractArchive(archivePath, extractDir, asset.archiveKind);
const payloadDir = findRuntimePayloadDir(extractDir, asset.binaryName);
cleanRuntimeDir();
fs.cpSync(payloadDir, runtimeDir, { recursive: true });
if (process.platform !== 'win32' && platformKey !== 'win32-x64') {
fs.chmodSync(path.join(runtimeDir, asset.binaryName), 0o755);
}
verifyStagedRuntime(runtimeLock, asset, platformKey);
process.stdout.write(`Staged runtime ${runtimeLock.version} for ${platformKey}\n`);
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printUsage();
return;
}
if (options.clean) {
cleanRuntimeDir();
process.stdout.write('Cleaned resources/runtime\n');
return;
}
await stageRuntime(options);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View file

@ -269,7 +269,7 @@ export function classifyLaunchFailureArtifact(
code: 'model_no_bootstrap',
confidence: 0.82,
pattern:
/did not bootstrap-confirm|bootstrap unconfirmed|bootstrap-confirm before timeout|check-in not yet received|bootstrap_stalled/i,
/did not bootstrap-confirm|bootstrap unconfirmed|bootstrap-confirm before timeout|bootstrap was not confirmed|bootstrap not confirmed|check-in not yet received|bootstrap_stalled/i,
},
{
code: 'process_exited',

View file

@ -1614,6 +1614,28 @@ function mergeProvisioningWarnings(
return merged.length > 0 ? merged : undefined;
}
const DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD = 8;
const DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS = 16;
function buildLargeDeterministicBootstrapWarning(memberCount: number): string | null {
if (memberCount <= DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD) {
return null;
}
return (
`Large Codex team launch: ${memberCount} primary teammates will bootstrap in one runtime. ` +
`Launches above ${DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD} teammates can be slower and more likely to hit provider rate limits or bootstrap timeouts.`
);
}
function assertDeterministicBootstrapPrimaryMemberLimit(memberCount: number): void {
if (memberCount <= DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS) {
return;
}
throw new Error(
`Codex deterministic bootstrap currently supports up to ${DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS} primary teammates; this team has ${memberCount}. Reduce primary teammates or move extra OpenCode members to secondary lanes.`
);
}
function buildRuntimeLaunchWarning(
request: Pick<
TeamCreateRequest,
@ -1825,6 +1847,16 @@ interface ProvisioningRun {
stdoutParserCarryLooksLikeClaudeJson: boolean;
/** ISO timestamp when the last CLI line was recorded. */
claudeLogsUpdatedAt?: string;
/** ISO timestamp when the first accepted deterministic bootstrap event arrived. */
deterministicBootstrapStartedAt?: string;
/** Latest accepted deterministic bootstrap event name. */
lastDeterministicBootstrapEvent?: string;
/** Latest accepted deterministic bootstrap phase name. */
lastDeterministicBootstrapPhase?: string;
/** True after deterministic bootstrap reports that teammate spawning started. */
deterministicBootstrapMemberSpawnSeen: boolean;
/** True after deterministic bootstrap reports at least one teammate spawn result. */
deterministicBootstrapMemberResultSeen: boolean;
processKilled: boolean;
finalizingByTimeout: boolean;
cancelRequested: boolean;
@ -5408,25 +5440,229 @@ function emitLogsProgress(run: ProvisioningRun): void {
run.onProgress(run.progress);
}
function buildCliExitError(code: number | null, stdoutText: string, stderrText: string): string {
const trimmed = buildCombinedLogs(stdoutText, stderrText).trim();
type CliLogStream = 'stdout' | 'stderr' | 'unknown';
interface CliLogLine {
stream: CliLogStream;
text: string;
}
interface CliExitFailurePresentation {
message?: string;
error: string;
}
const USER_FACING_CLI_NOISE_TEXT_PATTERN =
/additionalContext|skill_flow|EXTREMELY_IMPORTANT|superpowers:using-superpowers|TodoWrite|Skill tool|Invoke Skill tool|Might any skill apply|relevant or requested skills BEFORE|hook_response|hook_started|hook_progress/i;
const USER_FACING_STDOUT_ERROR_PATTERN =
/\b(error|failed|failure|fatal|exception|traceback|uncaught|unauthorized|forbidden|quota|rate limit|not authenticated|invalid api key|token refresh failed|warning)\b|please run \/login/i;
function parseCliLogLinesFromText(text: string): CliLogLine[] {
const lines: CliLogLine[] = [];
let currentStream: CliLogStream = 'unknown';
for (const rawLine of text.split(/\r?\n/)) {
const trimmed = rawLine.trim();
if (!trimmed) {
continue;
}
if (trimmed === '[stdout]') {
currentStream = 'stdout';
continue;
}
if (trimmed === '[stderr]') {
currentStream = 'stderr';
continue;
}
lines.push({ stream: currentStream, text: trimmed });
}
return lines;
}
function getCliLogLinesForUserFacingError(run: ProvisioningRun): CliLogLine[] {
const lineHistory = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : [];
const lines = lineHistory.length > 0 ? parseCliLogLinesFromText(lineHistory.join('\n')) : [];
const combinedBufferLines = parseCliLogLinesFromText(
buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer)
);
if (lines.length === 0) {
return combinedBufferLines;
}
// `claudeLogLines` stores complete newline-delimited lines. Add raw ring-buffer
// lines as a fallback only when they contain user-facing material that may be
// sitting in a final partial stderr/stdout line at process close.
const seen = new Set(lines.map((line) => `${line.stream}:${line.text}`));
for (const line of combinedBufferLines) {
const key = `${line.stream}:${line.text}`;
if (!seen.has(key) && isPotentiallyUserFacingCliLine(line)) {
lines.push(line);
seen.add(key);
}
}
return lines;
}
function isNoiseCliLine(text: string): boolean {
return USER_FACING_CLI_NOISE_TEXT_PATTERN.test(text);
}
function isPotentiallyUserFacingCliLine(line: CliLogLine): boolean {
if (isNoiseCliLine(line.text)) {
return false;
}
if (line.stream === 'stderr') {
return true;
}
return USER_FACING_STDOUT_ERROR_PATTERN.test(line.text);
}
function extractStringField(value: unknown, key: string): string | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const raw = (value as Record<string, unknown>)[key];
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined;
}
function extractStructuredCliError(parsed: Record<string, unknown>): string | undefined {
const type = typeof parsed.type === 'string' ? parsed.type : undefined;
const subtype = typeof parsed.subtype === 'string' ? parsed.subtype : undefined;
if (type === 'system') {
if (subtype === 'team_bootstrap' && parsed.event === 'failed') {
return extractStringField(parsed, 'reason');
}
if (subtype === 'init' || subtype?.startsWith('hook_')) {
return undefined;
}
return undefined;
}
if (type === 'result') {
const result = parsed.result;
const resultSubtype = subtype ?? extractStringField(result, 'subtype');
if (resultSubtype === 'success' || parsed.outcome === 'success') {
return undefined;
}
if (resultSubtype === 'error' || resultSubtype?.startsWith('error_')) {
return (
extractStringField(parsed, 'error') ??
extractStringField(result, 'error') ??
extractStringField(parsed, 'result')
);
}
return undefined;
}
if (type === 'error') {
return extractStringField(parsed, 'error') ?? extractStringField(parsed, 'message');
}
return undefined;
}
function buildSanitizedCliExitError(run: ProvisioningRun): string | undefined {
const errorLines: string[] = [];
for (const line of getCliLogLinesForUserFacingError(run)) {
if (!line.text || isNoiseCliLine(line.text)) {
continue;
}
try {
const parsed = JSON.parse(line.text) as Record<string, unknown>;
const structuredError = extractStructuredCliError(parsed);
if (structuredError && !isNoiseCliLine(structuredError)) {
errorLines.push(structuredError);
}
continue;
} catch {
// Non-JSON stderr/plain CLI errors are handled below.
}
if (isPotentiallyUserFacingCliLine(line)) {
errorLines.push(line.text);
}
}
const deduped = [...new Set(errorLines.map((line) => line.trim()).filter(Boolean))];
if (deduped.length === 0) {
return undefined;
}
return deduped.join('\n').slice(-4000);
}
function formatPendingBootstrapMemberNames(run: ProvisioningRun): string {
const pending = run.expectedMembers.filter((name) => {
const status = run.memberSpawnStatuses.get(name);
return status?.bootstrapConfirmed !== true;
});
const names = pending.length > 0 ? pending : run.expectedMembers;
if (names.length === 0) {
return 'unknown';
}
const visible = names.slice(0, 6);
const suffix = names.length > visible.length ? ` and ${names.length - visible.length} more` : '';
return `${visible.join(', ')}${suffix}`;
}
function buildDeterministicBootstrapExitFailure(run: ProvisioningRun): CliExitFailurePresentation {
if (!run.lastDeterministicBootstrapEvent) {
return {
message: 'Launch bootstrap was not confirmed',
error:
'Codex runtime exited before deterministic team bootstrap started. No team_bootstrap event was received.',
};
}
if (!run.deterministicBootstrapMemberSpawnSeen) {
const lastStage = run.lastDeterministicBootstrapPhase
? `${run.lastDeterministicBootstrapEvent}/${run.lastDeterministicBootstrapPhase}`
: run.lastDeterministicBootstrapEvent;
return {
message: 'Launch bootstrap was not confirmed',
error: `Codex runtime exited during deterministic team bootstrap before teammate spawning started. Last bootstrap event: ${lastStage}.`,
};
}
return {
message: 'Launch bootstrap was not confirmed',
error: `Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: ${formatPendingBootstrapMemberNames(run)}.`,
};
}
function buildCliExitFailurePresentation(
run: ProvisioningRun,
code: number | null
): CliExitFailurePresentation {
const trimmed = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer).trim();
const cliCommandLabel = getConfiguredCliCommandLabel();
if (trimmed.length > 0) {
if (trimmed.toLowerCase().includes('please run /login')) {
return (
return {
error:
`${cliCommandLabel} reports it is not authenticated ("Please run /login"). ` +
'Run the CLI in a normal terminal and complete login, then retry. ' +
'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.'
);
'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.',
};
}
return trimmed.slice(-4000);
const sanitized = buildSanitizedCliExitError(run);
if (sanitized) {
return { error: sanitized };
}
}
if (run.deterministicBootstrap) {
return buildDeterministicBootstrapExitFailure(run);
}
if (code === 1) {
return `${cliCommandLabel} exited with code 1 without stdout/stderr. Typical causes: missing auth/onboarding, interactive TTY requirements, or an early bootstrap/runtime crash. Check \`~/.claude/debug/latest\` for the real stack and retry.`;
return {
error: `${cliCommandLabel} exited with code 1 without user-facing stdout/stderr. Typical causes: missing auth/onboarding, interactive TTY requirements, or an early bootstrap/runtime crash. Check \`~/.claude/debug/latest\` for the real stack and retry.`,
};
}
return `${cliCommandLabel} exited with code ${code ?? 'unknown'}`;
return { error: `${cliCommandLabel} exited with code ${code ?? 'unknown'}` };
}
interface CachedProbeResult {
@ -19989,6 +20225,8 @@ export class TeamProvisioningService {
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
primaryMemberNames.has(member.name)
);
assertDeterministicBootstrapPrimaryMemberLimit(effectiveMemberSpecs.length);
const largeTeamWarning = buildLargeDeterministicBootstrapWarning(effectiveMemberSpecs.length);
const resolvedProviderId = resolveTeamProviderId(request.providerId);
const crossProviderMemberArgs = await this.buildCrossProviderMemberArgs(
resolvedProviderId,
@ -20075,6 +20313,11 @@ export class TeamProvisioningService {
stdoutParserCarryIsCompleteJson: false,
stdoutParserCarryLooksLikeClaudeJson: false,
claudeLogsUpdatedAt: undefined,
deterministicBootstrapStartedAt: undefined,
lastDeterministicBootstrapEvent: undefined,
lastDeterministicBootstrapPhase: undefined,
deterministicBootstrapMemberSpawnSeen: false,
deterministicBootstrapMemberResultSeen: false,
processKilled: false,
finalizingByTimeout: false,
cancelRequested: false,
@ -20161,6 +20404,7 @@ export class TeamProvisioningService {
message: 'Validating team provisioning request',
startedAt,
updatedAt: startedAt,
warnings: largeTeamWarning ? [largeTeamWarning] : undefined,
cliLogsTail: undefined,
},
};
@ -21233,6 +21477,11 @@ export class TeamProvisioningService {
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
primaryMemberNames.has(member.name)
);
assertDeterministicBootstrapPrimaryMemberLimit(effectiveMemberSpecs.length);
const largeTeamWarning = buildLargeDeterministicBootstrapWarning(effectiveMemberSpecs.length);
const initialLaunchWarnings = [warning, largeTeamWarning].filter((value): value is string =>
Boolean(value)
);
const expectedMembers = effectiveMemberSpecs.map((member) => member.name);
const resolvedProviderId = resolveTeamProviderId(request.providerId);
const crossProviderMemberArgs = await this.buildCrossProviderMemberArgs(
@ -21345,6 +21594,11 @@ export class TeamProvisioningService {
stdoutParserCarryIsCompleteJson: false,
stdoutParserCarryLooksLikeClaudeJson: false,
claudeLogsUpdatedAt: undefined,
deterministicBootstrapStartedAt: undefined,
lastDeterministicBootstrapEvent: undefined,
lastDeterministicBootstrapPhase: undefined,
deterministicBootstrapMemberSpawnSeen: false,
deterministicBootstrapMemberResultSeen: false,
processKilled: false,
finalizingByTimeout: false,
cancelRequested: false,
@ -21436,7 +21690,7 @@ export class TeamProvisioningService {
: 'Validating team launch request (fallback members from config.json)',
startedAt,
updatedAt: startedAt,
warnings: warning ? [warning] : undefined,
warnings: initialLaunchWarnings.length > 0 ? initialLaunchWarnings : undefined,
cliLogsTail: undefined,
},
};
@ -29981,6 +30235,7 @@ export class TeamProvisioningService {
if (!event) {
return true;
}
this.recordDeterministicBootstrapTracking(run, event, msg);
if (event === 'started') {
const progress = updateProgress(run, 'configuring', 'Starting deterministic team bootstrap');
@ -30162,6 +30417,29 @@ export class TeamProvisioningService {
return true;
}
private recordDeterministicBootstrapTracking(
run: ProvisioningRun,
event: string,
msg: Record<string, unknown>
): void {
run.deterministicBootstrapStartedAt ??= nowIso();
run.lastDeterministicBootstrapEvent = event;
if (event === 'phase_changed') {
const phase = typeof msg.phase === 'string' ? msg.phase.trim() : '';
if (phase) {
run.lastDeterministicBootstrapPhase = phase;
}
}
if (event === 'member_spawn_started') {
run.deterministicBootstrapMemberSpawnSeen = true;
} else if (event === 'member_spawn_result') {
run.deterministicBootstrapMemberSpawnSeen = true;
run.deterministicBootstrapMemberResultSeen = true;
}
}
private handleStreamJsonMessage(run: ProvisioningRun, msg: Record<string, unknown>): void {
// stream-json output has various message types:
// {"type":"assistant","content":[{"type":"text","text":"..."},...]}
@ -33206,15 +33484,22 @@ export class TeamProvisioningService {
return;
}
const errorText = buildCliExitError(code, run.stdoutBuffer, run.stderrBuffer);
const failurePresentation = buildCliExitFailurePresentation(run, code);
const runtimeFailureLabel = getRunRuntimeFailureLabel(run);
const progress = updateProgress(run, 'failed', `${runtimeFailureLabel} exited with an error`, {
error: errorText,
const progress = updateProgress(
run,
'failed',
failurePresentation.message ?? `${runtimeFailureLabel} exited with an error`,
{
error: failurePresentation.error,
cliLogsTail: extractCliLogsFromRun(run),
});
}
);
run.onProgress(progress);
this.cleanupRun(run);
logger.warn(`Provisioning failed for ${run.teamName}: ${progress.error ?? errorText}`);
logger.warn(
`Provisioning failed for ${run.teamName}: ${progress.error ?? failurePresentation.error}`
);
}
private async waitForValidConfig(

View file

@ -2,15 +2,23 @@ import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
const EPERM_MAX_RETRIES = 3;
const EPERM_RETRY_DELAY_MS = 50;
const RENAME_MAX_ATTEMPTS = 8;
const RENAME_RETRY_BASE_DELAY_MS = 40;
const RENAME_RETRY_MAX_DELAY_MS = 250;
const RENAME_RETRY_JITTER_MS = 25;
const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getRenameRetryDelayMs(attempt: number): number {
const backoff = Math.min(RENAME_RETRY_BASE_DELAY_MS * attempt, RENAME_RETRY_MAX_DELAY_MS);
return backoff + Math.floor(Math.random() * (RENAME_RETRY_JITTER_MS + 1));
}
async function renameWithRetry(src: string, dest: string): Promise<void> {
for (let attempt = 0; attempt <= EPERM_MAX_RETRIES; attempt++) {
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
try {
await fs.promises.rename(src, dest);
return;
@ -21,8 +29,8 @@ async function renameWithRetry(src: string, dest: string): Promise<void> {
await fs.promises.unlink(src).catch(() => undefined);
return;
}
if (code === 'EPERM' && attempt < EPERM_MAX_RETRIES) {
await sleep(EPERM_RETRY_DELAY_MS * (attempt + 1));
if (code && RETRYABLE_RENAME_CODES.has(code) && attempt < RENAME_MAX_ATTEMPTS) {
await sleep(getRenameRetryDelayMs(attempt));
continue;
}
throw error;
@ -32,7 +40,7 @@ async function renameWithRetry(src: string, dest: string): Promise<void> {
/**
* Async atomic write: write tmp file then rename over target.
* Uses best-effort fsync and EXDEV/EPERM fallback for safety.
* Uses best-effort fsync and bounded Windows transient rename retries for safety.
*/
export async function atomicWriteAsync(targetPath: string, data: string | Buffer): Promise<void> {
const dir = path.dirname(targetPath);
@ -49,7 +57,11 @@ export async function atomicWriteAsync(targetPath: string, data: string | Buffer
} catch {
// fsync is best-effort.
} finally {
try {
await fd?.close();
} catch {
// close is best-effort after a best-effort fsync.
}
}
await renameWithRetry(tmpPath, targetPath);

View file

@ -81,6 +81,8 @@ export interface ProvisioningProgressBlockProps {
assistantOutput?: string;
/** Bounded structured launch diagnostics */
launchDiagnostics?: TeamLaunchDiagnosticItem[];
/** Non-fatal warnings that should stay visible while the run continues. */
warnings?: string[];
/** Bounded per-member launch/runtime diagnostics for copy payloads. */
memberDiagnostics?: MemberLaunchDiagnosticsPayload[];
/** Visual surface chrome for the outer block */
@ -173,6 +175,73 @@ function formatOptionalValue(value: string | number | null | undefined): string
return String(value);
}
function formatBooleanValue(value: boolean | null | undefined): string {
if (value === null || value === undefined) {
return '(unknown)';
}
return value ? 'yes' : 'no';
}
function formatDetailsBlock(summary: string, content: string): string {
return [
'<details>',
`<summary>${summary}</summary>`,
'',
content.trim() || '(empty)',
'',
'</details>',
].join('\n');
}
function formatListOrNone(values: readonly string[] | undefined): string {
const lines = values?.map((value) => value.trim()).filter(Boolean) ?? [];
if (lines.length === 0) {
return '(none)';
}
return lines.map((line) => `- ${line}`).join('\n');
}
function parseJsonRecord(value: string | undefined): Record<string, unknown> | null {
if (!value?.trim()) {
return null;
}
try {
const parsed = JSON.parse(value) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function findArtifactManifest(
bundle: TeamLaunchFailureDiagnosticsBundle | null | undefined
): Record<string, unknown> | null {
const manifestFile = bundle?.files.find(
(file) => file.label === 'launch-failure-artifacts/manifest.json'
);
return parseJsonRecord(manifestFile?.content);
}
function getArrayLength(value: unknown): number | null {
return Array.isArray(value) ? value.length : null;
}
function getObjectKeyCount(value: unknown): number | null {
return value && typeof value === 'object' && !Array.isArray(value)
? Object.keys(value).length
: null;
}
function getStringField(value: unknown, key: string): string | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const raw = (value as Record<string, unknown>)[key];
return typeof raw === 'string' && raw.trim() ? raw.trim() : null;
}
function formatLaunchDiagnosticsCopy(
items: readonly TeamLaunchDiagnosticItem[] | undefined
): string {
@ -249,6 +318,109 @@ function formatLaunchFailureArtifactCopy(
return parts.join('\n');
}
function formatArtifactManifestSummary(manifest: Record<string, unknown> | null): string {
if (!manifest) {
return '(manifest unavailable)';
}
const progress =
manifest.progress && typeof manifest.progress === 'object' && !Array.isArray(manifest.progress)
? (manifest.progress as Record<string, unknown>)
: null;
const lines = [
`expectedMembers: ${formatOptionalValue(getArrayLength(manifest.expectedMembers))}`,
`effectiveMembers: ${formatOptionalValue(getArrayLength(manifest.effectiveMembers))}`,
`memberSpawnStatuses: ${formatOptionalValue(getObjectKeyCount(manifest.memberSpawnStatuses))}`,
`progress.state: ${formatOptionalValue(getStringField(progress, 'state'))}`,
`progress.message: ${formatOptionalValue(getStringField(progress, 'message'))}`,
`progress.error: ${formatOptionalValue(getStringField(progress, 'error'))}`,
`progress.warnings: ${formatOptionalValue(getArrayLength(progress?.warnings))}`,
`launchDiagnostics: ${formatOptionalValue(getArrayLength(manifest.launchDiagnostics))}`,
];
return lines.join('\n');
}
function hasNoBootstrapEventSignal(message: string | null | undefined): boolean {
const normalized = message?.toLowerCase() ?? '';
return (
normalized.includes('no team_bootstrap event') ||
normalized.includes('before deterministic team bootstrap started')
);
}
function formatConfidence(value: number | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '(unknown)';
}
return value.toFixed(2);
}
function buildDiagnosticsQuickTriage(input: {
title: string;
message?: string | null;
tone: 'default' | 'error';
startedAt?: string;
elapsed?: string | null;
pid?: number;
currentStepIndex: number;
errorStepIndex?: number;
warnings?: string[];
launchDiagnostics?: TeamLaunchDiagnosticItem[];
memberDiagnostics?: MemberLaunchDiagnosticsPayload[];
launchFailureArtifact?: TeamLaunchFailureDiagnosticsBundle | null;
launchFailureArtifactError?: string | null;
cliLogsTail?: string;
liveOutput?: string | null;
}): string {
const bundle = input.launchFailureArtifact;
const artifactManifest = findArtifactManifest(bundle);
const manifestProgress =
artifactManifest?.progress &&
typeof artifactManifest.progress === 'object' &&
!Array.isArray(artifactManifest.progress)
? (artifactManifest.progress as Record<string, unknown>)
: null;
const classification = bundle?.classification;
const breadcrumb = bundle?.bootstrapTransportBreadcrumb;
const warningCount = input.warnings?.filter((warning) => warning.trim()).length ?? 0;
const launchDiagnosticCount = input.launchDiagnostics?.length ?? 0;
const memberDiagnosticCount = input.memberDiagnostics?.length ?? 0;
const artifactFileCount = bundle?.files.length ?? 0;
const hasRawCliLogs = Boolean(input.cliLogsTail?.trim());
const hasLiveOutput = Boolean(input.liveOutput?.trim());
const largeTeamWarning = input.warnings?.find((warning) =>
warning.toLowerCase().includes('large codex team launch')
);
const noBootstrapEvent = hasNoBootstrapEventSignal(input.message);
const facts = [
`- User-visible title: ${input.title}`,
`- User-visible message: ${formatOptionalValue(input.message)}`,
`- Tone: ${input.tone}`,
`- Started at: ${formatOptionalValue(input.startedAt)}; elapsed: ${formatOptionalValue(input.elapsed)}; pid: ${formatOptionalValue(input.pid)}`,
`- Step index: current=${input.currentStepIndex}; error=${formatOptionalValue(input.errorStepIndex)}`,
`- Classification: ${classification?.code ?? '(none)'}; confidence=${formatConfidence(classification?.confidence)}`,
`- Bootstrap transport: submitted=${formatBooleanValue(breadcrumb?.bootstrapSubmitted)}; rejected=${formatBooleanValue(breadcrumb?.submitRejected)}; noStdinWarning=${formatBooleanValue(breadcrumb?.noStdinWarning)}; lastStage=${formatOptionalValue(breadcrumb?.lastTransportStage)}`,
`- Counts: warnings=${warningCount}; launchDiagnostics=${launchDiagnosticCount}; memberSnapshots=${memberDiagnosticCount}; artifactFiles=${artifactFileCount}`,
`- Manifest counts: expectedMembers=${formatOptionalValue(getArrayLength(artifactManifest?.expectedMembers))}; effectiveMembers=${formatOptionalValue(getArrayLength(artifactManifest?.effectiveMembers))}; spawnStatuses=${formatOptionalValue(getObjectKeyCount(artifactManifest?.memberSpawnStatuses))}`,
`- Manifest progress: state=${formatOptionalValue(getStringField(manifestProgress, 'state'))}; message=${formatOptionalValue(getStringField(manifestProgress, 'message'))}; error=${formatOptionalValue(getStringField(manifestProgress, 'error'))}`,
`- Raw CLI logs present: ${formatBooleanValue(hasRawCliLogs)}; live output present: ${formatBooleanValue(hasLiveOutput)}`,
];
if (noBootstrapEvent) {
facts.push(
'- Bootstrap signal: no `system/team_bootstrap` event reached the app before process exit.'
);
}
if (largeTeamWarning) {
facts.push(`- Large-team signal: ${largeTeamWarning}`);
}
if (input.launchFailureArtifactError) {
facts.push(`- Artifact read error: ${input.launchFailureArtifactError}`);
}
return facts.join('\n');
}
function buildProvisioningDiagnosticsCopy(input: {
title: string;
message?: string | null;
@ -261,14 +433,29 @@ function buildProvisioningDiagnosticsCopy(input: {
errorStepIndex?: number;
liveOutput?: string | null;
cliLogsTail?: string;
warnings?: string[];
launchDiagnostics?: TeamLaunchDiagnosticItem[];
memberDiagnostics?: MemberLaunchDiagnosticsPayload[];
launchFailureArtifact?: TeamLaunchFailureDiagnosticsBundle | null;
launchFailureArtifactError?: string | null;
}): string {
const warningsCopy = formatListOrNone(input.warnings);
const launchDiagnosticsCopy = formatLaunchDiagnosticsCopy(input.launchDiagnostics);
const memberDiagnosticsCopy = formatMemberDiagnosticsCopy(input.memberDiagnostics);
const artifactManifest = findArtifactManifest(input.launchFailureArtifact);
const artifactManifestSummary = formatArtifactManifestSummary(artifactManifest);
const artifactCopy = formatLaunchFailureArtifactCopy(
input.launchFailureArtifact,
input.launchFailureArtifactError
);
const liveOutputCopy = input.liveOutput?.trim() || '(empty)';
const cliLogsCopy = input.cliLogsTail?.trim() || '(empty)';
const payload = [
'# Team provisioning diagnostics',
'',
'## Quick triage',
buildDiagnosticsQuickTriage(input),
'',
'## Summary',
`Title: ${input.title}`,
`Message: ${formatOptionalValue(input.message)}`,
@ -280,20 +467,23 @@ function buildProvisioningDiagnosticsCopy(input: {
`Current step index: ${input.currentStepIndex}`,
`Error step index: ${formatOptionalValue(input.errorStepIndex)}`,
'',
'## Warnings',
warningsCopy,
'',
'## Launch diagnostics',
formatLaunchDiagnosticsCopy(input.launchDiagnostics),
launchDiagnosticsCopy,
'',
'## Member launch snapshots',
formatMemberDiagnosticsCopy(input.memberDiagnostics),
'## Artifact manifest summary',
artifactManifestSummary,
'',
'## Launch failure artifact bundle',
formatLaunchFailureArtifactCopy(input.launchFailureArtifact, input.launchFailureArtifactError),
'## Full details',
formatDetailsBlock('Member launch snapshots', memberDiagnosticsCopy),
'',
'## Live output',
input.liveOutput?.trim() || '(empty)',
formatDetailsBlock('Launch failure artifact bundle', artifactCopy),
'',
'## CLI logs tail',
input.cliLogsTail?.trim() || '(empty)',
formatDetailsBlock('Live output', liveOutputCopy),
'',
formatDetailsBlock('CLI logs tail', cliLogsCopy),
].join('\n');
return redactProvisioningDiagnosticsCopy(payload).trim();
@ -320,6 +510,7 @@ export const ProvisioningProgressBlock = ({
cliLogsTail,
assistantOutput,
launchDiagnostics,
warnings,
memberDiagnostics,
surface = 'raised',
className,
@ -336,6 +527,10 @@ export const ProvisioningProgressBlock = ({
const visibleLaunchDiagnostics =
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
[];
const visibleWarnings =
warnings
?.map((warning) => warning.trim())
.filter((warning) => warning && !warning.startsWith('Launch runtime:')) ?? [];
// Auto-scroll assistant output
useEffect(() => {
@ -399,6 +594,7 @@ export const ProvisioningProgressBlock = ({
errorStepIndex,
liveOutput: displayAssistantOutput,
cliLogsTail,
warnings,
launchDiagnostics,
memberDiagnostics,
launchFailureArtifact,
@ -518,6 +714,21 @@ export const ProvisioningProgressBlock = ({
})}
</div>
) : null}
{visibleWarnings.length > 0 ? (
<div className="mt-2 flex gap-2 rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
<AlertTriangle size={13} className="mt-0.5 shrink-0" />
<div className="min-w-0 space-y-1">
{visibleWarnings.slice(0, 3).map((warning) => (
<p key={warning} className="whitespace-pre-wrap">
{warning}
</p>
))}
{visibleWarnings.length > 3 ? (
<p>{visibleWarnings.length - 3} more warnings hidden</p>
) : null}
</div>
</div>
) : null}
<div className="mt-2 px-2">
<StepProgressBar
steps={PROVISIONING_STEPS}

View file

@ -141,6 +141,7 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
cliLogsTail={presentation.progress.cliLogsTail}
assistantOutput={presentation.progress.assistantOutput}
launchDiagnostics={presentation.progress.launchDiagnostics}
warnings={presentation.progress.warnings}
memberDiagnostics={memberDiagnostics}
defaultLiveOutputOpen={presentation.defaultLiveOutputOpen}
defaultLogsOpen={defaultLogsOpen}

View file

@ -438,7 +438,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
);
const [selectedEffort, setSelectedEffortRaw] = useState(() => {
const stored = localStorage.getItem('team:lastSelectedEffort');
return stored === null ? 'medium' : stored;
return stored === null ? '' : stored;
});
const [selectedFastMode, setSelectedFastModeRaw] = useState<TeamFastMode>(getStoredTeamFastMode);
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
@ -787,7 +787,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
);
setSelectedProviderIdRaw(storedProviderId);
setSelectedModelRaw(getStoredTeamModel(storedProviderId));
setSelectedEffortRaw('medium');
setSelectedEffortRaw('');
setSelectedFastModeRaw(getStoredTeamFastMode());
setSavedLaunchProviderBackendId(null);
setScheduleHydrationKey(null);
@ -837,7 +837,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
previousLaunchParams,
multimodelEnabled,
storedProviderId,
storedEffort: storedEffort === null ? 'medium' : storedEffort,
storedEffort: storedEffort === null ? '' : storedEffort,
storedFastMode: getStoredTeamFastMode(),
storedLimitContext: localStorage.getItem('team:lastLimitContext') === 'true',
getStoredModel: getStoredTeamModel,

View file

@ -263,7 +263,7 @@ export function setStoredCreateTeamSkipPermissions(value: boolean): void {
export function getStoredCreateTeamEffort(): string {
return (
readCreateTeamPreference(CREATE_TEAM_EFFORT_KEY, `${LEGACY_TEAM_PREFIX}lastSelectedEffort`) ??
'medium'
''
);
}

View file

@ -272,6 +272,11 @@ describe('TeamLaunchFailureArtifactPack', () => {
text: 'bob: Teammate was registered but did not bootstrap-confirm before timeout.',
code: 'model_no_bootstrap',
},
{
name: 'sanitized launch bootstrap fallback',
text: 'Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: alice.',
code: 'model_no_bootstrap',
},
{
name: 'process stale pid',
text: 'persisted runtime pid is not alive; persisted runtime pid was not found in process table',

View file

@ -250,6 +250,55 @@ function writeLaunchConfig(
);
}
async function startDeterministicLaunchCloseHarness(options?: {
teamName?: string;
leadSessionId?: string;
members?: string[];
}) {
const teamName = options?.teamName ?? `launch-close-${Date.now()}`;
const leadSessionId = options?.leadSessionId ?? `lead-session-${teamName}`;
const members = options?.members ?? ['alice'];
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, members);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
const child = createRunningChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
removeConfigFile: vi.fn(async () => {}),
} as any);
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { CODEX_API_KEY: 'test' },
authSource: 'codex_runtime',
}));
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: members.map((name) => ({ name })),
source: 'members-meta',
warning: undefined,
}));
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).waitForValidConfig = vi.fn(async () => ({ ok: false }));
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
targetPath.endsWith(`${leadSessionId}.jsonl`)
);
const progressUpdates: any[] = [];
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, (progress) => {
progressUpdates.push(progress);
});
const run = (svc as any).runs.get(runId);
expect(run).toBeTruthy();
return { child, members, progressUpdates, run, runId, svc, teamName };
}
function writeLaunchState(
teamName: string,
leadSessionId: string,
@ -15392,6 +15441,214 @@ describe('TeamProvisioningService', () => {
expect(progressStates).not.toContain('verifying');
});
it('warns but still starts deterministic launch with more than eight primary teammates', async () => {
allowConsoleLogs();
const members = Array.from({ length: 9 }, (_, index) => `member-${index + 1}`);
const { progressUpdates } = await startDeterministicLaunchCloseHarness({
teamName: 'launch-large-primary-team-warning',
members,
});
expect(spawnCli).toHaveBeenCalled();
expect(progressUpdates[0]?.warnings).toEqual(expect.arrayContaining([
expect.stringContaining('9 primary teammates'),
]));
expect(progressUpdates[0]?.warnings?.join('\n')).toContain('Launches above 8 teammates');
});
it('fails before spawning when deterministic launch exceeds the current primary teammate cap', async () => {
allowConsoleLogs();
const members = Array.from({ length: 17 }, (_, index) => `member-${index + 1}`);
await expect(
startDeterministicLaunchCloseHarness({
teamName: 'launch-too-many-primary-members',
members,
})
).rejects.toThrow(/up to 16 primary teammates/);
expect(spawnCli).not.toHaveBeenCalled();
});
it('keeps SessionStart hook payloads out of user-facing launch errors', async () => {
allowConsoleLogs();
const { child, progressUpdates } = await startDeterministicLaunchCloseHarness({
teamName: 'launch-hook-payload-sanitized',
members: ['alice'],
});
const hookPayload = [
'<EXTREMELY_IMPORTANT>',
'You have superpowers.',
'digraph skill_flow {',
'Might any skill apply?',
'Invoke Skill tool',
'TodoWrite',
'superpowers:using-superpowers',
'</EXTREMELY_IMPORTANT>',
].join('\n');
child.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
type: 'system',
subtype: 'hook_started',
hook_name: 'SessionStart:startup',
})}\n${JSON.stringify({
type: 'system',
subtype: 'hook_response',
hook_name: 'SessionStart:startup',
output: JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: hookPayload,
},
}),
exit_code: 0,
outcome: 'success',
})}\n`,
'utf8'
)
);
child.emit('close', 1);
await vi.waitFor(() => expect(progressUpdates.at(-1)?.state).toBe('failed'));
const finalProgress = progressUpdates.at(-1);
expect(finalProgress.message).toBe('Launch bootstrap was not confirmed');
expect(finalProgress.error).toContain('No team_bootstrap event was received');
expect(finalProgress.error).not.toMatch(
/skill_flow|EXTREMELY_IMPORTANT|additionalContext|TodoWrite|Skill tool/
);
expect(finalProgress.cliLogsTail).toMatch(/skill_flow|EXTREMELY_IMPORTANT|additionalContext/);
});
it('does not leak a mid-buffer hook payload when stdout starts inside hook JSON', async () => {
allowConsoleLogs();
const { child, progressUpdates, run } = await startDeterministicLaunchCloseHarness({
teamName: 'launch-hook-mid-buffer-sanitized',
members: ['alice'],
});
run.claudeLogLines = [];
run.stdoutBuffer =
'evant or requested skills BEFORE any response or action. digraph skill_flow { EXTREMELY_IMPORTANT TodoWrite Skill tool }';
run.stderrBuffer = '';
child.emit('close', 1);
await vi.waitFor(() => expect(progressUpdates.at(-1)?.state).toBe('failed'));
const finalProgress = progressUpdates.at(-1);
expect(finalProgress.error).toContain('No team_bootstrap event was received');
expect(finalProgress.error).not.toMatch(/skill_flow|EXTREMELY_IMPORTANT|TodoWrite|Skill tool/);
expect(finalProgress.cliLogsTail).toContain('skill_flow');
});
it('reports the last bootstrap phase when Codex exits before spawning teammates', async () => {
allowConsoleLogs();
const { child, progressUpdates, runId, teamName } = await startDeterministicLaunchCloseHarness({
teamName: 'launch-bootstrap-phase-before-spawn',
members: ['alice'],
});
child.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
type: 'system',
subtype: 'team_bootstrap',
event: 'started',
run_id: runId,
team_name: teamName,
seq: 1,
})}\n${JSON.stringify({
type: 'system',
subtype: 'team_bootstrap',
event: 'phase_changed',
phase: 'acquiring_bootstrap_lock',
run_id: runId,
team_name: teamName,
seq: 2,
})}\n`,
'utf8'
)
);
child.emit('close', 1);
await vi.waitFor(() => expect(progressUpdates.at(-1)?.state).toBe('failed'));
const finalProgress = progressUpdates.at(-1);
expect(finalProgress.message).toBe('Launch bootstrap was not confirmed');
expect(finalProgress.error).toContain('before teammate spawning started');
expect(finalProgress.error).toContain('phase_changed/acquiring_bootstrap_lock');
});
it('reports pending teammates when Codex exits after member spawning starts', async () => {
allowConsoleLogs();
const { child, progressUpdates, runId, teamName } = await startDeterministicLaunchCloseHarness({
teamName: 'launch-bootstrap-pending-members',
members: ['alice', 'bob'],
});
child.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
type: 'system',
subtype: 'team_bootstrap',
event: 'started',
run_id: runId,
team_name: teamName,
seq: 1,
})}\n${JSON.stringify({
type: 'system',
subtype: 'team_bootstrap',
event: 'member_spawn_started',
member_name: 'alice',
run_id: runId,
team_name: teamName,
seq: 2,
})}\n`,
'utf8'
)
);
child.emit('close', 1);
await vi.waitFor(() => expect(progressUpdates.at(-1)?.state).toBe('failed'));
const finalProgress = progressUpdates.at(-1);
expect(finalProgress.message).toBe('Launch bootstrap was not confirmed');
expect(finalProgress.error).toContain('Pending teammates: alice, bob');
});
it('preserves real stderr as the user-facing launch error', async () => {
allowConsoleLogs();
const { child, progressUpdates } = await startDeterministicLaunchCloseHarness({
teamName: 'launch-real-stderr-preserved',
members: ['alice'],
});
child.stderr.emit('data', Buffer.from('Fatal runtime exploded before bootstrap\n', 'utf8'));
child.emit('close', 1);
await vi.waitFor(() => expect(progressUpdates.at(-1)?.state).toBe('failed'));
const finalProgress = progressUpdates.at(-1);
expect(finalProgress.error).toBe('Fatal runtime exploded before bootstrap');
expect(finalProgress.message).not.toBe('Launch bootstrap was not confirmed');
});
it('preserves the CLI login hint on launch process exit', async () => {
allowConsoleLogs();
const { child, progressUpdates, svc } = await startDeterministicLaunchCloseHarness({
teamName: 'launch-login-hint-preserved',
members: ['alice'],
});
vi.spyOn(svc as any, 'handleAuthFailureInOutput').mockImplementation(() => {});
child.stderr.emit('data', Buffer.from('Please run /login to continue\n', 'utf8'));
child.emit('close', 1);
await vi.waitFor(() => expect(progressUpdates.at(-1)?.state).toBe('failed'));
const finalProgress = progressUpdates.at(-1);
expect(finalProgress.error).toContain('reports it is not authenticated');
expect(finalProgress.error).toContain('Please run /login');
});
it('clears stale team-scoped transient state before starting a new launch run', async () => {
allowConsoleLogs();
vi.useFakeTimers();

View file

@ -1,5 +1,5 @@
/**
* Tests for atomicWriteAsync tmp + fsync + rename atomic write pattern.
* Tests for atomicWriteAsync - tmp + fsync + rename atomic write pattern.
*/
import * as fs from 'fs';
@ -130,11 +130,54 @@ describe('atomicWriteAsync', () => {
expect(mockCopyFile).toHaveBeenCalled();
});
it('re-throws non-EXDEV rename errors and cleans tmp', async () => {
const permError = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
mockRename.mockRejectedValue(permError);
it.each(['EPERM', 'EACCES', 'EBUSY'])(
'retries transient %s rename failures before publishing',
async (code) => {
const transientError = Object.assign(new Error(`Transient ${code}`), { code });
mockRename
.mockRejectedValueOnce(transientError)
.mockRejectedValueOnce(transientError)
.mockResolvedValue(undefined);
await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow('Permission denied');
await atomicWriteAsync(TARGET_PATH, CONTENT);
const tmpPath = getTmpPath();
expect(mockRename).toHaveBeenCalledTimes(3);
expect(mockRename).toHaveBeenLastCalledWith(tmpPath, TARGET_PATH);
expect(mockUnlink).not.toHaveBeenCalled();
}
);
it('does not retry ENOENT rename failures and cleans tmp', async () => {
const missingError = Object.assign(new Error('No such file or directory'), { code: 'ENOENT' });
mockRename.mockRejectedValue(missingError);
await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow(
'No such file or directory'
);
expect(mockRename).toHaveBeenCalledTimes(1);
expect(mockUnlink).toHaveBeenCalled();
});
it('cleans tmp after retryable rename failures are exhausted', async () => {
const transientError = Object.assign(new Error('Transient lock stayed active'), {
code: 'EBUSY',
});
mockRename.mockRejectedValue(transientError);
await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow(
'Transient lock stayed active'
);
expect(mockRename).toHaveBeenCalledTimes(8);
expect(mockUnlink).toHaveBeenCalled();
});
it('re-throws non-retryable rename errors and cleans tmp', async () => {
const writeError = Object.assign(new Error('Disk unavailable'), { code: 'ENOSPC' });
mockRename.mockRejectedValue(writeError);
await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow('Disk unavailable');
expect(mockRename).toHaveBeenCalledTimes(1);
expect(mockUnlink).toHaveBeenCalled();
});

View file

@ -106,6 +106,36 @@ describe('ProvisioningProgressBlock', () => {
});
});
it('renders non-fatal provisioning warnings in the progress panel', 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(ProvisioningProgressBlock, {
title: 'Launching team',
currentStepIndex: 1,
loading: true,
warnings: [
'Large Codex team launch: 9 primary teammates will bootstrap in one runtime.',
],
defaultLiveOutputOpen: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Large Codex team launch');
expect(host.textContent).toContain('9 primary teammates');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders bounded launch diagnostics without opening CLI logs', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
@ -242,6 +272,7 @@ describe('ProvisioningProgressBlock', () => {
pid: 321,
assistantOutput: 'Launch trace line',
cliLogsTail: '[stderr] OPENAI_API_KEY=secret-value\n[stdout] booted',
warnings: ['Large Codex team launch: 9 primary teammates will bootstrap in one runtime.'],
launchDiagnostics: [
{
id: 'alice:runtime_not_found',
@ -271,10 +302,17 @@ describe('ProvisioningProgressBlock', () => {
expect(writeText).toHaveBeenCalledTimes(1);
const copied = String(writeText.mock.calls[0]?.[0] ?? '');
expect(copied).toContain('# Team provisioning diagnostics');
expect(copied).toContain('## Quick triage');
expect(copied).toContain('Title: Launching team');
expect(copied).toContain('Message: Starting Claude CLI process');
expect(copied).toContain('PID: 321');
expect(copied).toContain('Counts: warnings=1; launchDiagnostics=1; memberSnapshots=0; artifactFiles=0');
expect(copied).toContain('Large-team signal: Large Codex team launch');
expect(copied).toContain('## Warnings');
expect(copied).toContain('- Large Codex team launch');
expect(copied).toContain('alice - waiting for runtime');
expect(copied).toContain('<summary>Live output</summary>');
expect(copied).toContain('<summary>CLI logs tail</summary>');
expect(copied).toContain('Launch trace line');
expect(copied).toContain('[stdout] booted');
expect(copied).toContain('OPENAI_API_KEY=[redacted]');