merge(dev): sync dev into main
This commit is contained in:
commit
6d7d74c7bb
473 changed files with 76816 additions and 3731 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Restore ESLint cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .eslintcache
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*', 'src/**/*.ts', 'src/**/*.tsx') }}
|
||||
|
|
|
|||
6
.github/workflows/landing.yml
vendored
6
.github/workflows/landing.yml
vendored
|
|
@ -32,9 +32,9 @@ jobs:
|
|||
- name: Generate static site
|
||||
working-directory: landing
|
||||
env:
|
||||
NUXT_APP_BASE_URL: /claude_agent_teams_ui/
|
||||
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/claude_agent_teams_ui
|
||||
NUXT_PUBLIC_GITHUB_REPO: 777genius/claude_agent_teams_ui
|
||||
NUXT_APP_BASE_URL: /agent-teams-ai/
|
||||
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/agent-teams-ai
|
||||
NUXT_PUBLIC_GITHUB_REPO: 777genius/agent-teams-ai
|
||||
run: npm run generate:all
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
|
|
|||
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
|
|
@ -497,13 +497,13 @@ jobs:
|
|||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
declare -A FILES=(
|
||||
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
|
||||
["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-x64.dmg"
|
||||
["Claude-Agent-Teams-UI-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe"
|
||||
["Claude-Agent-Teams-UI.AppImage"]="Claude.Agent.Teams.UI-${VERSION}.AppImage"
|
||||
["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb"
|
||||
["Claude-Agent-Teams-UI-x86_64.rpm"]="claude-agent-teams-ui-${VERSION}.x86_64.rpm"
|
||||
["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman"
|
||||
["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||
["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
|
||||
["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
|
||||
["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage"
|
||||
["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb"
|
||||
["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm"
|
||||
["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
|
||||
)
|
||||
|
||||
# Download versioned files and re-upload with stable names
|
||||
|
|
@ -574,22 +574,22 @@ jobs:
|
|||
# electron-updater on GitHub still consumes a single latest-mac.yml, so we
|
||||
# publish the Apple Silicon feed here and suppress Intel auto-update in-app
|
||||
# until we switch to universal packaging or an arch-aware provider.
|
||||
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip"
|
||||
download_asset "Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
|
||||
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
|
||||
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip)"
|
||||
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
|
||||
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-arm64.dmg)"
|
||||
download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip"
|
||||
download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||
MAC_ZIP_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
|
||||
MAC_ZIP_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64-mac.zip)"
|
||||
MAC_DMG_SHA="$(sha512_base64 Agent.Teams.AI-${VERSION}-arm64.dmg)"
|
||||
MAC_DMG_SIZE="$(file_size Agent.Teams.AI-${VERSION}-arm64.dmg)"
|
||||
cat > latest-mac.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
- url: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
|
||||
- url: Agent.Teams.AI-${VERSION}-arm64-mac.zip
|
||||
sha512: ${MAC_ZIP_SHA}
|
||||
size: ${MAC_ZIP_SIZE}
|
||||
- url: Claude.Agent.Teams.UI-${VERSION}-arm64.dmg
|
||||
- url: Agent.Teams.AI-${VERSION}-arm64.dmg
|
||||
sha512: ${MAC_DMG_SHA}
|
||||
size: ${MAC_DMG_SIZE}
|
||||
path: Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip
|
||||
path: Agent.Teams.AI-${VERSION}-arm64-mac.zip
|
||||
sha512: ${MAC_ZIP_SHA}
|
||||
releaseDate: '${RELEASE_DATE}'
|
||||
EOF
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -48,4 +48,7 @@ eslint-fix/
|
|||
.eslintcache
|
||||
remotion/*
|
||||
|
||||
.home/
|
||||
.home/
|
||||
.board-task-log-freshness/
|
||||
|
||||
.serena/
|
||||
|
|
|
|||
6
.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml
Normal file
6
.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
- generic [ref=e1]:
|
||||
- img
|
||||
- img [ref=e2]
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]: Agent Teams AI
|
||||
- generic [ref=e15]: Get more done by doing less.
|
||||
|
|
@ -17,6 +17,8 @@ For new features:
|
|||
|
||||
- Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority.
|
||||
- For team launch hangs, OpenCode `registered`/`bootstrap unconfirmed`, missing teammate replies, or suspicious task logs, follow [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md) before changing code.
|
||||
- For launch failures, first inspect the newest artifact pack under `~/.claude/teams/<team>/launch-failure-artifacts/latest.json`, then open its `manifest.json`. The manifest includes `classification`, `bootstrapTransportBreadcrumb`, launch diagnostics, member spawn statuses, and redacted copies/tails of launch-state, bootstrap-state, bootstrap-journal, CLI logs, progress trace, and runtime adapter trace.
|
||||
- When running live smoke tests, keep cleanup narrow: stop only the smoke-owned team/run and launch-owned process teammates. Do not kill shared OpenCode hosts, unrelated tmux panes, or user teams while trying to clean stale smoke artifacts.
|
||||
- Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints.
|
||||
- Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases.
|
||||
- Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`.
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -10,15 +10,15 @@
|
|||
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams</a></h1>
|
||||
<h1 align="center"><a href="https://777genius.github.io/agent-teams-ai/">Agent Teams</a></h1>
|
||||
|
||||
<p align="center">
|
||||
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/tag/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest"><img src="https://img.shields.io/github/v/tag/777genius/agent-teams-ai?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/777genius/agent-teams-ai/actions/workflows/ci.yml"><img src="https://github.com/777genius/agent-teams-ai/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||
<a href="https://discord.gg/qtqSZSyuEc"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
|
|
@ -54,33 +54,33 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc
|
|||
<table align="center">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
||||
</a>
|
||||
<br />
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe">
|
||||
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI.AppImage">
|
||||
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
||||
</a>
|
||||
<br />
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb">
|
||||
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
||||
</a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm">
|
||||
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
||||
</a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.pacman">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI.pacman">
|
||||
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
||||
</a>
|
||||
</td>
|
||||
|
|
@ -268,14 +268,27 @@ Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.cl
|
|||
**Prerequisites:** Node.js 20+, pnpm 10+
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
||||
cd claude_agent_teams_ui
|
||||
git clone https://github.com/777genius/agent-teams-ai.git
|
||||
cd agent-teams-ai
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The app auto-discovers Claude Code projects from `~/.claude/`.
|
||||
|
||||
### Debug teammate runtimes
|
||||
|
||||
Development launches use the app-managed process backend for teammates by default. To inspect
|
||||
teammates in `tmux` panes while debugging, start the desktop app with:
|
||||
|
||||
```bash
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
```
|
||||
|
||||
The same override is available per launch from custom CLI args with
|
||||
`--teammate-mode tmux`. Use this as an operator/debug mode; the default process backend provides
|
||||
stronger app-owned lifecycle, diagnostics, and cleanup for normal team launches.
|
||||
|
||||
### Build for distribution
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -75,11 +75,26 @@ function normalizeMessageKind(messageKind) {
|
|||
return messageKind === 'default' ||
|
||||
messageKind === 'slash_command' ||
|
||||
messageKind === 'slash_command_result' ||
|
||||
messageKind === 'task_comment_notification'
|
||||
messageKind === 'task_comment_notification' ||
|
||||
messageKind === 'member_work_sync_nudge'
|
||||
? messageKind
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeWorkSyncIntent(workSyncIntent) {
|
||||
return workSyncIntent === 'agenda_sync' || workSyncIntent === 'review_pickup'
|
||||
? workSyncIntent
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const items = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))];
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
function normalizeSlashCommand(slashCommand) {
|
||||
if (!slashCommand || typeof slashCommand !== 'object') {
|
||||
return undefined;
|
||||
|
|
@ -123,6 +138,8 @@ function buildMessage(flags, defaults) {
|
|||
const attachments = normalizeAttachments(flags.attachments);
|
||||
const taskRefs = normalizeTaskRefs(flags.taskRefs);
|
||||
const messageKind = normalizeMessageKind(flags.messageKind);
|
||||
const workSyncIntent = normalizeWorkSyncIntent(flags.workSyncIntent);
|
||||
const workSyncReviewRequestEventIds = normalizeStringList(flags.workSyncReviewRequestEventIds);
|
||||
const slashCommand = normalizeSlashCommand(flags.slashCommand);
|
||||
const commandOutput = normalizeCommandOutput(flags.commandOutput);
|
||||
|
||||
|
|
@ -173,6 +190,11 @@ function buildMessage(flags, defaults) {
|
|||
}
|
||||
: {}),
|
||||
...(messageKind ? { messageKind } : {}),
|
||||
...(workSyncIntent ? { workSyncIntent } : {}),
|
||||
...(typeof flags.workSyncIntentKey === 'string' && flags.workSyncIntentKey.trim()
|
||||
? { workSyncIntentKey: flags.workSyncIntentKey.trim() }
|
||||
: {}),
|
||||
...(workSyncReviewRequestEventIds ? { workSyncReviewRequestEventIds } : {}),
|
||||
...(slashCommand ? { slashCommand } : {}),
|
||||
...(commandOutput ? { commandOutput } : {}),
|
||||
...(attachments ? { attachments } : {}),
|
||||
|
|
|
|||
|
|
@ -66,6 +66,54 @@ function normalizeActorKey(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function closeTimestampForInterval(interval, timestamp) {
|
||||
const startedAtMs = Date.parse(interval.startedAt);
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
if (Number.isFinite(startedAtMs) && Number.isFinite(timestampMs) && timestampMs < startedAtMs) {
|
||||
return interval.startedAt;
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function isOpenReviewInterval(interval) {
|
||||
return interval && interval.completedAt === undefined;
|
||||
}
|
||||
|
||||
function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) {
|
||||
const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : '';
|
||||
if (!reviewerName) return false;
|
||||
const reviewerKey = normalizeActorKey(reviewerName);
|
||||
const intervals = Array.isArray(task.reviewIntervals) ? [...task.reviewIntervals] : [];
|
||||
let changed = false;
|
||||
let hasOpenForReviewer = false;
|
||||
const nextIntervals = intervals.map((interval) => {
|
||||
if (!isOpenReviewInterval(interval)) return interval;
|
||||
if (normalizeActorKey(interval.reviewer) === reviewerKey) {
|
||||
hasOpenForReviewer = true;
|
||||
return interval;
|
||||
}
|
||||
changed = true;
|
||||
return { ...interval, completedAt: closeTimestampForInterval(interval, timestamp) };
|
||||
});
|
||||
if (hasOpenForReviewer) {
|
||||
task.reviewIntervals = nextIntervals;
|
||||
return changed;
|
||||
}
|
||||
task.reviewIntervals = [...nextIntervals, { reviewer: reviewerName, startedAt: timestamp }];
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeReviewIntervals(task, timestamp = new Date().toISOString()) {
|
||||
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||
let changed = false;
|
||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||
if (!isOpenReviewInterval(interval)) return interval;
|
||||
changed = true;
|
||||
return { ...interval, completedAt: closeTimestampForInterval(interval, timestamp) };
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function resolveKnownActorName(context, value, label) {
|
||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
if (!actor) return null;
|
||||
|
|
@ -130,7 +178,9 @@ function getReviewStartActor(context, task, flags) {
|
|||
return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer');
|
||||
}
|
||||
|
||||
throw new Error(`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`);
|
||||
throw new Error(
|
||||
`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`
|
||||
);
|
||||
}
|
||||
|
||||
function getLatestReviewStartedActor(task) {
|
||||
|
|
@ -155,18 +205,25 @@ function getLatestReviewStartedActor(task) {
|
|||
|
||||
function getReviewDecisionActor(context, task, flags, actionName) {
|
||||
const explicit = resolveKnownActorName(context, flags.from, 'review actor');
|
||||
const startedActor = tryResolveKnownActorName(context, getLatestReviewStartedActor(task), 'review actor');
|
||||
const assignedReviewer = tryResolveKnownActorName(context, getLatestReviewRequestedReviewer(task), 'reviewer');
|
||||
const startedActor = tryResolveKnownActorName(
|
||||
context,
|
||||
getLatestReviewStartedActor(task),
|
||||
'review actor'
|
||||
);
|
||||
const assignedReviewer = tryResolveKnownActorName(
|
||||
context,
|
||||
getLatestReviewRequestedReviewer(task),
|
||||
'reviewer'
|
||||
);
|
||||
const inferredActor =
|
||||
startedActor &&
|
||||
(!assignedReviewer ||
|
||||
resolveActorIdentityKey(context, startedActor) === resolveActorIdentityKey(context, assignedReviewer))
|
||||
resolveActorIdentityKey(context, startedActor) ===
|
||||
resolveActorIdentityKey(context, assignedReviewer))
|
||||
? startedActor
|
||||
: assignedReviewer;
|
||||
const actor =
|
||||
explicit ||
|
||||
inferredActor ||
|
||||
resolveKnownActorName(context, 'team-lead', 'review actor');
|
||||
explicit || inferredActor || resolveKnownActorName(context, 'team-lead', 'review actor');
|
||||
assertMatchesAssignedReviewer(context, task, actor, actionName);
|
||||
return actor;
|
||||
}
|
||||
|
|
@ -176,12 +233,16 @@ function assertReviewTransitionAllowed(context, task, transitionName) {
|
|||
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
||||
}
|
||||
if (task.status !== 'completed') {
|
||||
throw new Error(`Task #${task.displayId || task.id} must be completed before ${transitionName}`);
|
||||
throw new Error(
|
||||
`Task #${task.displayId || task.id} must be completed before ${transitionName}`
|
||||
);
|
||||
}
|
||||
|
||||
const reviewState = getEffectiveReviewState(context, task);
|
||||
if (reviewState !== 'review') {
|
||||
throw new Error(`Task #${task.displayId || task.id} must be in review before ${transitionName}`);
|
||||
throw new Error(
|
||||
`Task #${task.displayId || task.id} must be in review before ${transitionName}`
|
||||
);
|
||||
}
|
||||
return reviewState;
|
||||
}
|
||||
|
|
@ -223,9 +284,14 @@ function startReview(context, taskId, flags = {}) {
|
|||
|
||||
if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
|
||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||
const existingActor = typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
|
||||
const existingActor =
|
||||
typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
|
||||
const existingActorValid = existingActor
|
||||
? Boolean(runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { allowLeadAliases: true }))
|
||||
? Boolean(
|
||||
runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, {
|
||||
allowLeadAliases: true,
|
||||
})
|
||||
)
|
||||
: false;
|
||||
const assignedReviewer = tryResolveKnownActorName(
|
||||
context,
|
||||
|
|
@ -235,7 +301,8 @@ function startReview(context, taskId, flags = {}) {
|
|||
const existingMatchesAssigned =
|
||||
!assignedReviewer ||
|
||||
(existingActorValid &&
|
||||
resolveActorIdentityKey(context, existingActor) === resolveActorIdentityKey(context, assignedReviewer));
|
||||
resolveActorIdentityKey(context, existingActor) ===
|
||||
resolveActorIdentityKey(context, assignedReviewer));
|
||||
const requestedActor =
|
||||
typeof flags.from === 'string' && flags.from.trim()
|
||||
? getReviewStartActor(context, task, flags)
|
||||
|
|
@ -244,38 +311,52 @@ function startReview(context, taskId, flags = {}) {
|
|||
existingActorValid &&
|
||||
existingMatchesAssigned &&
|
||||
requestedActor &&
|
||||
resolveActorIdentityKey(context, existingActor) !== resolveActorIdentityKey(context, requestedActor)
|
||||
resolveActorIdentityKey(context, existingActor) !==
|
||||
resolveActorIdentityKey(context, requestedActor)
|
||||
) {
|
||||
throw new Error(`Task #${task.displayId || task.id} review is already started by ${existingActor}`);
|
||||
throw new Error(
|
||||
`Task #${task.displayId || task.id} review is already started by ${existingActor}`
|
||||
);
|
||||
}
|
||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||
if (!existingActorValid || !existingMatchesAssigned) {
|
||||
const repairedActor = requestedActor || getReviewStartActor(context, task, flags);
|
||||
const timestamp = new Date().toISOString();
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
openReviewInterval(t, repairedActor, timestamp);
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_started',
|
||||
from: prevReviewState,
|
||||
to: 'review',
|
||||
actor: repairedActor,
|
||||
timestamp,
|
||||
});
|
||||
t.reviewState = 'review';
|
||||
return t;
|
||||
});
|
||||
} else {
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
openReviewInterval(t, existingActor, latestReviewEvent.timestamp);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||
}
|
||||
|
||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||
const from = getReviewStartActor(context, task, flags);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
openReviewInterval(t, from, timestamp);
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_started',
|
||||
from: prevReviewState,
|
||||
to: 'review',
|
||||
actor: from,
|
||||
timestamp,
|
||||
});
|
||||
t.reviewState = 'review';
|
||||
return t;
|
||||
|
|
@ -285,7 +366,10 @@ function startReview(context, taskId, flags = {}) {
|
|||
try {
|
||||
kanban.clearKanban(context, task.id, { transition: 'rollback' });
|
||||
} catch (rollbackError) {
|
||||
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError);
|
||||
warnNonCritical(
|
||||
`[review] rollback failed while starting review for ${task.id}`,
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -296,17 +380,23 @@ function requestReview(context, taskId, flags = {}) {
|
|||
const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => {
|
||||
const currentTask = tasks.getTask(context, taskId);
|
||||
if (currentTask.status !== 'completed') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`);
|
||||
throw new Error(
|
||||
`Task #${currentTask.displayId || currentTask.id} must be completed before review`
|
||||
);
|
||||
}
|
||||
|
||||
const nextFrom =
|
||||
resolveKnownActorName(context, flags.from, 'review requester') ||
|
||||
resolveKnownActorName(context, 'team-lead', 'review requester');
|
||||
const rawReviewer = getReviewer(context, flags);
|
||||
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
|
||||
const nextReviewer = rawReviewer
|
||||
? resolveKnownActorName(context, rawReviewer, 'reviewer')
|
||||
: null;
|
||||
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||
if (prevReviewState === 'approved') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
||||
throw new Error(
|
||||
`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -326,7 +416,10 @@ function requestReview(context, taskId, flags = {}) {
|
|||
try {
|
||||
kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
|
||||
} catch (rollbackError) {
|
||||
warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError);
|
||||
warnNonCritical(
|
||||
`[review] rollback failed while requesting review for ${currentTask.id}`,
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -350,7 +443,9 @@ function requestReview(context, taskId, flags = {}) {
|
|||
text:
|
||||
`**Please review** task #${task.displayId || task.id}\n\n` +
|
||||
wrapAgentBlock(
|
||||
`FIRST call review_start to signal you are beginning the review:\n` +
|
||||
`This request is for the CURRENT review cycle. If you reviewed this task earlier, do not treat this message as a duplicate while the task is still in review; prior approvals become stale after later work.\n\n` +
|
||||
`Before declaring it duplicate, call task_get and check the current reviewState/status. If it is still in review for you, continue with the review tools below.\n\n` +
|
||||
`FIRST call review_start to signal you are beginning the review:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>" }\n\n` +
|
||||
`When approved, use MCP tool review_approve:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>", note?: "<optional note>", notifyOwner: true }\n\n` +
|
||||
|
|
@ -383,7 +478,9 @@ function approveReview(context, taskId, flags = {}) {
|
|||
|
||||
if (prevReviewState === 'approved') {
|
||||
if (currentTask.status !== 'completed') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before approval`);
|
||||
throw new Error(
|
||||
`Task #${currentTask.displayId || currentTask.id} must be completed before approval`
|
||||
);
|
||||
}
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
||||
return {
|
||||
|
|
@ -399,15 +496,18 @@ function approveReview(context, taskId, flags = {}) {
|
|||
}
|
||||
|
||||
assertReviewTransitionAllowed(context, currentTask, 'approval');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
closeReviewIntervals(t, timestamp);
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_approved',
|
||||
from: prevReviewState,
|
||||
to: 'approved',
|
||||
...(nextNote ? { note: nextNote } : {}),
|
||||
actor: nextFrom,
|
||||
timestamp,
|
||||
});
|
||||
t.reviewState = 'approved';
|
||||
return t;
|
||||
|
|
@ -472,15 +572,22 @@ function requestChanges(context, taskId, flags = {}) {
|
|||
typeof flags.comment === 'string' && flags.comment.trim()
|
||||
? flags.comment.trim()
|
||||
: 'Reviewer requested changes.';
|
||||
const prevReviewState = assertReviewTransitionAllowed(context, currentTask, 'requesting changes');
|
||||
const prevReviewState = assertReviewTransitionAllowed(
|
||||
context,
|
||||
currentTask,
|
||||
'requesting changes'
|
||||
);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
closeReviewIntervals(t, timestamp);
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_changes_requested',
|
||||
from: prevReviewState,
|
||||
to: 'needsFix',
|
||||
...(nextComment ? { note: nextComment } : {}),
|
||||
actor: nextFrom,
|
||||
timestamp,
|
||||
});
|
||||
t.reviewState = 'needsFix';
|
||||
return t;
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ function normalizeTask(rawTask, filePath) {
|
|||
};
|
||||
|
||||
if (!TASK_STATUSES.has(String(task.status || '').trim())) {
|
||||
throw new Error(`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`);
|
||||
throw new Error(
|
||||
`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`
|
||||
);
|
||||
}
|
||||
task.status = String(task.status).trim();
|
||||
|
||||
|
|
@ -121,10 +123,14 @@ function listTaskRows(paths, options = {}) {
|
|||
}
|
||||
|
||||
tasks.sort((a, b) => {
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(
|
||||
String(b.displayId || b.id),
|
||||
undefined,
|
||||
{
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
}
|
||||
);
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||
numeric: true,
|
||||
|
|
@ -144,7 +150,9 @@ function listTasks(paths, options = {}) {
|
|||
}
|
||||
|
||||
function resolveTaskRef(paths, taskRef, options = {}) {
|
||||
const normalizedRef = String(taskRef || '').trim().replace(/^#/, '');
|
||||
const normalizedRef = String(taskRef || '')
|
||||
.trim()
|
||||
.replace(/^#/, '');
|
||||
if (!normalizedRef) {
|
||||
throw new Error('Missing taskId');
|
||||
}
|
||||
|
|
@ -168,9 +176,7 @@ function resolveTaskRef(paths, taskRef, options = {}) {
|
|||
}
|
||||
|
||||
const byDisplay = tasks.find(
|
||||
(task) =>
|
||||
task.displayId === normalizedRef &&
|
||||
(includeDeleted || task.status !== 'deleted')
|
||||
(task) => task.displayId === normalizedRef && (includeDeleted || task.status !== 'deleted')
|
||||
);
|
||||
if (byDisplay) {
|
||||
return byDisplay.id;
|
||||
|
|
@ -195,6 +201,27 @@ function appendHistoryEvent(events, event) {
|
|||
return list;
|
||||
}
|
||||
|
||||
function isOpenReviewInterval(interval) {
|
||||
return interval && interval.completedAt === undefined;
|
||||
}
|
||||
|
||||
function closeOpenReviewIntervals(task, timestamp) {
|
||||
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||
let changed = false;
|
||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||
if (!isOpenReviewInterval(interval)) return interval;
|
||||
changed = true;
|
||||
const startedAtMs = Date.parse(interval.startedAt);
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const completedAt =
|
||||
Number.isFinite(startedAtMs) && Number.isFinite(timestampMs) && timestampMs < startedAtMs
|
||||
? interval.startedAt
|
||||
: timestamp;
|
||||
return { ...interval, completedAt };
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function normalizeStatus(status) {
|
||||
const normalized = String(status || '').trim();
|
||||
return TASK_STATUSES.has(normalized) ? normalized : null;
|
||||
|
|
@ -204,7 +231,10 @@ function parseRelationshipList(paths, value) {
|
|||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value.split(',').map((entry) => entry.trim()).filter(Boolean)
|
||||
? value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return rawValues.map((entry) => resolveTaskRef(paths, entry));
|
||||
|
|
@ -248,7 +278,9 @@ function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) {
|
|||
? explicitDisplayId.trim()
|
||||
: deriveDisplayId(canonicalId);
|
||||
|
||||
const existing = new Set(listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id)));
|
||||
const existing = new Set(
|
||||
listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id))
|
||||
);
|
||||
if (!existing.has(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
|
|
@ -310,7 +342,9 @@ function createTask(paths, input = {}) {
|
|||
? input.createdBy.trim()
|
||||
: undefined;
|
||||
const createdAt =
|
||||
typeof input.createdAt === 'string' && input.createdAt.trim() ? input.createdAt.trim() : nowIso();
|
||||
typeof input.createdAt === 'string' && input.createdAt.trim()
|
||||
? input.createdAt.trim()
|
||||
: nowIso();
|
||||
const status = computeInitialStatus(paths, input, owner, blockedByIds);
|
||||
const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId);
|
||||
|
||||
|
|
@ -429,7 +463,10 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
if (task.status === status) {
|
||||
if (status === 'deleted' || status === 'in_progress') {
|
||||
task.reviewState = 'none';
|
||||
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') {
|
||||
} else if (
|
||||
status === 'pending' &&
|
||||
normalizeTaskReviewState(task.reviewState) !== 'needsFix'
|
||||
) {
|
||||
task.reviewState = 'none';
|
||||
}
|
||||
return task;
|
||||
|
|
@ -439,7 +476,7 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
|
||||
|
||||
if (task.status !== 'in_progress' && status === 'in_progress') {
|
||||
if (!lastInterval || typeof lastInterval.completedAt === 'string') {
|
||||
if (!lastInterval || lastInterval.completedAt !== undefined) {
|
||||
workIntervals.push({ startedAt: timestamp });
|
||||
}
|
||||
} else if (task.status === 'in_progress' && status !== 'in_progress') {
|
||||
|
|
@ -447,6 +484,9 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
lastInterval.completedAt = timestamp;
|
||||
}
|
||||
}
|
||||
if (status === 'pending' || status === 'in_progress' || status === 'deleted') {
|
||||
closeOpenReviewIntervals(task, timestamp);
|
||||
}
|
||||
|
||||
task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
|
||||
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
||||
|
|
@ -531,16 +571,16 @@ function addTaskComment(paths, taskRef, text, options = {}) {
|
|||
const comment = {
|
||||
id: options.id || crypto.randomUUID(),
|
||||
author:
|
||||
typeof options.author === 'string' && options.author.trim()
|
||||
? options.author.trim()
|
||||
: 'user',
|
||||
typeof options.author === 'string' && options.author.trim() ? options.author.trim() : 'user',
|
||||
text,
|
||||
createdAt:
|
||||
typeof options.createdAt === 'string' && options.createdAt.trim()
|
||||
? options.createdAt.trim()
|
||||
: nowIso(),
|
||||
type: options.type || 'regular',
|
||||
...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}),
|
||||
...(normalizeTaskRefs(options.taskRefs)
|
||||
? { taskRefs: normalizeTaskRefs(options.taskRefs) }
|
||||
: {}),
|
||||
...(Array.isArray(options.attachments) && options.attachments.length > 0
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
|
|
@ -711,10 +751,14 @@ function getTaskFreshness(task) {
|
|||
function compareTasksByFreshness(a, b) {
|
||||
const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a);
|
||||
if (freshnessDiff !== 0) return freshnessDiff;
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(
|
||||
String(b.displayId || b.id),
|
||||
undefined,
|
||||
{
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
}
|
||||
);
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||
numeric: true,
|
||||
|
|
@ -756,7 +800,9 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
|||
in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
|
||||
needs_fix: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
return task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix';
|
||||
return (
|
||||
task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix'
|
||||
);
|
||||
}),
|
||||
pending: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ describe('agent-teams-controller API', () => {
|
|||
const address = server.address();
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () => await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
|
||||
close: async () =>
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -146,8 +149,12 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain('Implement carefully');
|
||||
expect(briefing).toContain('Working directory: /tmp/project-x');
|
||||
expect(briefing).toContain('Task briefing for bob:');
|
||||
expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.');
|
||||
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
|
||||
expect(briefing).toContain(
|
||||
'Use task_briefing as your primary working queue whenever you need to see assigned work.'
|
||||
);
|
||||
expect(briefing).toContain(
|
||||
'Use task_list only to search/browse inventory rows, not as your working queue.'
|
||||
);
|
||||
expect(briefing).toContain('member_work_sync_status and member_work_sync_report');
|
||||
expect(briefing).toContain(
|
||||
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||
|
|
@ -175,9 +182,7 @@ describe('agent-teams-controller API', () => {
|
|||
);
|
||||
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
|
||||
expect(briefing).toContain('OpenCode bootstrap silence rule');
|
||||
expect(briefing).toContain(
|
||||
'If it shows no actionable tasks, stop and wait silently.'
|
||||
);
|
||||
expect(briefing).toContain('If it shows no actionable tasks, stop and wait silently.');
|
||||
expect(briefing).toContain(
|
||||
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
|
||||
);
|
||||
|
|
@ -478,7 +483,10 @@ describe('agent-teams-controller API', () => {
|
|||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' });
|
||||
controller.tasks.addTaskComment(activeTask.id, {
|
||||
from: 'bob',
|
||||
text: 'Resumed work with latest context.',
|
||||
});
|
||||
const needsFixTask = controller.tasks.createTask({
|
||||
subject: 'Fix after review',
|
||||
owner: 'bob',
|
||||
|
|
@ -517,7 +525,9 @@ describe('agent-teams-controller API', () => {
|
|||
expect(ownerInbox[0].text).toContain('task_get');
|
||||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].text).toContain('task_add_comment');
|
||||
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
||||
);
|
||||
|
|
@ -527,7 +537,9 @@ describe('agent-teams-controller API', () => {
|
|||
expect(ownerInbox[0].text).toContain('Check the migration plan first.');
|
||||
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
|
||||
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
|
||||
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[3].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
|
|
@ -549,9 +561,7 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain(`#${reviewTask.displayId}`);
|
||||
expect(briefing).toContain('reason=review_reviewer_missing');
|
||||
expect(briefing).toContain(`#${completedTask.displayId}`);
|
||||
expect(briefing).not.toContain(
|
||||
'Completed task description should stay out of compact rows'
|
||||
);
|
||||
expect(briefing).not.toContain('Completed task description should stay out of compact rows');
|
||||
expect(briefing).toContain(`#${approvedTask.displayId}`);
|
||||
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
||||
});
|
||||
|
|
@ -709,12 +719,24 @@ describe('agent-teams-controller API', () => {
|
|||
const firstEvent = restored.historyEvents[0];
|
||||
expect(firstEvent.status).toBe('pending');
|
||||
const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
|
||||
expect(statusChanges).toEqual([
|
||||
'in_progress',
|
||||
'completed',
|
||||
'deleted',
|
||||
'pending',
|
||||
]);
|
||||
expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']);
|
||||
});
|
||||
|
||||
it('does not treat malformed empty completedAt work intervals as already open', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Malformed work interval' });
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
rawTask.workIntervals = [{ startedAt: '2026-01-01T00:00:00.000Z', completedAt: '' }];
|
||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||
|
||||
controller.tasks.startTask(task.id, 'bob');
|
||||
const reloaded = controller.tasks.getTask(task.id);
|
||||
|
||||
expect(reloaded.workIntervals).toHaveLength(2);
|
||||
expect(reloaded.workIntervals[0].completedAt).toBe('');
|
||||
expect(reloaded.workIntervals[1].completedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tracks owner assignment history without duplicate same-owner events', () => {
|
||||
|
|
@ -752,6 +774,9 @@ describe('agent-teams-controller API', () => {
|
|||
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].text).toContain('<info_for_agent>');
|
||||
expect(inbox[0].text).toContain('CURRENT review cycle');
|
||||
expect(inbox[0].text).toContain('Before declaring it duplicate, call task_get');
|
||||
expect(inbox[0].text).toContain('reviewState/status');
|
||||
expect(inbox[0].text).toContain('review_approve');
|
||||
expect(inbox[0].text).not.toContain('<agent-block>');
|
||||
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
||||
|
|
@ -804,6 +829,10 @@ describe('agent-teams-controller API', () => {
|
|||
expect(reviewEvent.from).toBe('review');
|
||||
expect(reviewEvent.to).toBe('review');
|
||||
expect(reviewEvent.actor).toBe('alice');
|
||||
expect(updatedTask.reviewIntervals).toHaveLength(1);
|
||||
expect(updatedTask.reviewIntervals[0].reviewer).toBe('alice');
|
||||
expect(updatedTask.reviewIntervals[0].startedAt).toBeTruthy();
|
||||
expect(updatedTask.reviewIntervals[0].completedAt).toBeUndefined();
|
||||
|
||||
// Idempotent: calling again should also succeed without duplicate events
|
||||
const again = controller.review.startReview(task.id, { from: 'alice' });
|
||||
|
|
@ -811,6 +840,59 @@ describe('agent-teams-controller API', () => {
|
|||
const reloaded = controller.tasks.getTask(task.id);
|
||||
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
|
||||
expect(startedEvents).toHaveLength(1);
|
||||
expect(reloaded.reviewIntervals).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('closes review intervals when review is approved or changes are requested', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const approvedTask = controller.tasks.createTask({ subject: 'Approve review', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(approvedTask.id, 'bob');
|
||||
controller.review.requestReview(approvedTask.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.review.startReview(approvedTask.id, { from: 'alice' });
|
||||
const approved = controller.review.approveReview(approvedTask.id, { from: 'alice' });
|
||||
|
||||
expect(approved.reviewIntervals).toHaveLength(1);
|
||||
expect(approved.reviewIntervals[0].reviewer).toBe('alice');
|
||||
expect(approved.reviewIntervals[0].completedAt).toBeTruthy();
|
||||
|
||||
const changesTask = controller.tasks.createTask({ subject: 'Request changes', owner: 'bob' });
|
||||
controller.tasks.completeTask(changesTask.id, 'bob');
|
||||
controller.review.requestReview(changesTask.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.review.startReview(changesTask.id, { from: 'alice' });
|
||||
const changed = controller.review.requestChanges(changesTask.id, {
|
||||
from: 'alice',
|
||||
comment: 'Needs a fix.',
|
||||
});
|
||||
|
||||
expect(changed.reviewIntervals).toHaveLength(1);
|
||||
expect(changed.reviewIntervals[0].reviewer).toBe('alice');
|
||||
expect(changed.reviewIntervals[0].completedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not treat malformed empty completedAt review intervals as already open', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
rawTask.reviewIntervals = [
|
||||
{ reviewer: 'alice', startedAt: '2026-01-01T00:00:00.000Z', completedAt: '' },
|
||||
];
|
||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||
|
||||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
const reloaded = controller.tasks.getTask(task.id);
|
||||
|
||||
expect(reloaded.reviewIntervals).toHaveLength(2);
|
||||
expect(reloaded.reviewIntervals[0].completedAt).toBe('');
|
||||
expect(reloaded.reviewIntervals[1].reviewer).toBe('alice');
|
||||
expect(reloaded.reviewIntervals[1].completedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
|
||||
|
|
@ -841,7 +923,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('uses the assigned reviewer when review_start omits from', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Queued for implicit reviewer', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Queued for implicit reviewer',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
|
|
@ -866,15 +951,23 @@ describe('agent-teams-controller API', () => {
|
|||
'must be completed before approval'
|
||||
);
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Completed but not review', owner: 'bob' });
|
||||
const completedTask = controller.tasks.createTask({
|
||||
subject: 'Completed but not review',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() =>
|
||||
controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' })
|
||||
).toThrow('must be in review before requesting changes');
|
||||
|
||||
const deletedTask = controller.tasks.createTask({ subject: 'Deleted review task', owner: 'bob' });
|
||||
const deletedTask = controller.tasks.createTask({
|
||||
subject: 'Deleted review task',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.softDeleteTask(deletedTask.id, 'bob');
|
||||
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow('is deleted');
|
||||
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow(
|
||||
'is deleted'
|
||||
);
|
||||
expect(() =>
|
||||
controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' })
|
||||
).toThrow('is deleted');
|
||||
|
|
@ -885,13 +978,19 @@ describe('agent-teams-controller API', () => {
|
|||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({ subject: 'Pending implementation', owner: 'bob' });
|
||||
const pendingTask = controller.tasks.createTask({
|
||||
subject: 'Pending implementation',
|
||||
owner: 'bob',
|
||||
});
|
||||
expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow(
|
||||
'must be completed before starting review'
|
||||
);
|
||||
expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none');
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Completed without review request', owner: 'bob' });
|
||||
const completedTask = controller.tasks.createTask({
|
||||
subject: 'Completed without review request',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
|
||||
'must be in review before starting review'
|
||||
|
|
@ -907,12 +1006,18 @@ describe('agent-teams-controller API', () => {
|
|||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({ subject: 'Kanban bypass pending', owner: 'bob' });
|
||||
const pendingTask = controller.tasks.createTask({
|
||||
subject: 'Kanban bypass pending',
|
||||
owner: 'bob',
|
||||
});
|
||||
expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow(
|
||||
'must be completed before moving to APPROVED column'
|
||||
);
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Kanban bypass completed', owner: 'bob' });
|
||||
const completedTask = controller.tasks.createTask({
|
||||
subject: 'Kanban bypass completed',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
|
||||
'must be in review before moving to REVIEW column'
|
||||
|
|
@ -938,9 +1043,9 @@ describe('agent-teams-controller API', () => {
|
|||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
||||
expect(() => controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })).toThrow(
|
||||
'is already approved'
|
||||
);
|
||||
expect(() =>
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })
|
||||
).toThrow('is already approved');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||
});
|
||||
|
|
@ -963,7 +1068,9 @@ describe('agent-teams-controller API', () => {
|
|||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review');
|
||||
expect(
|
||||
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_started')
|
||||
controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_started')
|
||||
).toHaveLength(1);
|
||||
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
|
@ -976,7 +1083,9 @@ describe('agent-teams-controller API', () => {
|
|||
expect(approvedAgain.alreadyApproved).toBe(true);
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||
expect(
|
||||
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_approved')
|
||||
controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_approved')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
@ -1021,7 +1130,10 @@ describe('agent-teams-controller API', () => {
|
|||
commentId: 'comment-123',
|
||||
relayOfMessageId: 'm-original-1',
|
||||
source: 'system_notification',
|
||||
messageKind: 'task_comment_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'review_pickup',
|
||||
workSyncIntentKey: 'review-pickup:evt-1',
|
||||
workSyncReviewRequestEventIds: ['evt-1'],
|
||||
leadSessionId: 'session-42',
|
||||
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
|
||||
});
|
||||
|
|
@ -1033,7 +1145,10 @@ describe('agent-teams-controller API', () => {
|
|||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].source).toBe('system_notification');
|
||||
expect(rows[0].messageKind).toBe('task_comment_notification');
|
||||
expect(rows[0].messageKind).toBe('member_work_sync_nudge');
|
||||
expect(rows[0].workSyncIntent).toBe('review_pickup');
|
||||
expect(rows[0].workSyncIntentKey).toBe('review-pickup:evt-1');
|
||||
expect(rows[0].workSyncReviewRequestEventIds).toEqual(['evt-1']);
|
||||
expect(rows[0].commentId).toBe('comment-123');
|
||||
expect(rows[0].relayOfMessageId).toBe('m-original-1');
|
||||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
|
|
@ -1189,7 +1304,11 @@ describe('agent-teams-controller API', () => {
|
|||
it('wakes task owner on regular comment from another member', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Investigate',
|
||||
owner: 'bob',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
const commented = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'alice',
|
||||
|
|
@ -1354,7 +1473,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('rejects task comments from unknown authors', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Reject unknown author', notifyOwner: false });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Reject unknown author',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
controller.tasks.addTaskComment(task.id, {
|
||||
|
|
@ -1374,7 +1496,10 @@ describe('agent-teams-controller API', () => {
|
|||
claudeDir,
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
const task = appController.tasks.createTask({ subject: 'Reserved comment authors', notifyOwner: false });
|
||||
const task = appController.tasks.createTask({
|
||||
subject: 'Reserved comment authors',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
const appComment = appController.tasks.addTaskComment(task.id, {
|
||||
from: 'user',
|
||||
|
|
@ -1803,11 +1928,19 @@ describe('agent-teams-controller API', () => {
|
|||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
|
||||
const leadOwnedTask = controller.tasks.createTask({
|
||||
subject: 'Lead alias owner',
|
||||
owner: 'lead',
|
||||
});
|
||||
expect(leadOwnedTask.owner).toBe('leadbot');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' });
|
||||
const reassignedTask = controller.tasks.createTask({
|
||||
subject: 'Reassign alias owner',
|
||||
owner: 'bob',
|
||||
});
|
||||
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
|
||||
|
||||
controller.kanban.addReviewer('lead');
|
||||
|
|
@ -1822,8 +1955,12 @@ describe('agent-teams-controller API', () => {
|
|||
.historyEvents.filter((event) => event.type === 'review_requested')
|
||||
.at(-1);
|
||||
expect(requested.reviewer).toBe('leadbot');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects task_briefing for unknown members', async () => {
|
||||
|
|
@ -1879,7 +2016,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('clears kanban tasks and column order when task_set_status deletes a review task', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Generic status delete cleanup', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Generic status delete cleanup',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
|
|
@ -1956,7 +2096,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Do not unapprove directly', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Do not unapprove directly',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
|
|
@ -1980,11 +2123,13 @@ describe('agent-teams-controller API', () => {
|
|||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' });
|
||||
|
||||
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow('Unknown task owner: boob');
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
expect(() => controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })).toThrow(
|
||||
'Unknown reviewer: boob'
|
||||
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow(
|
||||
'Unknown task owner: boob'
|
||||
);
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
expect(() =>
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })
|
||||
).toThrow('Unknown reviewer: boob');
|
||||
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
|
|
@ -2006,8 +2151,12 @@ describe('agent-teams-controller API', () => {
|
|||
|
||||
controller.tasks.softDeleteTask(task.id, 'bob');
|
||||
|
||||
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow('use task_restore before starting work');
|
||||
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow('use task_restore before changing status');
|
||||
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow(
|
||||
'use task_restore before starting work'
|
||||
);
|
||||
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow(
|
||||
'use task_restore before changing status'
|
||||
);
|
||||
expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
|
||||
'use task_restore before changing status'
|
||||
);
|
||||
|
|
@ -2020,7 +2169,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('rejects task_restore for non-deleted tasks', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Approved task must stay approved',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
|
|
@ -2047,7 +2199,9 @@ describe('agent-teams-controller API', () => {
|
|||
delete state.tasks[task.id];
|
||||
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
|
||||
|
||||
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)).toContain(task.id);
|
||||
expect(
|
||||
controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)
|
||||
).toContain(task.id);
|
||||
expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
@ -2090,7 +2244,10 @@ describe('agent-teams-controller API', () => {
|
|||
config.members.push({ name: 'carol', role: 'reviewer' });
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Repair mismatched reviewer actor', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Repair mismatched reviewer actor',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
|
|
@ -2105,13 +2262,22 @@ describe('agent-teams-controller API', () => {
|
|||
to: 'review',
|
||||
actor: 'carol',
|
||||
});
|
||||
rawTask.reviewIntervals = [{ reviewer: 'carol', startedAt: '2026-01-01T00:00:00.000Z' }];
|
||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||
|
||||
controller.review.startReview(task.id);
|
||||
const startedEvents = controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_started');
|
||||
const repairedTask = controller.tasks.getTask(task.id);
|
||||
const startedEvents = repairedTask.historyEvents.filter(
|
||||
(event) => event.type === 'review_started'
|
||||
);
|
||||
expect(startedEvents.at(-1).actor).toBe('alice');
|
||||
expect(repairedTask.reviewIntervals).toHaveLength(2);
|
||||
expect(repairedTask.reviewIntervals[0]).toMatchObject({
|
||||
reviewer: 'carol',
|
||||
completedAt: expect.any(String),
|
||||
});
|
||||
expect(repairedTask.reviewIntervals[1].reviewer).toBe('alice');
|
||||
expect(repairedTask.reviewIntervals[1].completedAt).toBeUndefined();
|
||||
|
||||
const aliceBriefing = await controller.tasks.taskBriefing('alice');
|
||||
const carolBriefing = await controller.tasks.taskBriefing('carol');
|
||||
|
|
@ -2124,7 +2290,11 @@ describe('agent-teams-controller API', () => {
|
|||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const longSubject = `Long subject ${'x'.repeat(5000)}`;
|
||||
const task = controller.tasks.createTask({ subject: longSubject, owner: 'bob', notifyOwner: false });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: longSubject,
|
||||
owner: 'bob',
|
||||
notifyOwner: false,
|
||||
});
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
fs.writeFileSync(
|
||||
kanbanPath,
|
||||
|
|
@ -2147,7 +2317,11 @@ describe('agent-teams-controller API', () => {
|
|||
'utf8'
|
||||
);
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`), '{ bad json', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`),
|
||||
'{ bad json',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
const briefing = await controller.tasks.leadBriefing();
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -3,7 +3,7 @@
|
|||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "claude-agent-teams-ui",
|
||||
"name": "agent-teams-ai",
|
||||
"dependencies": {
|
||||
"@claude-teams/agent-graph": "workspace:*",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Initial release: Agent Teams with reliable CLI detection in packaged builds (she
|
|||
After CI uploads artifacts, optional notes update:
|
||||
|
||||
```bash
|
||||
gh release edit v1.0.0 --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF'
|
||||
gh release edit v1.0.0 --repo 777genius/agent-teams-ai --notes "$(cat <<'EOF'
|
||||
## Agent Teams v1.0.0
|
||||
|
||||
First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging.
|
||||
|
|
@ -37,33 +37,33 @@ First stable build: CLI/auth reliability in packaged apps, IPC hardening, and pl
|
|||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0-arm64.dmg">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
||||
</a>
|
||||
<br />
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.dmg">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.dmg">
|
||||
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI.Setup.1.0.0.exe">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI.Setup.1.0.0.exe">
|
||||
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.AppImage">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/Claude.Agent.Teams.UI-1.0.0.AppImage">
|
||||
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
||||
</a>
|
||||
<br />
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui_1.0.0_amd64.deb">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui_1.0.0_amd64.deb">
|
||||
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
||||
</a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.x86_64.rpm">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.x86_64.rpm">
|
||||
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
||||
</a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.pacman">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v1.0.0/claude-agent-teams-ui-1.0.0.pacman">
|
||||
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
||||
</a>
|
||||
</td>
|
||||
|
|
@ -112,7 +112,7 @@ This triggers the `release.yml` GitHub Actions workflow which:
|
|||
After the workflow completes, edit the release notes:
|
||||
|
||||
```bash
|
||||
gh release edit v<VERSION> --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF'
|
||||
gh release edit v<VERSION> --repo 777genius/agent-teams-ai --notes "$(cat <<'EOF'
|
||||
<paste release notes here>
|
||||
EOF
|
||||
)"
|
||||
|
|
@ -140,33 +140,33 @@ EOF
|
|||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>-arm64.dmg">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>-arm64.dmg">
|
||||
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
|
||||
</a>
|
||||
<br />
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>-x64.dmg">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>-x64.dmg">
|
||||
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI.Setup.<VERSION>.exe">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI.Setup.<VERSION>.exe">
|
||||
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
|
||||
</a>
|
||||
<br />
|
||||
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>.AppImage">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/Agent.Teams.AI-<VERSION>.AppImage">
|
||||
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
|
||||
</a>
|
||||
<br />
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui_<VERSION>_amd64.deb">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai_<VERSION>_amd64.deb">
|
||||
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
|
||||
</a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui-<VERSION>.x86_64.rpm">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai-<VERSION>.x86_64.rpm">
|
||||
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
|
||||
</a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui-<VERSION>.pacman">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/download/v<VERSION>/agent-teams-ai-<VERSION>.pacman">
|
||||
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
|
||||
</a>
|
||||
</td>
|
||||
|
|
@ -196,15 +196,15 @@ electron-builder generates these artifacts per platform:
|
|||
|
||||
| Platform | Versioned Name | Stable Name (for /latest/download) |
|
||||
|------------------|--------------------------------------------------|--------------------------------------------|
|
||||
| macOS arm64 DMG | `Claude.Agent.Teams.UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
|
||||
| macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
|
||||
| macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | - |
|
||||
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-x64-mac.zip` | - |
|
||||
| Windows | `Claude.Agent.Teams.UI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
|
||||
| Linux AppImage | `Claude.Agent.Teams.UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
|
||||
| Linux deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
|
||||
| Linux rpm | `claude-agent-teams-ui-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
|
||||
| Linux pacman | `claude-agent-teams-ui-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
|
||||
| macOS arm64 DMG | `Agent.Teams.AI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
|
||||
| macOS x64 DMG | `Agent.Teams.AI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
|
||||
| macOS arm64 ZIP | `Agent.Teams.AI-<VER>-arm64-mac.zip` | - |
|
||||
| macOS x64 ZIP | `Agent.Teams.AI-<VER>-x64-mac.zip` | - |
|
||||
| Windows | `Agent.Teams.AI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
|
||||
| Linux AppImage | `Agent.Teams.AI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
|
||||
| Linux deb | `agent-teams-ai_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
|
||||
| Linux rpm | `agent-teams-ai-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
|
||||
| Linux pacman | `agent-teams-ai-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
|
||||
|
||||
## Stable Download Links
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ It starts only after **release-mac** (two matrix jobs), **release-win**, and **r
|
|||
This enables permanent links in README that always point to the latest release:
|
||||
|
||||
```
|
||||
https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg
|
||||
https://github.com/777genius/agent-teams-ai/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg
|
||||
```
|
||||
|
||||
GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version.
|
||||
|
|
@ -251,10 +251,10 @@ git push origin v1.0.0
|
|||
# Wait for CI to finish (~10 min), then update notes
|
||||
|
||||
# Delete a release (if needed)
|
||||
gh release delete v1.0.0 --repo 777genius/claude_agent_teams_ui --yes
|
||||
gh release delete v1.0.0 --repo 777genius/agent-teams-ai --yes
|
||||
git tag -d v1.0.0
|
||||
git push origin :refs/tags/v1.0.0
|
||||
|
||||
# Check workflow status
|
||||
gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3
|
||||
gh run list --repo 777genius/agent-teams-ai --workflow release.yml --limit 3
|
||||
```
|
||||
|
|
|
|||
291
docs/research/agent-launch-architecture-comparison-2026-05-07.md
Normal file
291
docs/research/agent-launch-architecture-comparison-2026-05-07.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
# Agent launch architecture comparison research
|
||||
|
||||
Research date: 2026-05-07
|
||||
|
||||
Purpose: record factual research on how different systems launch or execute agents. This is informational context only, not an implementation recommendation.
|
||||
|
||||
## Scope
|
||||
|
||||
Systems compared:
|
||||
|
||||
| System | Repository / source | Snapshot |
|
||||
|---|---|---|
|
||||
| Our Agent Teams | Local `claude_team` + `agent_teams_orchestrator` | Local working tree, 2026-05-07 |
|
||||
| Paperclip | `paperclipai` docs/code research from earlier pass | Public docs / local research |
|
||||
| Gastown | `github.com/gastownhall/gastown` | cloned `cfbdf3c` |
|
||||
| GoClaw Enterprise / Teams | `github.com/nextlevelbuilder/goclaw` | cloned `a97e502` |
|
||||
| GoClaw OpenClaw-compatible gateway | `github.com/roelfdiedericks/goclaw` | cloned `6a7ccdb` |
|
||||
|
||||
Primary external references:
|
||||
|
||||
| Topic | Source |
|
||||
|---|---|
|
||||
| Gastown README | https://github.com/gastownhall/gastown/blob/main/README.md |
|
||||
| Gastown agent provider integration | https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md |
|
||||
| GoClaw agent loop | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/01-agent-loop.md |
|
||||
| GoClaw agent teams | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/11-agent-teams.md |
|
||||
| GoClaw team WS events | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/13-ws-team-events.md |
|
||||
| Paperclip agent runtime | https://github.com/paperclipai/docs/blob/main/agents-runtime.md |
|
||||
| Paperclip adapters overview | https://paperclip.inc/docs/adapters/overview/ |
|
||||
|
||||
## Short answer
|
||||
|
||||
There are four distinct launch/execution models:
|
||||
|
||||
| Model | Used by | Essence |
|
||||
|---|---|---|
|
||||
| External live CLI process | Our Agent Teams | App/orchestrator launches real teammate runtimes and tracks bootstrap, PID, stderr, process health, runtime evidence, task/message state. |
|
||||
| Bounded adapter run | Paperclip | A heartbeat or job starts a short agent run, adapter invokes CLI/provider, result is captured, run exits or times out. |
|
||||
| Tmux session orchestration | Gastown | `tmux` is the universal runtime adapter. Agents run in terminal sessions, receive input through tmux, and are observed through panes/session state. |
|
||||
| In-process agent loop | GoClaw Enterprise / Teams | Agent execution is a Go `Loop.Run(ctx, RunRequest)` scheduled through lanes. The agent is a logical loop inside the gateway, not necessarily a separate CLI teammate process. |
|
||||
|
||||
## What “in-process agent loop” removes from our live teammate product
|
||||
|
||||
In-process loop does not mean “bad”. It is often cleaner. But compared to our external process teammate model, it removes or changes several product properties.
|
||||
|
||||
| Product property | External process teammate, our model | In-process GoClaw-style loop |
|
||||
|---|---|---|
|
||||
| Real process identity | Each teammate can have PID/RSS/stdout/stderr/process lifetime. | Agent run is a gateway invocation; no independent teammate PID by default. |
|
||||
| CLI-realism | Claude/Codex/OpenCode behave as their real CLI runtimes, including auth, prompts, provider errors, stderr quirks. | Provider/driver behavior is normalized inside gateway; fewer raw CLI lifecycle surfaces. |
|
||||
| Per-member restart semantics | Restart means kill/relaunch or reattach a concrete runtime for that member. | Restart is usually cancel/reschedule a logical run/session. |
|
||||
| Bootstrap evidence | We can distinguish process alive, bootstrap submitted, bootstrap confirmed, delivery proof, task proof. | The loop itself is already the controlled runtime; less need for low-level bootstrap proof. |
|
||||
| UI runtime cards | UI can show memory, process state, liveness source, failed/stalled bootstrap, exact runtime diagnostics. | UI tends to show run/session/task status rather than OS/process-level teammate state. |
|
||||
| TTY/process debugging | Process/tmux mode can expose raw CLI behavior when needed. | Debugging is gateway traces/events/logs, not a live CLI pane/process per member. |
|
||||
| Failure classes | Auth prompt, no stdin, CLI did not submit bootstrap, process died, stale PID, provider CLI stderr. | Mostly provider/tool/session/run errors inside the loop. |
|
||||
| Isolation boundary | OS process boundary per teammate. | Mostly logical/session isolation inside one gateway process, unless it delegates to external providers/tools. |
|
||||
|
||||
Important distinction: in-process loop is simpler and can be more stable for gateway/chat products. It is not a drop-in replacement for a desktop product whose value includes live external teammate runtimes.
|
||||
|
||||
## Our Agent Teams launch/execution model
|
||||
|
||||
Our current direction is app-managed live external teammate runtime.
|
||||
|
||||
Observed local architecture:
|
||||
|
||||
| Layer | Role |
|
||||
|---|---|
|
||||
| `claude_team` Electron app | UI, provisioning, runtime projection, team messages, tasks, diagnostics, retries. |
|
||||
| `agent_teams_orchestrator` | Multi-agent runtime orchestration, teammate spawning, provider/runtime bridging. |
|
||||
| Process backend | Default for app-launched teammates after recent changes. Launch-owned processes are tracked as runtime entities. |
|
||||
| Optional tmux mode | Debug/manual mode, not production default. Useful for real TTY inspection. |
|
||||
| App-managed bootstrap | Backend injects/records startup context and requires durable readiness evidence instead of trusting “process exists”. |
|
||||
| Runtime projection | Maps launch state, process liveness, bootstrap proof, delivery proof, task state and diagnostics to UI. |
|
||||
|
||||
Key properties:
|
||||
|
||||
| Dimension | Current behavior |
|
||||
|---|---|
|
||||
| Agent lifetime | Long-lived teammate process/session, not just one request. |
|
||||
| Availability proof | Process alive is not enough. Need bootstrap/runtime evidence. |
|
||||
| Provider mix | Claude, Codex, OpenCode can coexist in one team. |
|
||||
| User experience | Live team room: cards, memory, tasks, messages, runtime errors, restart/retry controls. |
|
||||
| Complexity cost | High. Many edge cases around launch, cleanup, stale state, delivery, work-sync, retries. |
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Live team product fit | 9.2/10 |
|
||||
| Mixed provider fidelity | 8.7/10 |
|
||||
| Runtime proof strictness | 8.8/10 |
|
||||
| Simplicity | 5.8/10 |
|
||||
| Maintainability today | 7.2/10 |
|
||||
| Overall technical score | 8.5/10 |
|
||||
|
||||
## Paperclip launch/execution model
|
||||
|
||||
Paperclip is closest to a bounded job/heartbeat runner.
|
||||
|
||||
Research summary from earlier pass:
|
||||
|
||||
| Piece | Behavior |
|
||||
|---|---|
|
||||
| Agent invocation | Heartbeat or scheduled run calls adapter execution. |
|
||||
| Runtime | Adapter starts/calls CLI or provider, captures output/status/errors. |
|
||||
| Lifecycle | Run exits, times out, or is cancelled. |
|
||||
| Concurrency | Wakeups coalesce if agent is already running. |
|
||||
| Persistence | Status/logs/tokens/errors are stored per run. |
|
||||
|
||||
This is operationally clean because there is no expectation that every teammate is a continuously alive process with card-level runtime state.
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Bounded execution design | 9.1/10 |
|
||||
| Simplicity | 8.8/10 |
|
||||
| Failure boundedness | 8.7/10 |
|
||||
| Live teammate room fit | 6.3/10 |
|
||||
| External CLI fidelity | 7.5/10 |
|
||||
| Overall technical score | 8.2/10 |
|
||||
|
||||
## Gastown launch/execution model
|
||||
|
||||
Gastown is tmux-first.
|
||||
|
||||
Facts from `gastownhall/gastown`:
|
||||
|
||||
| Piece | Behavior |
|
||||
|---|---|
|
||||
| Main runtime adapter | `tmux` sessions. |
|
||||
| Universal integration | Any CLI that runs in terminal can be started and controlled. |
|
||||
| Work unit | Beads/issues and convoys. |
|
||||
| Worker identity | Polecats have persistent identity and reusable worktrees. |
|
||||
| Session lifetime | Sessions are ephemeral; identity and sandbox can persist. |
|
||||
| Communication | Mail, nudges, hooks, Beads state, tmux input/output. |
|
||||
| Monitoring | Witness, Deacon, Dogs, Doctor, cleanup commands. |
|
||||
| Provider integration | Built-in/custom presets with command, args, env, process names, hooks, readiness delay/prompt. |
|
||||
|
||||
Gastown explicitly documents a Tier 0 tmux shim: start CLI in tmux, send work through keystrokes, detect liveness through pane process, read output through captured pane. It also notes that this level is timing-sensitive and lacks delivery confirmation.
|
||||
|
||||
Core model:
|
||||
|
||||
```text
|
||||
gt sling <bead> <rig>
|
||||
-> allocate or reuse polecat identity/worktree
|
||||
-> create tmux session
|
||||
-> set env: GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, GT_AGENT, etc.
|
||||
-> inject startup beacon / prompt / hook context
|
||||
-> nudge with instructions if provider needs fallback
|
||||
-> Witness/Deacon patrol health and cleanup
|
||||
```
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Terminal-native ops | 9.0/10 |
|
||||
| Persistent worker identity | 8.7/10 |
|
||||
| Cleanup / doctor culture | 8.8/10 |
|
||||
| Delivery proof strictness | 6.4/10 |
|
||||
| Live product state consistency | 6.8/10 |
|
||||
| Overall technical score | 8.0/10 |
|
||||
|
||||
## GoClaw Enterprise / Teams launch/execution model
|
||||
|
||||
This is `nextlevelbuilder/goclaw`, the relevant GoClaw for agent teams.
|
||||
|
||||
Core architecture from docs/code:
|
||||
|
||||
| Piece | Behavior |
|
||||
|---|---|
|
||||
| Agent unit | `Loop` configured with provider, model, tools, workspace and agent type. |
|
||||
| Run entrypoint | `Loop.Run(ctx, RunRequest)`. |
|
||||
| Loop pattern | Think -> Act -> Observe, with max iterations and tool execution. |
|
||||
| Scheduler | First-class lane scheduler. |
|
||||
| Lanes | `main`, `subagent`, `team`, `cron`. |
|
||||
| Queueing | Per-session queues with debounce, drop policy, max concurrent. |
|
||||
| Team model | Lead/member, task board, mailbox, delegation. |
|
||||
| Task semantics | Atomic claim, status lifecycle, dependencies, blocker escalation, task events. |
|
||||
| Events | Typed WS events for delegation, tasks, team messages and agent lifecycle. |
|
||||
|
||||
Core execution shape:
|
||||
|
||||
```text
|
||||
Inbound message / teammate message / cron / delegation
|
||||
-> Scheduler.Schedule(lane, RunRequest)
|
||||
-> SessionQueue serializes or bounds per session
|
||||
-> Lane worker admits execution
|
||||
-> Router.Get(agentID)
|
||||
-> Loop.Run(ctx, req)
|
||||
-> Provider call + tools + finalization
|
||||
-> Events + stored session/task/trace state
|
||||
```
|
||||
|
||||
GoClaw team member execution is conceptually a scheduled agent run, not an externally spawned teammate CLI process with bootstrap/check-in.
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Scheduler architecture | 9.2/10 |
|
||||
| Agent loop clarity | 8.9/10 |
|
||||
| Team task model | 8.8/10 |
|
||||
| Typed event model | 8.8/10 |
|
||||
| Real external teammate runtime fidelity | 6.6/10 |
|
||||
| Live process UI fit | 6.5/10 |
|
||||
| Overall technical score | 8.7/10 |
|
||||
|
||||
## GoClaw OpenClaw-compatible gateway model
|
||||
|
||||
This is `roelfdiedericks/goclaw`. It is a different project than `nextlevelbuilder/goclaw`.
|
||||
|
||||
High-level facts:
|
||||
|
||||
| Piece | Behavior |
|
||||
|---|---|
|
||||
| Product class | Personal AI gateway / OpenClaw-compatible bot runtime. |
|
||||
| Main strengths | Transcript search, memory graph, channels, persistent memory, delegated runs, ACP sessions. |
|
||||
| Delegated work | `subagent_spawn`, `subagent_fanout`, `subagent_status`, `subagent_cancel`. |
|
||||
| Runner | `DefaultRunner` starts active runs as goroutines with run IDs, timeout/cancel, optional concurrency lane semaphore. |
|
||||
| UI/control | `/runners` dashboard, SSE events, Telegram/TUI summaries. |
|
||||
| Cursor integration | ACP attachment to live Cursor session. |
|
||||
|
||||
Runner shape:
|
||||
|
||||
```text
|
||||
subagent_spawn / fanout
|
||||
-> DefaultRunner.Start(ctx, RunSpec)
|
||||
-> create RunRecord queued
|
||||
-> goroutine waits for lane admission
|
||||
-> execute function runs child work
|
||||
-> registry records completed/failed/canceled/timeout
|
||||
-> events emitted
|
||||
```
|
||||
|
||||
This is closer to Paperclip-style delegated bounded runs than to our live teammate process model.
|
||||
|
||||
Technical assessment:
|
||||
|
||||
| Criterion | Score |
|
||||
|---|---:|
|
||||
| Personal gateway/memory architecture | 8.8/10 |
|
||||
| Delegated run boundedness | 8.5/10 |
|
||||
| Channel/memory richness | 9.0/10 |
|
||||
| Live external teammate fidelity | 5.8/10 |
|
||||
| Team room fit | 6.4/10 |
|
||||
| Overall technical score | 8.1/10 |
|
||||
|
||||
## Direct comparison table
|
||||
|
||||
| System | Launch/execution primitive | Separate OS process per agent? | Long-lived teammate? | Task board | Team messages | Scheduler | Tmux | Best fit |
|
||||
|---|---|---:|---:|---:|---:|---:|---:|---|
|
||||
| Our Agent Teams | Launch-owned external CLI/process runtime | Yes | Yes | Yes | Yes | Partial/ad-hoc today | Optional/debug | Desktop live mixed-provider team room |
|
||||
| Paperclip | Bounded adapter heartbeat run | Usually per run | No | Limited/not central | Not team-room focused | Yes, job-like | No core tmux | Reliable background/job agents |
|
||||
| Gastown | Tmux session + worktree + Beads | Yes, through tmux | Session ephemeral, identity persistent | Beads/convoys | Mail/nudges | Scheduler/capacity exists | Core | Terminal-native multi-agent ops |
|
||||
| GoClaw Enterprise | In-process scheduled agent loop | Not by default | Logical sessions/runs | Yes | Yes | First-class lanes | No core tmux | Multi-agent gateway/platform |
|
||||
| GoClaw OpenClaw-compatible | Delegated goroutine runner + gateway sessions | Not by default | Logical runs/sessions | Not primary team board in same way | Channels | Runner lane semaphore | No core tmux | Personal gateway, memory, delegated runs |
|
||||
|
||||
## Honest overall scores
|
||||
|
||||
| System | Overall technical score | Why |
|
||||
|---|---:|---|
|
||||
| GoClaw Enterprise / Teams | 8.7/10 | Cleanest scheduler/team/task/event architecture among compared systems. |
|
||||
| Our Agent Teams | 8.5/10 | Best fit for real live external Claude/Codex/OpenCode teammate product, but high complexity. |
|
||||
| Paperclip | 8.2/10 | Very clean bounded runtime model, but not a live team-room system. |
|
||||
| GoClaw OpenClaw-compatible | 8.1/10 | Strong personal gateway/memory/delegated run model, less comparable to our team runtime. |
|
||||
| Gastown | 8.0/10 | Strong terminal ops and lifecycle culture, but tmux-first delivery/readiness is less proof-strict. |
|
||||
|
||||
## Research conclusions
|
||||
|
||||
The systems optimize for different truths:
|
||||
|
||||
| System | Optimized for |
|
||||
|---|---|
|
||||
| Our Agent Teams | User-visible live team of real external coding agents. |
|
||||
| Paperclip | Bounded, simple, resumable background agent runs. |
|
||||
| Gastown | Terminal-native agent ops at scale with durable work identity. |
|
||||
| GoClaw Enterprise | Clean gateway-native multi-agent scheduling and team task orchestration. |
|
||||
| GoClaw OpenClaw-compatible | Long-memory personal agent gateway with delegated subruns. |
|
||||
|
||||
Most useful conceptual takeaways for future reference:
|
||||
|
||||
| Idea | Source | Why it matters |
|
||||
|---|---|---|
|
||||
| First-class scheduler lanes | GoClaw Enterprise | Separates main/team/subagent/cron load and makes cancellation/backpressure more deterministic. |
|
||||
| Typed team event catalog | GoClaw Enterprise | Makes UI and state transitions easier to reason about. |
|
||||
| Persistent identity vs ephemeral session | Gastown | Useful framing for member identity, runtime session, task ownership and cleanup. |
|
||||
| Bounded adapter runs | Paperclip | Good model for cron, background checks and non-live workers. |
|
||||
| Patrol/doctor cleanup culture | Gastown | Good operational model for stale runtime/process/data cleanup. |
|
||||
|
||||
Non-recommendation note: this document intentionally does not propose changing our architecture. It records observed models for future design discussions.
|
||||
92
docs/research/paperclip-agent-launch-research-2026-05-07.md
Normal file
92
docs/research/paperclip-agent-launch-research-2026-05-07.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Paperclip agent launch research
|
||||
|
||||
Research date: 2026-05-07
|
||||
|
||||
This note records factual findings only. It is not an implementation plan and does not make recommendations.
|
||||
|
||||
## Scope
|
||||
|
||||
Compared Paperclip agent launch/runtime behavior with the local Agent Teams orchestrator.
|
||||
|
||||
Local orchestrator inspected:
|
||||
|
||||
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/tools/shared/spawnMultiAgent.ts`
|
||||
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapRunner.ts`
|
||||
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/processBackend.ts`
|
||||
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts`
|
||||
|
||||
Paperclip inspected from GitHub and a shallow local clone at `/tmp/paperclip-inspect`.
|
||||
|
||||
## Paperclip runtime model
|
||||
|
||||
Paperclip agents do not run continuously. They run in heartbeat windows triggered by wakeups such as timer, assignment, on-demand, or automation.
|
||||
|
||||
Each heartbeat starts an adapter, gives it prompt/context, lets it run until exit, timeout, or cancellation, stores run status/tokens/errors/logs, and updates UI.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/docs/agents-runtime.md
|
||||
|
||||
## Paperclip process execution
|
||||
|
||||
Paperclip uses child processes for local adapters. Normal execution is not tmux-based.
|
||||
|
||||
The shared process runner uses `node:child_process.spawn`, streams stdout/stderr, records pid and process group information, supports timeout, sends graceful termination, and escalates to kill after a grace period.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapter-utils/src/server-utils.ts
|
||||
|
||||
The generic process adapter is documented as executing arbitrary shell commands as child processes with env injection and exit-code based success/failure.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/docs/adapters/process.md
|
||||
|
||||
## Paperclip adapter examples
|
||||
|
||||
Claude local adapter:
|
||||
|
||||
- Uses `claude --print - --output-format stream-json --verbose`.
|
||||
- Supports session resume with `--resume`.
|
||||
- Supports model/effort/max-turns/append-system-prompt/add-dir style options.
|
||||
- Parses stream JSON for terminal result, session id, and usage.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/claude-local/src/server/execute.ts
|
||||
|
||||
Codex local adapter:
|
||||
|
||||
- Uses `codex exec --json ... -`.
|
||||
- Supports session continuation through `resume`.
|
||||
- Manages `CODEX_HOME`, injected skills/config/auth context, and fresh-session fallback paths.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/codex-local/src/server/execute.ts
|
||||
|
||||
OpenCode local adapter:
|
||||
|
||||
- Uses `opencode run --format json`.
|
||||
- Supports `--session`, `--model`, and `--variant`.
|
||||
- Uses temp config and model/session validation paths.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/opencode-local/src/server/execute.ts
|
||||
|
||||
## Paperclip orchestration/lifecycle
|
||||
|
||||
Paperclip stores heartbeat runs and events, updates agent runtime state, publishes live events, stores run logs, supports cancellation by pid/process group, and has recovery paths for lost/orphaned running runs.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/heartbeat.ts
|
||||
|
||||
It has a per-agent start lock so concurrent starts for the same agent are coalesced or blocked.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/agent-start-lock.ts
|
||||
|
||||
It also has run liveness classification/recovery paths for cases like empty or low-signal runs.
|
||||
|
||||
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/run-liveness.ts
|
||||
|
||||
## Comparison facts
|
||||
|
||||
Paperclip is organized around short, resumable heartbeat runs. It waits for CLI run completion and records result/logs/state.
|
||||
|
||||
Agent Teams is organized around live team members, mixed providers, direct messages, tasks, work-sync, runtime evidence, and durable bootstrap/check-in proof.
|
||||
|
||||
Paperclip does not need the same live teammate readiness model because it does not maintain a long-running team room with continuously addressable members.
|
||||
|
||||
Agent Teams still supports tmux/pane backends in the orchestrator, but current app-launched teammates can use process backend with app-managed runtime evidence.
|
||||
|
||||
Paperclip's process lifecycle primitives are more centralized. Agent Teams has more live multi-agent protocol surface and therefore more runtime states to reconcile.
|
||||
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="48%" r="70%">
|
||||
<stop offset="0%" stop-color="#111827"/>
|
||||
<stop offset="58%" stop-color="#08091a"/>
|
||||
<stop offset="100%" stop-color="#050510"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<style>
|
||||
.title{font:700 30px Inter,Arial,sans-serif;fill:#f8fafc;letter-spacing:0}
|
||||
.sub{font:500 18px Inter,Arial,sans-serif;fill:#94a3b8;letter-spacing:0}
|
||||
.edge{fill:none;stroke:#1d4ed8;stroke-width:2;opacity:.42}
|
||||
.card{fill:#090b1d;stroke:#26314f;stroke-width:1.5}
|
||||
.lead{fill:#142006;stroke:#9bef13;stroke-width:2;filter:url(#glow)}
|
||||
.label{font:700 18px Inter,Arial,sans-serif;fill:#f8fafc;text-anchor:middle;letter-spacing:0}
|
||||
.small{font:500 14px Inter,Arial,sans-serif;fill:#94a3b8;text-anchor:middle;letter-spacing:0}
|
||||
.badge{font:700 12px Inter,Arial,sans-serif;fill:#020617;text-anchor:middle;letter-spacing:0}
|
||||
</style>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<circle cx="0" cy="0" r="0.60" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="97" cy="211" r="1.54" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="194" cy="422" r="1.49" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="291" cy="633" r="1.43" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="388" cy="844" r="1.38" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="485" cy="55" r="1.32" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="582" cy="266" r="1.27" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="679" cy="477" r="1.21" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="776" cy="688" r="1.16" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="873" cy="899" r="1.10" fill="#dbeafe" opacity="0.29"/>
|
||||
<circle cx="970" cy="110" r="1.04" fill="#dbeafe" opacity="0.60"/>
|
||||
<circle cx="1067" cy="321" r="0.99" fill="#dbeafe" opacity="0.36"/>
|
||||
<circle cx="1164" cy="532" r="0.93" fill="#dbeafe" opacity="0.67"/>
|
||||
<circle cx="1261" cy="743" r="0.88" fill="#dbeafe" opacity="0.43"/>
|
||||
<circle cx="1358" cy="954" r="0.82" fill="#dbeafe" opacity="0.74"/>
|
||||
<circle cx="55" cy="165" r="0.77" fill="#dbeafe" opacity="0.50"/>
|
||||
<circle cx="152" cy="376" r="0.71" fill="#dbeafe" opacity="0.26"/>
|
||||
<circle cx="249" cy="587" r="0.66" fill="#dbeafe" opacity="0.57"/>
|
||||
<circle cx="346" cy="798" r="0.60" fill="#dbeafe" opacity="0.33"/>
|
||||
<circle cx="443" cy="9" r="1.54" fill="#dbeafe" opacity="0.64"/>
|
||||
<circle cx="540" cy="220" r="1.49" fill="#dbeafe" opacity="0.40"/>
|
||||
<circle cx="637" cy="431" r="1.43" fill="#dbeafe" opacity="0.71"/>
|
||||
<circle cx="734" cy="642" r="1.38" fill="#dbeafe" opacity="0.47"/>
|
||||
<circle cx="831" cy="853" r="1.32" fill="#dbeafe" opacity="0.78"/>
|
||||
<circle cx="928" cy="64" r="1.27" fill="#dbeafe" opacity="0.54"/>
|
||||
<circle cx="1025" cy="275" r="1.21" fill="#dbeafe" opacity="0.30"/>
|
||||
<circle cx="1122" cy="486" r="1.16" fill="#dbeafe" opacity="0.61"/>
|
||||
<circle cx="1219" cy="697" r="1.10" fill="#dbeafe" opacity="0.37"/>
|
||||
<circle cx="1316" cy="908" r="1.04" fill="#dbeafe" opacity="0.68"/>
|
||||
<circle cx="13" cy="119" r="0.99" fill="#dbeafe" opacity="0.44"/>
|
||||
<circle cx="110" cy="330" r="0.93" fill="#dbeafe" opacity="0.75"/>
|
||||
<circle cx="207" cy="541" r="0.88" fill="#dbeafe" opacity="0.51"/>
|
||||
<circle cx="304" cy="752" r="0.82" fill="#dbeafe" opacity="0.27"/>
|
||||
<circle cx="401" cy="963" r="0.77" fill="#dbeafe" opacity="0.58"/>
|
||||
<circle cx="498" cy="174" r="0.71" fill="#dbeafe" opacity="0.34"/>
|
||||
<circle cx="595" cy="385" r="0.66" fill="#dbeafe" opacity="0.65"/>
|
||||
<circle cx="692" cy="596" r="0.60" fill="#dbeafe" opacity="0.41"/>
|
||||
<circle cx="789" cy="807" r="1.54" fill="#dbeafe" opacity="0.72"/>
|
||||
<circle cx="886" cy="18" r="1.49" fill="#dbeafe" opacity="0.48"/>
|
||||
<circle cx="983" cy="229" r="1.43" fill="#dbeafe" opacity="0.79"/>
|
||||
<circle cx="1080" cy="440" r="1.38" fill="#dbeafe" opacity="0.55"/>
|
||||
<circle cx="1177" cy="651" r="1.32" fill="#dbeafe" opacity="0.31"/>
|
||||
<circle cx="1274" cy="862" r="1.27" fill="#dbeafe" opacity="0.62"/>
|
||||
<circle cx="1371" cy="73" r="1.21" fill="#dbeafe" opacity="0.38"/>
|
||||
<circle cx="68" cy="284" r="1.16" fill="#dbeafe" opacity="0.69"/>
|
||||
<circle cx="165" cy="495" r="1.10" fill="#dbeafe" opacity="0.45"/>
|
||||
<circle cx="262" cy="706" r="1.04" fill="#dbeafe" opacity="0.76"/>
|
||||
<circle cx="359" cy="917" r="0.99" fill="#dbeafe" opacity="0.52"/>
|
||||
<circle cx="456" cy="128" r="0.93" fill="#dbeafe" opacity="0.28"/>
|
||||
<circle cx="553" cy="339" r="0.88" fill="#dbeafe" opacity="0.59"/>
|
||||
<circle cx="650" cy="550" r="0.82" fill="#dbeafe" opacity="0.35"/>
|
||||
<circle cx="747" cy="761" r="0.77" fill="#dbeafe" opacity="0.66"/>
|
||||
<circle cx="844" cy="972" r="0.71" fill="#dbeafe" opacity="0.42"/>
|
||||
<circle cx="941" cy="183" r="0.66" fill="#dbeafe" opacity="0.73"/>
|
||||
<circle cx="1038" cy="394" r="0.60" fill="#dbeafe" opacity="0.49"/>
|
||||
<circle cx="1135" cy="605" r="1.54" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="1232" cy="816" r="1.49" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="1329" cy="27" r="1.43" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="26" cy="238" r="1.38" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="123" cy="449" r="1.32" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="220" cy="660" r="1.27" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="317" cy="871" r="1.21" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="414" cy="82" r="1.16" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="511" cy="293" r="1.10" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="608" cy="504" r="1.04" fill="#dbeafe" opacity="0.29"/>
|
||||
<circle cx="705" cy="715" r="0.99" fill="#dbeafe" opacity="0.60"/>
|
||||
<circle cx="802" cy="926" r="0.93" fill="#dbeafe" opacity="0.36"/>
|
||||
<circle cx="899" cy="137" r="0.88" fill="#dbeafe" opacity="0.67"/>
|
||||
<circle cx="996" cy="348" r="0.82" fill="#dbeafe" opacity="0.43"/>
|
||||
<circle cx="1093" cy="559" r="0.77" fill="#dbeafe" opacity="0.74"/>
|
||||
<circle cx="1190" cy="770" r="0.71" fill="#dbeafe" opacity="0.50"/>
|
||||
<circle cx="1287" cy="981" r="0.66" fill="#dbeafe" opacity="0.26"/>
|
||||
<circle cx="1384" cy="192" r="0.60" fill="#dbeafe" opacity="0.57"/>
|
||||
<circle cx="81" cy="403" r="1.54" fill="#dbeafe" opacity="0.33"/>
|
||||
<circle cx="178" cy="614" r="1.49" fill="#dbeafe" opacity="0.64"/>
|
||||
<circle cx="275" cy="825" r="1.43" fill="#dbeafe" opacity="0.40"/>
|
||||
<circle cx="372" cy="36" r="1.38" fill="#dbeafe" opacity="0.71"/>
|
||||
<circle cx="469" cy="247" r="1.32" fill="#dbeafe" opacity="0.47"/>
|
||||
<circle cx="566" cy="458" r="1.27" fill="#dbeafe" opacity="0.78"/>
|
||||
<circle cx="663" cy="669" r="1.21" fill="#dbeafe" opacity="0.54"/>
|
||||
<circle cx="760" cy="880" r="1.16" fill="#dbeafe" opacity="0.30"/>
|
||||
<circle cx="857" cy="91" r="1.10" fill="#dbeafe" opacity="0.61"/>
|
||||
<circle cx="954" cy="302" r="1.04" fill="#dbeafe" opacity="0.37"/>
|
||||
<circle cx="1051" cy="513" r="0.99" fill="#dbeafe" opacity="0.68"/>
|
||||
<circle cx="1148" cy="724" r="0.93" fill="#dbeafe" opacity="0.44"/>
|
||||
<circle cx="1245" cy="935" r="0.88" fill="#dbeafe" opacity="0.75"/>
|
||||
<circle cx="1342" cy="146" r="0.82" fill="#dbeafe" opacity="0.51"/>
|
||||
<circle cx="39" cy="357" r="0.77" fill="#dbeafe" opacity="0.27"/>
|
||||
<circle cx="136" cy="568" r="0.71" fill="#dbeafe" opacity="0.58"/>
|
||||
<circle cx="233" cy="779" r="0.66" fill="#dbeafe" opacity="0.34"/>
|
||||
<circle cx="330" cy="990" r="0.60" fill="#dbeafe" opacity="0.65"/>
|
||||
<circle cx="427" cy="201" r="1.54" fill="#dbeafe" opacity="0.41"/>
|
||||
<circle cx="524" cy="412" r="1.49" fill="#dbeafe" opacity="0.72"/>
|
||||
<circle cx="621" cy="623" r="1.43" fill="#dbeafe" opacity="0.48"/>
|
||||
<circle cx="718" cy="834" r="1.38" fill="#dbeafe" opacity="0.79"/>
|
||||
<circle cx="815" cy="45" r="1.32" fill="#dbeafe" opacity="0.55"/>
|
||||
<circle cx="912" cy="256" r="1.27" fill="#dbeafe" opacity="0.31"/>
|
||||
<circle cx="1009" cy="467" r="1.21" fill="#dbeafe" opacity="0.62"/>
|
||||
<circle cx="1106" cy="678" r="1.16" fill="#dbeafe" opacity="0.38"/>
|
||||
<circle cx="1203" cy="889" r="1.10" fill="#dbeafe" opacity="0.69"/>
|
||||
<circle cx="1300" cy="100" r="1.04" fill="#dbeafe" opacity="0.45"/>
|
||||
<circle cx="1397" cy="311" r="0.99" fill="#dbeafe" opacity="0.76"/>
|
||||
<circle cx="94" cy="522" r="0.93" fill="#dbeafe" opacity="0.52"/>
|
||||
<circle cx="191" cy="733" r="0.88" fill="#dbeafe" opacity="0.28"/>
|
||||
<circle cx="288" cy="944" r="0.82" fill="#dbeafe" opacity="0.59"/>
|
||||
<circle cx="385" cy="155" r="0.77" fill="#dbeafe" opacity="0.35"/>
|
||||
<circle cx="482" cy="366" r="0.71" fill="#dbeafe" opacity="0.66"/>
|
||||
<circle cx="579" cy="577" r="0.66" fill="#dbeafe" opacity="0.42"/>
|
||||
<circle cx="676" cy="788" r="0.60" fill="#dbeafe" opacity="0.73"/>
|
||||
<circle cx="773" cy="999" r="1.54" fill="#dbeafe" opacity="0.49"/>
|
||||
<circle cx="870" cy="210" r="1.49" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="967" cy="421" r="1.43" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="1064" cy="632" r="1.38" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="1161" cy="843" r="1.32" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="1258" cy="54" r="1.27" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="1355" cy="265" r="1.21" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="52" cy="476" r="1.16" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="149" cy="687" r="1.10" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="246" cy="898" r="1.04" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="343" cy="109" r="0.99" fill="#dbeafe" opacity="0.29"/>
|
||||
<text x="70" y="76" class="title">4 participants - current radial layout</text>
|
||||
<text x="70" y="110" class="sub">Strict small-team preset: top / right / bottom / left around Lead</text>
|
||||
<path d="M 700 500 C 700 500, 700 235, 700 235" class="edge"/>
|
||||
<path d="M 700 500 C 895 500, 895 500, 1090 500" class="edge"/>
|
||||
<path d="M 700 500 C 700 500, 700 765, 700 765" class="edge"/>
|
||||
<path d="M 700 500 C 505 500, 505 500, 310 500" class="edge"/>
|
||||
<rect x="615" y="457" width="170" height="86" rx="16" class="lead"/>
|
||||
<text x="700" y="496" class="label">Lead</text>
|
||||
<text x="700" y="524" class="small">center reserved zone</text>
|
||||
|
||||
<rect x="570" y="160" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="700" cy="187" r="20" fill="#38bdf8" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="700" cy="187" r="14" fill="#38bdf8"/>
|
||||
<text x="700" y="227" class="label">Participant 1</text>
|
||||
<text x="700" y="255" class="small">top side</text>
|
||||
<rect x="658" y="278" width="84" height="24" rx="6" fill="#38bdf8"/>
|
||||
<text x="700" y="295" class="badge">slot 1</text>
|
||||
|
||||
|
||||
<rect x="960" y="425" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="1090" cy="452" r="20" fill="#facc15" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="1090" cy="452" r="14" fill="#facc15"/>
|
||||
<text x="1090" y="492" class="label">Participant 2</text>
|
||||
<text x="1090" y="520" class="small">right side</text>
|
||||
<rect x="1048" y="543" width="84" height="24" rx="6" fill="#facc15"/>
|
||||
<text x="1090" y="560" class="badge">slot 2</text>
|
||||
|
||||
|
||||
<rect x="570" y="690" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="700" cy="717" r="20" fill="#ef4444" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="700" cy="717" r="14" fill="#ef4444"/>
|
||||
<text x="700" y="757" class="label">Participant 3</text>
|
||||
<text x="700" y="785" class="small">bottom side</text>
|
||||
<rect x="658" y="808" width="84" height="24" rx="6" fill="#ef4444"/>
|
||||
<text x="700" y="825" class="badge">slot 3</text>
|
||||
|
||||
|
||||
<rect x="180" y="425" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="310" cy="452" r="20" fill="#a78bfa" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="310" cy="452" r="14" fill="#a78bfa"/>
|
||||
<text x="310" y="492" class="label">Participant 4</text>
|
||||
<text x="310" y="520" class="small">left side</text>
|
||||
<rect x="268" y="543" width="84" height="24" rx="6" fill="#a78bfa"/>
|
||||
<text x="310" y="560" class="badge">slot 4</text>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
125
docs/screenshots/agent-graph-row-orbit-layout-preview.svg
Normal file
125
docs/screenshots/agent-graph-row-orbit-layout-preview.svg
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<svg width="1800" height="1050" viewBox="0 0 1800 1050" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
|
||||
<stop offset="0%" stop-color="#111733"/>
|
||||
<stop offset="55%" stop-color="#090d20"/>
|
||||
<stop offset="100%" stop-color="#050717"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.25 0 0 0 0 0.75 0 0 0 0 1 0 0 0 0.65 0"/>
|
||||
<feBlend in="SourceGraphic"/>
|
||||
</filter>
|
||||
<style>
|
||||
.panel-title { font: 700 28px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #e6f0ff; letter-spacing: 0; }
|
||||
.panel-subtitle { font: 500 15px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
|
||||
.label { font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #eff6ff; letter-spacing: 0; }
|
||||
.role { font: 500 10px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8ea0bd; letter-spacing: 0; }
|
||||
.hint { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
|
||||
.card { fill: rgba(11, 16, 36, 0.84); stroke: rgba(148, 163, 184, 0.22); stroke-width: 1; }
|
||||
.slot { fill: rgba(59, 130, 246, 0.035); stroke: rgba(125, 211, 252, 0.16); stroke-width: 1; stroke-dasharray: 5 7; }
|
||||
.edge { stroke: rgba(96, 165, 250, 0.18); stroke-width: 2; }
|
||||
.row-guide { stroke: rgba(148, 163, 184, 0.14); stroke-width: 1; stroke-dasharray: 6 10; }
|
||||
.divider { stroke: rgba(148, 163, 184, 0.16); stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1800" height="1050" fill="url(#bg)"/>
|
||||
<g opacity="0.85">
|
||||
<circle cx="108" cy="102" r="1.5" fill="#94a3b8"/>
|
||||
<circle cx="238" cy="860" r="1.2" fill="#64748b"/>
|
||||
<circle cx="385" cy="145" r="1.3" fill="#94a3b8"/>
|
||||
<circle cx="520" cy="950" r="1.4" fill="#cbd5e1"/>
|
||||
<circle cx="690" cy="372" r="1.1" fill="#94a3b8"/>
|
||||
<circle cx="823" cy="787" r="1.2" fill="#64748b"/>
|
||||
<circle cx="982" cy="210" r="1.1" fill="#94a3b8"/>
|
||||
<circle cx="1112" cy="902" r="1.5" fill="#cbd5e1"/>
|
||||
<circle cx="1286" cy="118" r="1.2" fill="#94a3b8"/>
|
||||
<circle cx="1458" cy="730" r="1.3" fill="#64748b"/>
|
||||
<circle cx="1632" cy="340" r="1.5" fill="#cbd5e1"/>
|
||||
<circle cx="1748" cy="934" r="1.1" fill="#94a3b8"/>
|
||||
</g>
|
||||
|
||||
<line x1="900" y1="70" x2="900" y2="980" class="divider"/>
|
||||
|
||||
<text x="70" y="72" class="panel-title">8 participants</text>
|
||||
<text x="70" y="101" class="panel-subtitle">3 top / 2 at lead level / 3 bottom</text>
|
||||
<text x="970" y="72" class="panel-title">12 participants</text>
|
||||
<text x="970" y="101" class="panel-subtitle">4 top / 2 + lead + 2 middle / 4 bottom</text>
|
||||
|
||||
<g id="eight-layout">
|
||||
<line x1="110" y1="245" x2="790" y2="245" class="row-guide"/>
|
||||
<line x1="110" y1="525" x2="790" y2="525" class="row-guide"/>
|
||||
<line x1="110" y1="805" x2="790" y2="805" class="row-guide"/>
|
||||
<text x="118" y="232" class="hint">top row</text>
|
||||
<text x="118" y="512" class="hint">lead row</text>
|
||||
<text x="118" y="792" class="hint">bottom row</text>
|
||||
|
||||
<path d="M450 525 C370 430 305 330 245 245" class="edge"/>
|
||||
<path d="M450 525 C450 425 450 335 450 245" class="edge"/>
|
||||
<path d="M450 525 C530 430 595 330 655 245" class="edge"/>
|
||||
<path d="M450 525 C360 515 285 515 200 525" class="edge"/>
|
||||
<path d="M450 525 C540 515 615 515 700 525" class="edge"/>
|
||||
<path d="M450 525 C370 620 305 720 245 805" class="edge"/>
|
||||
<path d="M450 525 C450 625 450 715 450 805" class="edge"/>
|
||||
<path d="M450 525 C530 620 595 720 655 805" class="edge"/>
|
||||
|
||||
<g transform="translate(450 525)">
|
||||
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
|
||||
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
|
||||
<circle r="17" fill="#84cc16"/>
|
||||
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(245 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text><text y="34" text-anchor="middle" class="role">reviewer</text></g>
|
||||
<g transform="translate(450 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(655 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(200 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(700 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text><text y="34" text-anchor="middle" class="role">assistant</text></g>
|
||||
<g transform="translate(245 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(450 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text><text y="34" text-anchor="middle" class="role">qa</text></g>
|
||||
<g transform="translate(655 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text><text y="34" text-anchor="middle" class="role">ops</text></g>
|
||||
</g>
|
||||
|
||||
<g id="twelve-layout">
|
||||
<line x1="970" y1="245" x2="1730" y2="245" class="row-guide"/>
|
||||
<line x1="970" y1="525" x2="1730" y2="525" class="row-guide"/>
|
||||
<line x1="970" y1="805" x2="1730" y2="805" class="row-guide"/>
|
||||
<text x="978" y="232" class="hint">top row</text>
|
||||
<text x="978" y="512" class="hint">lead row</text>
|
||||
<text x="978" y="792" class="hint">bottom row</text>
|
||||
|
||||
<path d="M1350 525 C1245 425 1135 330 1030 245" class="edge"/>
|
||||
<path d="M1350 525 C1295 420 1270 335 1243 245" class="edge"/>
|
||||
<path d="M1350 525 C1405 420 1430 335 1457 245" class="edge"/>
|
||||
<path d="M1350 525 C1455 425 1565 330 1670 245" class="edge"/>
|
||||
<path d="M1350 525 C1235 515 1135 515 1030 525" class="edge"/>
|
||||
<path d="M1350 525 C1270 520 1235 520 1210 525" class="edge"/>
|
||||
<path d="M1350 525 C1430 520 1465 520 1490 525" class="edge"/>
|
||||
<path d="M1350 525 C1465 515 1565 515 1670 525" class="edge"/>
|
||||
<path d="M1350 525 C1245 625 1135 720 1030 805" class="edge"/>
|
||||
<path d="M1350 525 C1295 630 1270 715 1243 805" class="edge"/>
|
||||
<path d="M1350 525 C1405 630 1430 715 1457 805" class="edge"/>
|
||||
<path d="M1350 525 C1455 625 1565 720 1670 805" class="edge"/>
|
||||
|
||||
<g transform="translate(1350 525)">
|
||||
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
|
||||
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
|
||||
<circle r="17" fill="#84cc16"/>
|
||||
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(1030 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text></g>
|
||||
<g transform="translate(1243 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text></g>
|
||||
<g transform="translate(1457 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text></g>
|
||||
<g transform="translate(1670 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text></g>
|
||||
<g transform="translate(1030 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text></g>
|
||||
<g transform="translate(1210 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text></g>
|
||||
<g transform="translate(1490 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text></g>
|
||||
<g transform="translate(1670 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text></g>
|
||||
<g transform="translate(1030 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#a855f7"/><text y="17" text-anchor="middle" class="label">ivy</text></g>
|
||||
<g transform="translate(1243 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f43f5e"/><text y="17" text-anchor="middle" class="label">rex</text></g>
|
||||
<g transform="translate(1457 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#38bdf8"/><text y="17" text-anchor="middle" class="label">zoe</text></g>
|
||||
<g transform="translate(1670 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#84cc16"/><text y="17" text-anchor="middle" class="label">sam</text></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -22,6 +22,7 @@
|
|||
| [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API |
|
||||
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
|
||||
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
|
||||
| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, включая `CLAUDE_TEAM_TEAMMATE_MODE=tmux` для pane-backed teammate debug |
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
|
|
|
|||
2147
docs/team-management/agent-attachments-architecture-plan.md
Normal file
2147
docs/team-management/agent-attachments-architecture-plan.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1218
docs/team-management/agent-attachments-phase-3-codex-native-plan.md
Normal file
1218
docs/team-management/agent-attachments-phase-3-codex-native-plan.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
139
docs/team-management/agent-attachments.md
Normal file
139
docs/team-management/agent-attachments.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Agent attachments
|
||||
|
||||
This document describes the v1 attachment path for Agent Teams.
|
||||
|
||||
## Supported runtime paths
|
||||
|
||||
- Claude lead/runtime: structured stream-json content blocks.
|
||||
- Codex native: optimized app-owned image files passed with repeatable `--image <file>`.
|
||||
- OpenCode: model-gated `file` parts sent through the OpenCode session API.
|
||||
|
||||
Do not append base64 to prompt text. Base64 is only valid inside provider-native structured payloads.
|
||||
|
||||
## Current non-image file policy
|
||||
|
||||
- Claude: `text/*` files and PDFs are allowed through structured document blocks.
|
||||
- Codex native: non-image files are blocked before provider delivery. Codex receives images only through the native image channel in this phase.
|
||||
- OpenCode: non-image files are blocked before provider delivery. OpenCode receives verified image file parts only in this phase.
|
||||
- Unknown or binary file types are blocked before provider delivery.
|
||||
|
||||
This policy is intentionally conservative. It avoids silent text-only fallbacks, accidental huge stdin payloads, and provider-specific behavior that is not covered by live smokes.
|
||||
|
||||
## Current image model policy
|
||||
|
||||
- Claude: image attachments are allowed through structured image blocks.
|
||||
- Codex native: image attachments are allowed through native image args.
|
||||
- OpenCode `openai/gpt-5.4-mini`: allowed.
|
||||
- OpenCode `openrouter/moonshotai/kimi-k2.6`: allowed.
|
||||
- OpenCode `openrouter/z-ai/glm-4.5v`: allowed.
|
||||
- OpenCode `openrouter/z-ai/glm-5.1`: blocked for images.
|
||||
- Unknown OpenCode models: blocked for images until verified.
|
||||
|
||||
Text-only messages continue to work for unsupported image models.
|
||||
|
||||
## Size and optimization rules
|
||||
|
||||
The renderer optimizes images before send. The backend still validates and owns final delivery decisions.
|
||||
|
||||
- Original attachments are immutable.
|
||||
- Optimized variants are derived artifacts.
|
||||
- If optimized images exceed the runtime budget, sending must fail before provider delivery.
|
||||
- Multiple images must be delivered together or blocked together. No partial image delivery.
|
||||
|
||||
## Diagnostics rules
|
||||
|
||||
Diagnostics may include:
|
||||
|
||||
- attachment count;
|
||||
- optimized bytes;
|
||||
- target runtime and model;
|
||||
- capability decision;
|
||||
- provider/runtime error text.
|
||||
|
||||
Diagnostics must not include:
|
||||
|
||||
- base64 payloads;
|
||||
- data URLs;
|
||||
- API keys;
|
||||
- bearer tokens.
|
||||
|
||||
## Smoke tests
|
||||
|
||||
The smoke harness generates a deterministic red PNG and checks real CLI transports.
|
||||
|
||||
List cases:
|
||||
|
||||
```bash
|
||||
node scripts/smoke/agent-attachments-smoke.mjs --list
|
||||
```
|
||||
|
||||
Run all cases and save a JSON report:
|
||||
|
||||
```bash
|
||||
node scripts/smoke/agent-attachments-smoke.mjs --all --json /tmp/agent-attachments-smoke.json
|
||||
```
|
||||
|
||||
Run Codex native:
|
||||
|
||||
```bash
|
||||
node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini
|
||||
node scripts/smoke/agent-attachments-smoke.mjs --case codex-native-gpt-5-4-mini-multi-image
|
||||
```
|
||||
|
||||
Run Claude subscription stream-json:
|
||||
|
||||
```bash
|
||||
node scripts/smoke/agent-attachments-smoke.mjs --case claude-subscription-streaming
|
||||
node scripts/smoke/agent-attachments-smoke.mjs --case claude-subscription-streaming-multi-image
|
||||
```
|
||||
|
||||
Run OpenCode OpenAI:
|
||||
|
||||
```bash
|
||||
node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openai-gpt-5-4-mini
|
||||
```
|
||||
|
||||
Run OpenRouter cases:
|
||||
|
||||
```bash
|
||||
OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-kimi-k2-6
|
||||
OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-kimi-k2-6-multi-image
|
||||
OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-glm-4-5v
|
||||
OPENROUTER_API_KEY=... node scripts/smoke/agent-attachments-smoke.mjs --case opencode-openrouter-glm-5-1-negative
|
||||
```
|
||||
|
||||
The script extracts assistant/result text from JSONL output before matching expected answers. This prevents false positives from prompts, base64 payloads, or diagnostics. It also redacts stdout/stderr tails for generated image bytes, data URLs, bearer tokens, API keys, environment-provided secrets, and long provider metadata signatures.
|
||||
|
||||
## Live verification record
|
||||
|
||||
Latest local verification: 2026-05-09.
|
||||
|
||||
| Scope | Command or case | Result | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude visual transport | `claude-subscription-streaming` | passed | Real Claude CLI `stream-json` run answered `red` for generated PNG. |
|
||||
| Claude multi-image transport | `claude-subscription-streaming-multi-image` | passed | Real Claude CLI `stream-json` run received three generated PNGs and answered `red` from extracted assistant text. |
|
||||
| Codex visual transport | `codex-native-gpt-5-4-mini` | passed | Real Codex native `--image` run answered `red` for generated PNG. |
|
||||
| Codex multi-image transport | `codex-native-gpt-5-4-mini-multi-image` | passed | Real Codex native run received three repeated `--image` args and answered `red` from extracted assistant text. |
|
||||
| OpenCode OpenAI visual transport | `opencode-openai-gpt-5-4-mini` | passed | Real OpenCode file attachment run answered `red` after local OpenCode OpenAI auth was refreshed. |
|
||||
| OpenRouter Kimi visual transport | `opencode-openrouter-kimi-k2-6` | passed | Real OpenCode file attachment run through OpenRouter answered `red` for generated PNG. |
|
||||
| OpenRouter Kimi multi-image transport | `opencode-openrouter-kimi-k2-6-multi-image` | passed | Real OpenCode file attachment run through OpenRouter received three generated PNGs and answered `red` from extracted assistant text. |
|
||||
| OpenRouter GLM vision transport | `opencode-openrouter-glm-4-5v` | passed | Real OpenCode file attachment run through OpenRouter answered `red` for generated PNG. |
|
||||
| OpenRouter GLM non-vision guard | `opencode-openrouter-glm-5-1-negative` | passed as guard | Model responded that it cannot process images. The app policy blocks this model for image attachments before app delivery. |
|
||||
| CLI process launch | `scripts/prove-agent-cli-launch.mjs` | passed | Real `opencode`, `codex`, and `claude` binaries launched through `execCli` and `spawnCli`. |
|
||||
| OpenCode team provisioning | `scripts/prove-opencode-team-provisioning.mjs` with `OPENCODE_E2E_MODEL=openai/gpt-5.4-mini` | passed | Real pure OpenCode team created through `TeamProvisioningService`, live members verified, then stopped. |
|
||||
| Mixed Anthropic + Codex + OpenCode team launch | `MixedProviderTeamLaunch.live.test.ts` | passed | Real mixed team launch passed with Claude subscription auth, Codex subscription auth, and OpenCode. |
|
||||
|
||||
`--all` can return non-zero when local provider auth is invalidated. Treat the per-case rows above as the release signal when debugging local credential issues.
|
||||
|
||||
## Release checklist
|
||||
|
||||
- Text-only messages still work for Claude, Codex, and OpenCode.
|
||||
- Oversized images fail before provider delivery.
|
||||
- Claude image send uses structured image blocks.
|
||||
- Claude text/PDF file send uses structured document blocks.
|
||||
- Codex image send uses `--image`, not prompt base64.
|
||||
- Codex non-image files fail before provider delivery.
|
||||
- OpenCode image send is blocked for unknown/non-vision models.
|
||||
- OpenCode non-image files fail before provider delivery.
|
||||
- Attachment retry reuses the same artifacts or fails loudly.
|
||||
- Copied diagnostics do not include base64 or data URLs.
|
||||
|
|
@ -52,6 +52,34 @@ Primary launch and OpenCode secondary lanes are different paths.
|
|||
|
||||
When a launch hangs at `Prepared communication channels for X/Y members`, check whether `Y` incorrectly includes secondary OpenCode members. The filesystem monitor should wait for `effectiveMembers`, not every requested member.
|
||||
|
||||
## Teammate Runtime Debug Mode
|
||||
|
||||
Desktop launches use the app-managed process backend by default. That is the supported default for
|
||||
normal app launches because the app owns the process lifecycle, runtime logs, cleanup, and bootstrap
|
||||
evidence.
|
||||
|
||||
For local debugging, force pane-backed teammates through `tmux`:
|
||||
|
||||
```bash
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
```
|
||||
|
||||
For a single launch from the UI, add this to custom CLI args:
|
||||
|
||||
```bash
|
||||
--teammate-mode tmux
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
- `tmux` mode should remove `CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES` from the launch env.
|
||||
- The desktop app should pass `--teammate-mode tmux` to the runtime CLI.
|
||||
- The orchestrator should report `backend_type: "tmux"` and `tmux_pane_id` like `%1`.
|
||||
- If `tmux` is unavailable, the launch dialog should block explicit tmux mode with a tmux readiness message.
|
||||
|
||||
Use this mode to inspect interactive CLI behavior, terminal prompts, and pane output. Do not treat it
|
||||
as equivalent to the process backend for recovery semantics; persisted pane IDs can help discovery,
|
||||
but app restart does not make old panes a fully app-owned runtime again.
|
||||
|
||||
## Member State Meanings
|
||||
|
||||
Common `launch-state.json` cases:
|
||||
|
|
|
|||
1231
docs/team-management/member-log-stream-v2-implementation-plan.md
Normal file
1231
docs/team-management/member-log-stream-v2-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load diff
2046
docs/team-management/member-log-stream-v2-research-addendum.md
Normal file
2046
docs/team-management/member-log-stream-v2-research-addendum.md
Normal file
File diff suppressed because it is too large
Load diff
2078
docs/team-management/member-work-sync-review-obligation-plan.md
Normal file
2078
docs/team-management/member-work-sync-review-obligation-plan.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -132,6 +132,7 @@ export default defineConfig({
|
|||
}
|
||||
},
|
||||
renderer: {
|
||||
cacheDir: resolve(__dirname, 'node_modules/.vite/electron-renderer'),
|
||||
optimizeDeps: {
|
||||
include: ['@codemirror/language-data'],
|
||||
exclude: ['@claude-teams/agent-graph']
|
||||
|
|
|
|||
BIN
graph-log-preview-smoke.png
Normal file
BIN
graph-log-preview-smoke.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
export const useGithubRepo = () => {
|
||||
const config = useRuntimeConfig();
|
||||
const githubRepo = computed(
|
||||
() => (config.public.githubRepo as string) || '777genius/claude_agent_teams_ui',
|
||||
() => (config.public.githubRepo as string) || '777genius/agent-teams-ai',
|
||||
);
|
||||
const repoUrl = computed(() => `https://github.com/${githubRepo.value}`);
|
||||
const releasesUrl = computed(
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function writeCache(data: DownloadsApiResponse): void {
|
|||
|
||||
export const useReleaseDownloads = () => {
|
||||
const config = useRuntimeConfig();
|
||||
const githubRepo = (config.public.githubRepo as string) || "777genius/claude_agent_teams_ui";
|
||||
const githubRepo = (config.public.githubRepo as string) || "777genius/agent-teams-ai";
|
||||
|
||||
const fallbackUrl =
|
||||
(config.public.githubReleasesUrl as string) ||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { generateI18nRoutes, supportedLocales } from "./data/i18n";
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare const process: any;
|
||||
|
||||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui";
|
||||
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
|
||||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai";
|
||||
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai";
|
||||
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
|
||||
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
|
||||
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|||
import { defineConfig, type DefaultTheme } from "vitepress";
|
||||
import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms";
|
||||
|
||||
const REPO = "777genius/claude_agent_teams_ui";
|
||||
const REPO = "777genius/agent-teams-ai";
|
||||
const SITE_TITLE = "Agent Teams Docs";
|
||||
const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration.";
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ const withTrailingSlash = (value: string) => `${trimTrailingSlash(value)}/`;
|
|||
const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/");
|
||||
const base = appBase === "/" ? "/docs/" : `${appBase}docs/`;
|
||||
const siteUrl = trimTrailingSlash(
|
||||
process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui"
|
||||
process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai"
|
||||
);
|
||||
const publicBaseUrl =
|
||||
appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase))
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const props = withDefaults(
|
|||
copiedLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
command: "git clone https://github.com/777genius/claude_agent_teams_ui.git",
|
||||
command: "git clone https://github.com/777genius/agent-teams-ai.git",
|
||||
label: "Click to copy",
|
||||
copiedLabel: "Copied"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,34 +2,84 @@
|
|||
|
||||
Agent Teams makes agent work visible as task state, messages, logs, and reviewable code changes.
|
||||
|
||||
## Lifecycle
|
||||
## Modes
|
||||
|
||||
| Stage | What happens |
|
||||
| Mode | Description |
|
||||
| --- | --- |
|
||||
| Provisioning | The app starts the team and confirms runtime readiness |
|
||||
| Planning | The lead creates tasks and may assign teammates |
|
||||
| In progress | Agents work in parallel and update task state |
|
||||
| Review | Changes are reviewed by agents or by you |
|
||||
| Done | Accepted work stays linked to its task history |
|
||||
| Solo | One teammate with self-managed tasks |
|
||||
| Team | Many teammates working in parallel, reviewing each other |
|
||||
|
||||
Both modes share the same kanban, task logs, and code review surfaces.
|
||||
|
||||
## Task lifecycle
|
||||
|
||||
| Stage | What happens | Owner |
|
||||
| --- | --- | --- |
|
||||
| Provisioning | The app starts the runtime, confirms the process is alive, and waits for bootstrap confirmation | App |
|
||||
| Planning | The lead creates tasks, optionally assigns teammates, and sets dependencies | Lead or user |
|
||||
| In progress | Agents work in parallel and update task state via board MCP tools | Teammates |
|
||||
| Review | Changes are reviewed by agents or by you before final acceptance | Team lead or user |
|
||||
| Done | Accepted work stays linked to its task history and can still be inspected later | User |
|
||||
|
||||
### Planning → In progress
|
||||
|
||||
When a teammate starts a task, the board status becomes `in_progress`. The agent creates a task comment with its plan and continues working. All native tool actions (read, bash, edit, write) are streamed into a task log.
|
||||
|
||||
### In progress → Review
|
||||
|
||||
When the teammate finishes work, it posts a result comment and marks the task `completed`. The lead can then decide whether to accept it immediately or move it into review.
|
||||
|
||||
### Review → Done
|
||||
|
||||
If the review surface shows acceptable changes, approve the review. The task is finalized and linked to its diff.
|
||||
|
||||
::: warning Fix-first review
|
||||
If a teammate is asked for changes during review, it should post a follow-up comment with the fixes, then the lead can approve.
|
||||
:::
|
||||
|
||||
## Kanban board
|
||||
|
||||
The board is the primary operating surface. It lets you scan work, spot blocked tasks, open task detail, inspect logs, and review changes without reading raw session files.
|
||||
The board is the primary operating surface. It lets you:
|
||||
|
||||
- Scan open, blocked, and in-review work
|
||||
- Open task detail and inspect runtime logs
|
||||
- Review changes without reading raw session files
|
||||
- Assign or reassign owners
|
||||
|
||||
::: tip
|
||||
Use quick action buttons on cards to start, complete, or request review without opening the detail panel.
|
||||
:::
|
||||
|
||||
## Messages and comments
|
||||
|
||||
Use direct messages when you need to redirect an agent. Use task comments when the note belongs to a specific piece of work. Comments preserve context for later review.
|
||||
| Channel | When to use |
|
||||
| --- | --- |
|
||||
| Direct message | Redirect an agent, ask a quick question |
|
||||
| Task comment | Notes that belong to a specific task |
|
||||
|
||||
Comments preserve context for later review and appear in the task timeline.
|
||||
|
||||
::: tip Prefer task comments
|
||||
If the remark is about a specific task, add it as a comment on that task rather than sending a direct message. It keeps the history linked to the work.
|
||||
:::
|
||||
|
||||
## Task logs
|
||||
|
||||
Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them when you need to answer:
|
||||
Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them to answer:
|
||||
|
||||
- what did this agent run?
|
||||
- why did it change this file?
|
||||
- did it ask another teammate for help?
|
||||
- which task produced this diff?
|
||||
- What did this agent run?
|
||||
- Why did it change this file?
|
||||
- Did it ask another teammate for help?
|
||||
- Which task produced this diff?
|
||||
|
||||
## Parallel work patterns
|
||||
|
||||
Teammates can work on independent tasks at the same time. You can also create dependency links (`blocked-by`) so that one task waits until another is complete. Watch the board for blocked lanes and reassign owners if one teammate is idle while another is overloaded.
|
||||
|
||||
## Live processes
|
||||
|
||||
The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results.
|
||||
The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results. Processes remain registered until they are explicitly stopped or the runtime exits.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Agents can send messages to other teams when teams are linked. Use this for handoffs, shared libraries, or status checks between squads.
|
||||
|
|
|
|||
|
|
@ -4,32 +4,60 @@ Code review in Agent Teams is task-centered. You inspect what changed for a spec
|
|||
|
||||
## Review surface
|
||||
|
||||
Use the review UI to:
|
||||
For each completed task that touched files, the review UI lets you:
|
||||
|
||||
- inspect changed files
|
||||
- accept or reject individual hunks
|
||||
- leave comments
|
||||
- connect the diff back to the task and agent logs
|
||||
- Inspect changed files with before/after context
|
||||
- Accept or reject individual hunks
|
||||
- Leave inline comments
|
||||
- Connect the diff back to the task description and agent logs
|
||||
|
||||
## Hunk-level decisions
|
||||
|
||||
Accept small correct changes and reject isolated mistakes without throwing away the whole task. This is useful when an agent mostly solved the task but overreached in one file.
|
||||
|
||||
::: tip Accept incrementally
|
||||
If a diff is mostly correct, accept the good hunks first and request changes only for the parts that need fixing. This keeps the board moving.
|
||||
:::
|
||||
|
||||
## Initiating review
|
||||
|
||||
1. Open a completed task
|
||||
2. Look at the **Changes** tab
|
||||
3. If the diff looks reasonable, click **Request Review** to move the task into the review column
|
||||
|
||||
During review the task is not yet considered done, so other teammates or the lead can still comment on it.
|
||||
|
||||
## Review states
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `none` | Task is new, in progress, or completed but not yet in review |
|
||||
| `review` | The task is actively under review |
|
||||
| `needsFix` | Changes were requested; the owner must update before re-approval |
|
||||
| `approved` | The review was accepted and the task is finalized |
|
||||
|
||||
## Agent review workflow
|
||||
|
||||
Teams can review each other's work before you make the final call. This catches obvious regressions and keeps the board honest, but you should still review risky areas yourself.
|
||||
|
||||
## Review participants
|
||||
|
||||
The team lead is the default reviewer. You can configure additional reviewers in the Kanban settings if you want peers to review each other's work.
|
||||
|
||||
## What to check manually
|
||||
|
||||
Prioritize:
|
||||
Prioritize these areas when reviewing:
|
||||
|
||||
- provider auth and runtime detection
|
||||
- IPC, preload, and filesystem boundaries
|
||||
- Git and worktree behavior
|
||||
- parsing and task lifecycle logic
|
||||
- persistence and code review flows
|
||||
- **Provider auth and runtime detection** — did the agent change runtime setup in a way that would break other paths?
|
||||
- **IPC, preload, and filesystem boundaries** — keep Electron responsibilities separated
|
||||
- **Git and worktree behavior** — verify branch naming, commits, and pushes
|
||||
- **Parsing and task lifecycle logic** — changes to task references, chunking, or filtering can break message delivery
|
||||
- **Persistence and code review flows** — changes to task storage or review state must stay consistent across IPC layers
|
||||
|
||||
## Verification
|
||||
|
||||
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.
|
||||
|
||||
::: warning Do not auto-format across the whole project
|
||||
Unless the task is specifically about formatting, avoid running `pnpm lint:fix` on unrelated files. It creates noise in the review surface.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -6,14 +6,28 @@ A team is a named group of agents with roles, a lead, a target project, and a co
|
|||
|
||||
Start with a small team:
|
||||
|
||||
| Role | Purpose |
|
||||
| --- | --- |
|
||||
| Lead | Splits work, creates tasks, coordinates teammates |
|
||||
| Builder | Implements scoped tasks |
|
||||
| Role | Purpose |
|
||||
| -------- | --------------------------------------------------- |
|
||||
| Lead | Splits work, creates tasks, coordinates teammates |
|
||||
| Builder | Implements scoped tasks |
|
||||
| Reviewer | Reviews output, catches regressions, asks for fixes |
|
||||
|
||||
This shape gives you enough coordination to see the product value without making the first launch noisy.
|
||||
|
||||
::: tip
|
||||
You can add more members later. Start small, validate the workflow, then scale up.
|
||||
:::
|
||||
|
||||
## Assign providers and models
|
||||
|
||||
Each team member runs on a provider backend. In the team editor, pick a provider (Claude, Codex, or OpenCode) and a model for every member. The app shows only providers you have already authenticated.
|
||||
|
||||
Mixing providers in one team is supported — for example, a Claude lead with OpenCode builders.
|
||||
|
||||
::: info
|
||||
Gemini support is in development and will appear in the provider list when available.
|
||||
:::
|
||||
|
||||
## Write a good team brief
|
||||
|
||||
The team brief should include:
|
||||
|
|
@ -30,10 +44,40 @@ Example:
|
|||
Build a focused improvement to the download flow. Keep changes inside the landing app unless a shared helper is clearly needed. Create tasks before implementation, review each task diff, and run landing lint/build checks.
|
||||
```
|
||||
|
||||
## Worktree isolation
|
||||
|
||||
OpenCode members can use **worktree isolation** to work in a separate Git worktree instead of the main working directory. This prevents file conflicts when multiple agents edit the same project.
|
||||
|
||||
::: warning
|
||||
Worktree isolation requires a Git-tracked project and is currently limited to OpenCode members.
|
||||
:::
|
||||
|
||||
To enable it, toggle the **Worktree isolation** option when adding or editing an OpenCode team member.
|
||||
|
||||
## Choose autonomy
|
||||
|
||||
Agent Teams supports different levels of control. Use more autonomy for routine changes and tighter review for risky areas like provider auth, IPC, persistence, Git workflows, and release tooling.
|
||||
|
||||
### Effort level
|
||||
|
||||
Each team member has an **effort** setting that controls how much reasoning the provider invests before responding. Higher effort produces more thorough output at the cost of time and tokens.
|
||||
|
||||
| Level | When to use |
|
||||
| ------ | ---------------------------------------------------------- |
|
||||
| Low | Quick lookups, small formatting changes, routine edits |
|
||||
| Medium | Default for most implementation tasks |
|
||||
| High | Complex refactors, cross-cutting changes, risky code paths |
|
||||
|
||||
The app offers additional levels (minimal, xhigh, max) for providers that support them. If a model does not support configurable effort, the selector is disabled and the provider default is used.
|
||||
|
||||
### Fast mode
|
||||
|
||||
Toggle **Fast mode** per member to prioritize speed over depth. This maps to the provider's native fast/speed mode when available. Set it to **On** for routine tasks, **Off** for careful work, or **Inherit** to follow the team-level default.
|
||||
|
||||
### Limit context
|
||||
|
||||
Enable **Limit context** to reduce the context window for a member. This is useful for Claude models that support extended context (e.g. 1M tokens) — limiting context avoids unnecessary token usage and can improve latency for tasks that do not need large context.
|
||||
|
||||
## Add context
|
||||
|
||||
Attach files, screenshots, or specific notes when they materially change the task. Agents can use task descriptions, comments, and attachments as durable context.
|
||||
|
|
@ -49,3 +93,8 @@ Good teams create tasks that are:
|
|||
|
||||
If the lead creates vague tasks, send a direct message asking for smaller, testable tasks.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Runtime setup](/guide/runtime-setup) — configure provider auth and models
|
||||
- [Code review](/guide/code-review) — accept, reject, or comment on agent changes
|
||||
- [Troubleshooting](/guide/troubleshooting) — common issues and fixes
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
|
|||
|
||||
## Download builds
|
||||
|
||||
Use the latest GitHub release when you want the packaged app:
|
||||
Use the <a href="/download/" target="_self">download page</a> or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app:
|
||||
|
||||
- macOS Apple Silicon: `.dmg`
|
||||
- macOS Intel: `.dmg`
|
||||
|
|
@ -17,29 +17,60 @@ Unsigned or newly published open-source apps can trigger SmartScreen. If you tru
|
|||
|
||||
## Requirements
|
||||
|
||||
The packaged app is designed for zero-setup onboarding. It can guide runtime detection and provider authentication from the UI.
|
||||
The packaged app is designed for zero-setup onboarding. It guides you through runtime detection and provider authentication from the UI — no manual CLI configuration needed.
|
||||
|
||||
For source development, use:
|
||||
To use agent runtimes, you need access to at least one provider:
|
||||
|
||||
| Tool | Version |
|
||||
| --- | --- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
| Provider | Access method |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
| Claude (Anthropic) | Claude Code CLI login or API key |
|
||||
| Codex (OpenAI) | Codex CLI login or API key |
|
||||
| Gemini (Google) | _In development_ |
|
||||
| OpenCode | API key for a supported backend (e.g. OpenRouter) |
|
||||
|
||||
::: info
|
||||
Gemini provider support is in development. You can prepare access now, but it will not appear in the team editor until it is ready.
|
||||
:::
|
||||
|
||||
For source development, you also need:
|
||||
|
||||
| Tool | Version |
|
||||
| ------- | ------- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
|
||||
## Run from source
|
||||
|
||||
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" />
|
||||
<InstallBlock command="git clone https://github.com/777genius/agent-teams-ai.git && cd agent-teams-ai && pnpm install && pnpm dev" />
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
||||
cd claude_agent_teams_ui
|
||||
git clone https://github.com/777genius/agent-teams-ai.git
|
||||
cd agent-teams-ai
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
If you want the freshest local version, use the repository branch that currently carries active development.
|
||||
The `main` branch carries the latest stable development. Switch to feature branches only if you need a specific unreleased change.
|
||||
|
||||
## Updating
|
||||
## Auto-updates
|
||||
|
||||
Use the latest release for packaged builds. If you run from source, pull the branch you use and rerun install when dependencies change.
|
||||
The packaged app checks for updates automatically on launch and periodically while running. When an update is available, the app prompts you to download and install it. You can also check manually from the app menu.
|
||||
|
||||
::: tip
|
||||
Auto-updates are not available when running from source. Pull the latest changes and rerun `pnpm install` when dependencies change.
|
||||
:::
|
||||
|
||||
## Updating from source
|
||||
|
||||
If you run from source, pull the `main` branch and rerun install when dependencies change:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Quickstart](/guide/quickstart) — from install to first running team
|
||||
- [Runtime setup](/guide/runtime-setup) — provider auth and model selection per runtime
|
||||
- [Create a team](/guide/create-team) — recommended team shapes and brief writing
|
||||
|
|
|
|||
|
|
@ -1,33 +1,45 @@
|
|||
# Quickstart
|
||||
|
||||
This guide gets you from a fresh install to a running team.
|
||||
This guide gets you from a fresh install to a running team in a few minutes.
|
||||
|
||||
## 1. Install Agent Teams
|
||||
|
||||
Download the latest release for your platform from the landing page or GitHub releases.
|
||||
Download the latest release for your platform from the <a href="/download/" target="_self">download page</a> or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
|
||||
|
||||
::: tip
|
||||
The app is free and open source. The agent runtime you choose may still require provider access, such as Claude, Codex, OpenCode, or API-key based providers.
|
||||
The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details.
|
||||
:::
|
||||
|
||||
## 2. Open or create a project
|
||||
|
||||
Launch the app and select the project directory you want agents to work in. Agent Teams reads local project files and runtime/session state so the UI can show tasks, logs, diffs, and teammate activity.
|
||||
|
||||
::: tip
|
||||
Pick a Git-tracked project for the best experience. Worktree isolation and diff-based review both rely on Git.
|
||||
:::
|
||||
|
||||
## 3. Choose a runtime path
|
||||
|
||||
Use the setup flow to detect available runtimes. A common first setup is:
|
||||
The setup flow auto-detects installed runtimes on your machine. A common first setup is:
|
||||
|
||||
| Runtime | Good for |
|
||||
| --- | --- |
|
||||
| Claude | Claude Code users and existing Anthropic access |
|
||||
| Codex | Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Multimodel teams and many provider backends |
|
||||
| Runtime | Good for |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| Claude | Claude Code users and existing Anthropic access |
|
||||
| Codex | Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Multi-model teams and many provider backends |
|
||||
|
||||
::: info
|
||||
Gemini support is in development and will appear in the runtime list when available.
|
||||
:::
|
||||
|
||||
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
|
||||
|
||||
## 4. Create your first team
|
||||
|
||||
Create a team with a lead and one or more specialists. Keep the first team small: one lead, one implementation agent, and one review-oriented agent is enough to validate the workflow.
|
||||
|
||||
See [Create a team](/guide/create-team) for the recommended structure and tips.
|
||||
|
||||
## 5. Give the lead a concrete goal
|
||||
|
||||
Write the goal like you would brief an engineering lead:
|
||||
|
|
@ -36,15 +48,16 @@ Write the goal like you would brief an engineering lead:
|
|||
Improve the onboarding flow. Split the work into tasks, keep changes small, and ask for review before broad refactors.
|
||||
```
|
||||
|
||||
The lead should create tasks, assign work, and coordinate teammates. You can watch progress on the kanban board and intervene with comments or direct messages.
|
||||
The lead creates tasks, assigns work, and coordinates teammates. You can watch progress on the kanban board and intervene with comments or direct messages at any time.
|
||||
|
||||
## 6. Review results
|
||||
|
||||
Open completed or review-ready tasks, inspect the diff, and accept, reject, or comment on individual changes. Use task logs when you need to understand why an agent made a choice.
|
||||
|
||||
See [Code review](/guide/code-review) for the full review workflow.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Create a team](/guide/create-team)
|
||||
- [Runtime setup](/guide/runtime-setup)
|
||||
- [Code review](/guide/code-review)
|
||||
|
||||
- [Create a team](/guide/create-team) — recommended team shapes and brief writing
|
||||
- [Runtime setup](/guide/runtime-setup) — provider auth and model selection
|
||||
- [Code review](/guide/code-review) — review, approve, or request changes
|
||||
|
|
|
|||
|
|
@ -2,13 +2,25 @@
|
|||
|
||||
Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before launching a team, make sure:
|
||||
|
||||
- The runtime binary is installed and on your `PATH`.
|
||||
- Your provider account has active access to the model you intend to use.
|
||||
- The project path exists and is readable.
|
||||
|
||||
::: tip
|
||||
Start with a single teammate and one provider. Confirm one launch works before adding multimodel lanes.
|
||||
:::
|
||||
|
||||
## Supported paths
|
||||
|
||||
| Path | Use when |
|
||||
| --- | --- |
|
||||
| Claude | You already use Claude Code or Anthropic-backed workflows |
|
||||
| Codex | You want Codex-native runtime integration |
|
||||
| OpenCode | You want multimodel routing and broad provider coverage |
|
||||
| Path | Default CLI | Typical providers | Use when |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude | `claude` | Anthropic | You already use Claude Code or Anthropic-backed workflows |
|
||||
| Codex | `codex` | OpenAI | You want Codex-native runtime integration |
|
||||
| OpenCode | `opencode` | OpenRouter and many backends | You want multimodel routing and broad provider coverage |
|
||||
|
||||
The app detects supported runtimes and guides setup from the UI when possible.
|
||||
|
||||
|
|
@ -16,18 +28,71 @@ The app detects supported runtimes and guides setup from the UI when possible.
|
|||
|
||||
Agent Teams has no paid tier of its own. You bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose.
|
||||
|
||||
- **Claude** and **Codex** paths rely on their respective CLI auth tools.
|
||||
- **OpenCode** needs provider-specific API keys in a config file (e.g., `openrouter`, `openai`, `anthropic`).
|
||||
|
||||
## Auth configuration
|
||||
|
||||
### Claude Code
|
||||
|
||||
Run the standard auth flow in a terminal:
|
||||
|
||||
```bash
|
||||
claude login
|
||||
```
|
||||
|
||||
Then verify the CLI is reachable:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
```
|
||||
|
||||
### Codex
|
||||
|
||||
Install and authenticate via OpenAI's CLI flow:
|
||||
|
||||
```bash
|
||||
codex login
|
||||
```
|
||||
|
||||
### OpenCode
|
||||
|
||||
Create or edit `~/.opencode/config.json` (or the equivalent path on your platform) with the provider key you want:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the exact provider name that OpenCode expects. If you set a custom provider name, double-check it against the provider ID you use in the model string (for example `openrouter/moonshotai/kimi-k2.6` would use the `openrouter` block).
|
||||
|
||||
## Multimodel mode
|
||||
|
||||
Multimodel mode can route work through many provider backends via OpenCode-compatible configuration. Use it when you need provider flexibility or want teammates to use different model lanes.
|
||||
|
||||
## Operational advice
|
||||
::: info Model lanes
|
||||
Each teammate can use a different `providerId` + `model` pair. In the team edit UI, expand member options to override the global defaults.
|
||||
:::
|
||||
|
||||
- Keep the first runtime setup simple.
|
||||
- Confirm one team can launch before adding many providers.
|
||||
- Treat auth, provider model names, and runtime PATH issues as setup problems, not team-prompt problems.
|
||||
- If launch hangs, check the troubleshooting page before changing code.
|
||||
## Prelaunch checklist
|
||||
|
||||
Before launching a team:
|
||||
|
||||
1. The selected runtime is installed
|
||||
2. The runtime binary is in the environment `PATH`
|
||||
3. Provider auth is configured for the chosen backend
|
||||
4. The provider has access to the exact model string you specify
|
||||
5. The project path exists and is readable
|
||||
|
||||
## When to switch runtime paths
|
||||
|
||||
Switch when the current path is blocked by model availability, rate limits, provider capabilities, or team role needs. Keep the same project and team workflow, but validate one small task after switching.
|
||||
|
||||
::: warning Treat setup errors as setup problems
|
||||
If auth fails, a model name is rejected, or the runtime binary cannot be found, fix the setup first. Do not change team prompts or project code to work around a runtime configuration issue.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,40 +1,158 @@
|
|||
# Troubleshooting
|
||||
|
||||
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, or provider limits.
|
||||
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, and provider limits.
|
||||
|
||||
## Team does not launch
|
||||
|
||||
Check:
|
||||
Check each item in order:
|
||||
|
||||
- the selected runtime is installed or authenticated
|
||||
- the runtime is available in the environment PATH
|
||||
- the provider has access to the requested model
|
||||
- the project path exists and is readable
|
||||
1. **Runtime available** — the selected CLI (`claude`, `codex`, `opencode`) is installed
|
||||
2. **PATH reachable** — the binary is available in the environment `PATH`
|
||||
3. **Model access** — the provider has access to the requested model string (especially for OpenCode, exact provider/model names matter)
|
||||
4. **Project path** — the project directory exists and is readable
|
||||
5. **Network / VPN** — some providers drop traffic when a VPN is active
|
||||
|
||||
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect launch logs before changing team prompts.
|
||||
::: tip
|
||||
Run the runtime binary in a terminal to verify `PATH` and auth. Example: `claude --version` or `opencode --version`.
|
||||
:::
|
||||
|
||||
### OpenCode: registered but bootstrap unconfirmed
|
||||
|
||||
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
|
||||
|
||||
Look at the newest launch failure artifact:
|
||||
|
||||
```bash
|
||||
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
|
||||
```
|
||||
|
||||
The manifest inside includes:
|
||||
|
||||
- `classification` — why the launch was considered a failure
|
||||
- `bootstrapTransportBreadcrumb` — delivery path used
|
||||
- Member spawn statuses
|
||||
- Redacted logs and traces
|
||||
|
||||
Also check the lane manifest:
|
||||
|
||||
```bash
|
||||
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json
|
||||
jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane>/manifest.json
|
||||
```
|
||||
|
||||
::: tip Do not guess from the UI
|
||||
Always correlate UI diagnostics with persisted files (`launch-state.json`, `bootstrap-journal.jsonl`) and runtime-specific evidence.
|
||||
:::
|
||||
|
||||
## Agent replies are missing
|
||||
|
||||
Open task logs and teammate messages. Missing replies often come from runtime delivery, parsing, or task filtering issues. Do not assume the model ignored the message until logs confirm it.
|
||||
Open task logs and teammate messages. Missing replies often come from:
|
||||
|
||||
- **Runtime delivery retry** — the agent may have answered, but the message was not delivered to the app. Check the delivery ledger.
|
||||
- **Parsing or filtering** — the agent output did not include expected markers or task references.
|
||||
- **Task attribution** — the work happened during the session but was not linked to the task because the correct task id was missing from the output.
|
||||
|
||||
::: warning Do not assume silence means ignoring
|
||||
Do not assume the model ignored the message until logs confirm it.
|
||||
:::
|
||||
|
||||
## Tasks are not linked to changes
|
||||
|
||||
Use task-specific logs and code review links. If a diff appears detached, check whether the task id or task reference was included in the agent output.
|
||||
Use task-specific logs and code review links. If a diff appears detached:
|
||||
|
||||
- Check whether the task id or task reference was included in the agent output.
|
||||
- Verify the agent called `task_add_comment` before making edits.
|
||||
- Ensure the agent called `task_start` so the board knows work began.
|
||||
|
||||
For OpenCode teammates, the authoritative proof that a session belongs to a task is in `opencode-sessions.json` and the lane manifest entry, not only the UI message stream.
|
||||
|
||||
## Rate limits
|
||||
|
||||
If a provider reports a known reset time, Agent Teams can nudge the lead to continue after cooldown. If reset time is unknown, wait or switch provider/runtime path.
|
||||
|
||||
| Provider behavior | Suggested action |
|
||||
| --- | --- |
|
||||
| Known reset time displayed | Wait for cooldown and continue |
|
||||
| No reset time shown | Switch provider or runtime path |
|
||||
| Repeated 429s | Lower concurrency or use a different model lane |
|
||||
|
||||
## CLI auth issues
|
||||
|
||||
### `claude login` not persist
|
||||
|
||||
If the CLI is authenticated in one terminal but the app says it is not, verify the auth is saved to the expected config path and that the app process sees the same `$HOME`.
|
||||
|
||||
### OpenCode provider key rejected
|
||||
|
||||
- Double-check the provider name in `config.json` matches the provider prefix in the model string
|
||||
- Ensure the key is not expired or revoked in the provider dashboard
|
||||
|
||||
### Auth diagnostic log
|
||||
|
||||
Each call to `CliInstallerService.getStatus()` appends one line to `claude-cli-auth-diag.ndjson` in the Electron log folder (usually `~/Library/Logs/<product-name>/` on macOS). If the file exceeds **512 KiB**, it is truncated to empty before the next write.
|
||||
|
||||
Check this file if you see "Not logged in" or auth errors in the packaged app.
|
||||
|
||||
## Lane bootstrap stuck
|
||||
|
||||
For OpenCode secondary lanes:
|
||||
|
||||
- A missing `inboxes/<member>.json` is not automatically a bug. OpenCode lanes do not have to be primary-inbox-created before they start.
|
||||
- If the UI shows the team still launching while primary members are already usable, "all teammates joined" is waiting for secondary lanes.
|
||||
- If `Prepared communication channels for X/Y members` hangs, verify whether `Y` incorrectly includes secondary OpenCode members.
|
||||
|
||||
### Lane manifest empty entries
|
||||
|
||||
If the bridge says bootstrap succeeded but `manifest.json` shows `entries: []`, the issue is **evidence commit**, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
|
||||
|
||||
## Common member states
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `confirmed_alive` + `bootstrapConfirmed` | Healthy and ready |
|
||||
| `registered` / `runtime_pending_bootstrap` | Process or lane exists, but bootstrap proof has not been committed yet |
|
||||
| `failed_to_start` + `runtime_process` | Process exists, but launch gate failed. Check diagnostics |
|
||||
| `failed_to_start` + `stale_metadata` | Saved pid/session is stale or dead |
|
||||
|
||||
::: warning
|
||||
`member_briefing` by itself is NOT runtime evidence. For OpenCode, authoritative proof is committed runtime evidence such as `opencode-sessions.json` and the manifest entry.
|
||||
:::
|
||||
|
||||
## Runtime debug mode
|
||||
|
||||
For local debugging, you can force teammates to run in tmux panes:
|
||||
|
||||
```bash
|
||||
# Launch from a terminal
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
|
||||
# Or add to custom CLI args
|
||||
--teammate-mode tmux
|
||||
```
|
||||
|
||||
Use this to inspect interactive CLI behavior. Do not consider this fully equivalent to the process backend.
|
||||
|
||||
## Safe cleanup
|
||||
|
||||
When cleaning up stale processes:
|
||||
|
||||
1. Identify the pid and confirm it belongs to the current team / lane.
|
||||
2. Stop only processes explicitly belonging to a smoke test or the launch you are debugging.
|
||||
3. **Do not kill** all OpenCode or shared host processes as a shortcut.
|
||||
|
||||
## When to collect evidence
|
||||
|
||||
Collect:
|
||||
Before asking for help, collect:
|
||||
|
||||
- task id
|
||||
- team name
|
||||
- runtime path
|
||||
- launch log excerpt
|
||||
- provider/model
|
||||
- exact time window
|
||||
- Task id (short or full)
|
||||
- Team name
|
||||
- Runtime path (`claude`, `codex`, or `opencode`)
|
||||
- Launch log excerpt (from `latest.json` or `bootstrap-journal.jsonl`)
|
||||
- Provider / model string
|
||||
- Exact time window when the issue occurred
|
||||
|
||||
This is enough to debug most launch and task lifecycle issues.
|
||||
This data is usually enough to debug launch and task lifecycle issues.
|
||||
|
||||
::: tip
|
||||
If the issue persists, open the team's persisted files under `~/.claude/teams/<teamName>/` and correlate UI diagnostics with the live process state before changing code.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,32 +1,75 @@
|
|||
# Concepts
|
||||
|
||||
This page defines the core terms used across Agent Teams.
|
||||
This page defines the core terms used across Agent Teams. Use it as the shared vocabulary for the app, task board, messages, and review flow.
|
||||
|
||||
## Team
|
||||
|
||||
A team is a group of agents configured for a project. A team usually has a lead and one or more teammates with specialized roles.
|
||||
A team is a named group of agents attached to one project path. It has a lead, optional teammates, runtime/provider settings, prompts, inboxes, tasks, and local launch state.
|
||||
|
||||
## Lead
|
||||
|
||||
The lead coordinates work. It should break goals into tasks, assign teammates, track blockers, and ask for review when needed.
|
||||
The lead is the coordinator for the team. It turns a user goal into tasks, assigns or redirects teammates, tracks blockers, asks for review, and keeps work moving through the board.
|
||||
|
||||
Lead messages use a different delivery path from teammate messages: the app relays lead inbox entries into the lead runtime, while teammates read their own inbox files between turns.
|
||||
|
||||
## Teammate
|
||||
|
||||
A teammate is a non-lead agent in the team. Teammates usually own focused roles such as builder, reviewer, researcher, or tester. A teammate can receive direct messages, task assignments, task comments, and review requests.
|
||||
|
||||
## Task
|
||||
|
||||
A task is the durable unit of work. It has status, description, comments, logs, attachments, and reviewable changes.
|
||||
A task is the durable unit of work. It has an id, status, owner, description, comments, logs, attachments, task references, and reviewable changes.
|
||||
|
||||
## Solo mode
|
||||
Common task states are `todo`, `in_progress`, `done`, `review`, and `approved`. Internally the task file stores the work state, while review and approval placement can also use kanban overlay state.
|
||||
|
||||
Solo mode runs a one-member team. It is useful for quick work, lower token usage, and validating a prompt before expanding to a full team.
|
||||
## Kanban
|
||||
|
||||
## Cross-team communication
|
||||
Kanban is the board view for team work. It lets you scan tasks by state, open task details, inspect logs, review diffs, approve finished work, or request changes.
|
||||
|
||||
Agents can message within and across teams. Use this when separate teams own related work and need to coordinate.
|
||||
## Inbox
|
||||
|
||||
## Autonomy level
|
||||
An inbox is a local message file for a team participant. Agent Teams uses inboxes for user messages, lead messages, teammate messages, runtime delivery metadata, cross-team messages, and some system notifications.
|
||||
|
||||
Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths.
|
||||
Messages are durable local records. Delivery still depends on the selected runtime being alive and able to process its next turn.
|
||||
|
||||
## Agent Block
|
||||
|
||||
An agent block is hidden, agent-only instruction text wrapped with `<info_for_agent>...</info_for_agent>`. The UI strips these blocks from normal human-facing display, but agents and runtime delivery can use them for coordination details.
|
||||
|
||||
The current canonical marker is `info_for_agent`; older documents may still contain legacy agent block formats.
|
||||
|
||||
## Context Phase
|
||||
|
||||
A context phase is one segment of a session context timeline. Compaction starts a new phase, so token and context usage can be analyzed before and after the reset.
|
||||
|
||||
Context tracking separates categories such as project instructions, mentioned files, tool output, thinking text, team coordination, and user messages. These numbers are diagnostics, not provider billing statements.
|
||||
|
||||
## Runtime
|
||||
|
||||
A runtime is the local execution path that connects Agent Teams to a model/provider workflow, such as Claude, Codex, or OpenCode.
|
||||
A runtime is the local execution path that runs an agent turn. Supported runtime paths include Claude Code, Codex, and OpenCode.
|
||||
|
||||
The runtime owns model execution behavior, auth details, tool execution semantics, rate limits, model availability, and some transcript/log formats.
|
||||
|
||||
## Provider
|
||||
|
||||
A provider is the model access path behind a runtime. Current provider ids include Anthropic, Codex, Gemini, and OpenCode. OpenCode can route to many model providers through its own configuration.
|
||||
|
||||
Agent Teams orchestrates tasks and messages, but it does not replace provider authentication or provider policy.
|
||||
|
||||
## Solo mode
|
||||
|
||||
Solo mode runs a one-member team. It is useful for quick work, lower coordination overhead, and validating a prompt before expanding to a full team.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Agents can message within and across teams. Use this when separate teams own related work and need to coordinate without collapsing everything into one large team.
|
||||
|
||||
## Autonomy level
|
||||
|
||||
Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths, persistence, provider auth, Git operations, and releases.
|
||||
|
||||
## Review
|
||||
|
||||
Review is the task-scoped acceptance flow. A task can move to review, receive comments or requested changes, and then move to approved when the result is accepted.
|
||||
|
||||
Review is tied to local diffs and task history, so it works best when tasks stay narrow and agents mention the task they are working on.
|
||||
|
|
|
|||
|
|
@ -4,26 +4,62 @@
|
|||
|
||||
Yes. The app is free and open source. Provider or runtime access may still cost money depending on what you use.
|
||||
|
||||
## Do I need to install Claude or Codex first?
|
||||
## Does Agent Teams include model access?
|
||||
|
||||
No. Agent Teams is the local orchestration and UI layer. Model access comes from the selected runtime/provider path, such as Claude Code, Codex, or OpenCode.
|
||||
|
||||
## Which runtimes are supported?
|
||||
|
||||
The supported runtime paths are Claude Code, Codex, and OpenCode. The app also tracks provider ids such as Anthropic, Codex, Gemini, and OpenCode when the runtime exposes them.
|
||||
|
||||
## Do I need to install Claude Code or Codex first?
|
||||
|
||||
Not always. The app guides runtime detection and setup from the UI. Some paths still require external runtime auth.
|
||||
|
||||
OpenCode setup is separate from Claude Code and Codex setup. If a launch fails, check runtime status and provider auth before changing the team prompt.
|
||||
|
||||
## Does it upload my code to Agent Teams servers?
|
||||
|
||||
No. Agent Teams is not a cloud code-sync service. Provider-backed model calls may receive prompt context depending on your selected runtime.
|
||||
|
||||
## Where are team files stored?
|
||||
|
||||
Team coordination data is stored locally under `~/.claude/teams/<team>/`, task files under `~/.claude/tasks/<team>/`, and project session data under `~/.claude/projects/<encoded-project>/` when available.
|
||||
|
||||
## What can leave my machine?
|
||||
|
||||
Prompt context, selected file contents, tool results, command output, task text, comments, and attachments can leave your machine through the runtime/provider path when an agent uses a provider-backed model. The exact behavior depends on the runtime and provider.
|
||||
|
||||
## Can agents talk to each other?
|
||||
|
||||
Yes. Agents can message teammates, comment on tasks, and coordinate across teams.
|
||||
Yes. Agents can message teammates, comment on tasks, coordinate across teams, and use task references to keep conversations attached to work.
|
||||
|
||||
## Can I review code before accepting it?
|
||||
|
||||
Yes. The review flow is built around task-scoped diffs and hunk-level decisions.
|
||||
|
||||
## What is an Agent Block?
|
||||
|
||||
An Agent Block is hidden agent-only text wrapped in markers such as `<info_for_agent>...</info_for_agent>`. The app strips it from normal user-facing display but keeps it available for agent coordination.
|
||||
|
||||
## What is solo mode?
|
||||
|
||||
Solo mode is a one-agent team. It is useful for smaller tasks and lower coordination overhead.
|
||||
|
||||
## Can different teammates use different providers?
|
||||
|
||||
Yes, provider/model settings can be carried per team member when the selected runtime path supports them. OpenCode is the main path for broad multi-provider routing.
|
||||
|
||||
## Why does a task show review or approved separately from done?
|
||||
|
||||
The work state and review state are related but not identical. A task can be done from the agent's perspective, then move through review and approval in the kanban UI.
|
||||
|
||||
## What should I do when a launch hangs?
|
||||
|
||||
Open troubleshooting, collect runtime logs, and verify provider auth before changing prompts.
|
||||
Open troubleshooting, collect launch diagnostics, check `~/.claude/teams/<team>/`, and verify runtime/provider auth before changing prompts.
|
||||
|
||||
For OpenCode, check lane/session evidence before assuming a teammate is online but ignoring messages.
|
||||
|
||||
## Why are logs different across runtimes?
|
||||
|
||||
Claude Code, Codex, and OpenCode expose different transcript formats and runtime evidence. Agent Teams normalizes what it can, but log completeness and attribution can differ by runtime.
|
||||
|
|
|
|||
|
|
@ -1,30 +1,56 @@
|
|||
# Privacy and Local Data
|
||||
|
||||
Agent Teams is local-first, but the selected provider path still matters.
|
||||
Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models.
|
||||
|
||||
## What stays local
|
||||
|
||||
The desktop app runs on your machine and reads local project/runtime data to power the UI:
|
||||
The desktop app runs on your machine and reads local project/runtime data to power the UI. Typical local data includes:
|
||||
|
||||
- project files
|
||||
- task metadata
|
||||
- team configuration and member metadata
|
||||
- task metadata, task comments, and task references
|
||||
- inbox messages
|
||||
- runtime/session logs
|
||||
- launch state and bootstrap diagnostics
|
||||
- review state
|
||||
- local app settings
|
||||
|
||||
Important local locations include:
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state, and review-related team files. |
|
||||
| `~/.claude/tasks/<team>/` | Durable task JSON files for the team board. |
|
||||
| `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files used for session history, context analysis, and transcript-backed UI. |
|
||||
|
||||
Exact files can vary by runtime and app version. For launch debugging, the newest evidence is usually under the relevant `~/.claude/teams/<team>/` folder.
|
||||
|
||||
## What can leave your machine
|
||||
|
||||
When an agent asks a provider-backed model to work, prompt context and tool results may be sent through that provider/runtime path. This depends on the runtime and provider you choose.
|
||||
Agent Teams itself is not a cloud code-sync service for your repository. It does not need to upload your whole project to an Agent Teams server to show the board, inbox, logs, or review UI.
|
||||
|
||||
However, when an agent asks a provider-backed model to work, prompt context, selected file contents, task text, comments, tool results, command output, and other runtime-provided context may be sent through the selected runtime/provider path. What is sent depends on the runtime, model, tool calls, prompt, and provider configuration.
|
||||
|
||||
Provider authentication, provider-side retention, training, logging, regional processing, and billing are governed by the provider/runtime you choose. Review those policies for sensitive projects.
|
||||
|
||||
## What the app does not guarantee
|
||||
|
||||
- It cannot guarantee that provider-backed model calls never receive private code.
|
||||
- It cannot override provider retention or billing policies.
|
||||
- It cannot make a remote provider behave like a fully local model.
|
||||
- It cannot protect secrets that an agent is instructed to paste into prompts, task comments, files, or commands.
|
||||
- It cannot make every runtime expose the same transcript or audit detail.
|
||||
|
||||
## Practical guidance
|
||||
|
||||
- Do not attach secrets to tasks.
|
||||
- Do not attach secrets to tasks, comments, or direct messages.
|
||||
- Review provider policies for sensitive projects.
|
||||
- Use lower autonomy for risky repositories.
|
||||
- Keep task scope narrow when working with private code.
|
||||
- Prefer local evidence and logs when debugging.
|
||||
- Check generated prompts, task descriptions, and attached files before asking agents to work on confidential material.
|
||||
- Use provider/model paths that match your privacy requirements.
|
||||
|
||||
## Open source model
|
||||
|
||||
The app itself is open source and free. You can inspect how local orchestration, task tracking, and review flows work in the repository.
|
||||
|
||||
The app itself is open source and free. You can inspect how local orchestration, task tracking, inboxes, runtime diagnostics, and review flows work in the repository.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Providers and Runtimes
|
||||
|
||||
Agent Teams separates orchestration from model access.
|
||||
Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work.
|
||||
|
||||
## What the app provides
|
||||
|
||||
|
|
@ -12,6 +12,8 @@ Agent Teams provides:
|
|||
- task logs
|
||||
- review UI
|
||||
- local project integration
|
||||
- runtime detection and capability checks
|
||||
- local logs and diagnostics
|
||||
|
||||
## What the runtime provides
|
||||
|
||||
|
|
@ -21,20 +23,52 @@ The runtime provides:
|
|||
- provider authentication
|
||||
- tool execution behavior
|
||||
- model-specific rate limits and capabilities
|
||||
- runtime-specific transcripts and delivery evidence
|
||||
|
||||
## Common choices
|
||||
## Supported runtime paths
|
||||
|
||||
| Runtime | Notes |
|
||||
| Runtime path | Provider/model path | Best fit | Notes |
|
||||
| --- | --- |
|
||||
| Claude | Good for Claude Code users and Anthropic access |
|
||||
| Codex | Good for Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Good for multimodel routing and broad provider coverage |
|
||||
| Claude Code | Anthropic / Claude models | Claude Code users and Anthropic-backed workflows | Default local-first path for Claude teams. Requires the runtime and account access to be available locally. |
|
||||
| Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. |
|
||||
| OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. |
|
||||
|
||||
## Provider ids
|
||||
|
||||
The app currently recognizes these provider ids in team/runtime configuration:
|
||||
|
||||
| Provider id | Display intent |
|
||||
| --- | --- |
|
||||
| `anthropic` | Anthropic / Claude Code path |
|
||||
| `codex` | Codex path |
|
||||
| `gemini` | Gemini provider path when exposed by the runtime |
|
||||
| `opencode` | OpenCode path, including OpenCode-managed provider routing |
|
||||
|
||||
Do not read this table as a guarantee that every provider is authenticated, installed, or available for every model on every machine. The runtime status and capability checks are the source of truth for a given launch.
|
||||
|
||||
## Multi-provider strategy
|
||||
|
||||
Agent Teams keeps orchestration provider-aware but not provider-owned:
|
||||
|
||||
- teams, tasks, inboxes, comments, review state, and launch diagnostics stay in local Agent Teams storage
|
||||
- each member can carry provider/model settings through team launch metadata
|
||||
- model availability, auth, rate limits, and tool behavior remain runtime/provider responsibilities
|
||||
- OpenCode is the broadest routing path when you want one team to use multiple provider/model lanes
|
||||
|
||||
## Provider costs
|
||||
|
||||
Agent Teams is free. Provider usage is governed by the runtime/provider you select.
|
||||
Agent Teams is free and open source. Provider usage is governed by the runtime/provider you select: subscription limits, API keys, account auth, rate limits, and provider policies all remain external to the app.
|
||||
|
||||
## Capability checks
|
||||
|
||||
During setup, the app may perform access and capability checks. This helps detect missing runtime auth before a team launch fails halfway through provisioning.
|
||||
|
||||
Capability checks can report that a provider exists but is not authenticated, that a model list is unavailable, that a runtime path is missing, or that a specific extension capability is unsupported. Treat those results as setup diagnostics, not task failures.
|
||||
|
||||
## Limits to expect
|
||||
|
||||
- Runtime support does not mean equal feature parity across Claude Code, Codex, and OpenCode.
|
||||
- Log and transcript coverage differs by runtime.
|
||||
- OpenCode lanes need stable lane/session evidence before the app can attribute runtime logs safely.
|
||||
- Provider model names and availability can change outside the app.
|
||||
- A team prompt cannot fix missing auth, missing PATH entries, provider outages, or exhausted rate limits.
|
||||
|
|
|
|||
|
|
@ -2,34 +2,84 @@
|
|||
|
||||
Agent Teams делает работу агентов видимой через task state, messages, logs и reviewable code changes.
|
||||
|
||||
## Lifecycle
|
||||
## Режимы
|
||||
|
||||
| Этап | Что происходит |
|
||||
| --- | --- |
|
||||
| Provisioning | Приложение запускает команду и проверяет готовность runtime |
|
||||
| Planning | Lead создаёт задачи и назначает teammates |
|
||||
| In progress | Агенты работают параллельно и обновляют статус задач |
|
||||
| Review | Изменения проверяют агенты или вы |
|
||||
| Done | Принятая работа остаётся связанной с историей задачи |
|
||||
| Режим | Описание |
|
||||
|-------|----------|
|
||||
| Solo | Один teammate с самостоятельным управлением задачами |
|
||||
| Team | Несколько teammates, работающих параллельно и ревьюящих друг друга |
|
||||
|
||||
Оба режима используют одну и ту же канбан-доску, логи задач и поверхность код-ревью.
|
||||
|
||||
## Жизненный цикл задачи
|
||||
|
||||
| Этап | Что происходит | Ответственный |
|
||||
|------|---------------|---------------|
|
||||
| Provisioning | Приложение запускает runtime, проверяет, что процесс жив, и ждёт подтверждения bootstrap | Приложение |
|
||||
| Planning | Lead создаёт задачи, назначает teammates и задаёт зависимости | Lead или пользователь |
|
||||
| In progress | Агенты работают параллельно и обновляют статус задач через board MCP tools | Teammates |
|
||||
| Review | Изменения проверяют агенты или вы перед финальным принятием | Team lead или пользователь |
|
||||
| Done | Принятая работа остаётся связанной с историей задачи и доступна для инспекции | Пользователь |
|
||||
|
||||
### Planning → In progress
|
||||
|
||||
Когда teammate берёт задачу, статус на доске меняется на `in_progress`. Агент создаёт task comment с планом работы и продолжает. Все нативные инструменты (read, bash, edit, write) попадают в task log.
|
||||
|
||||
### In progress → Review
|
||||
|
||||
Когда teammate завершает работу, он публикует result comment и помечает задачу `completed`. Lead затем решает — принять сразу или отправить на ревью.
|
||||
|
||||
### Review → Done
|
||||
|
||||
Если изменения в review surface выглядят приемлемо, approve the review. Задача финализируется и связывается со своим diff.
|
||||
|
||||
::: warning Ревью с правками
|
||||
Если teammate попросили внести правки во время ревью, он должен добавить follow-up comment с исправлениями, после чего lead может approve.
|
||||
:::
|
||||
|
||||
## Канбан-доска
|
||||
|
||||
Доска - основной рабочий экран. Через неё удобно смотреть работу, находить blocked tasks, открывать task detail, читать logs и ревьюить changes без ручного чтения session files.
|
||||
Доска — основной рабочий экран. Через неё удобно:
|
||||
|
||||
## Messages и comments
|
||||
- Смотреть открытые, заблокированные и на ревью задачи
|
||||
- Открывать task detail и инспектировать runtime logs
|
||||
- Ревьюить изменения без чтения raw session files
|
||||
- Назначать или переназначать владельцев
|
||||
|
||||
Direct messages подходят для перенаправления агента. Task comments лучше использовать, когда заметка относится к конкретной работе. Комментарии сохраняют контекст для review.
|
||||
::: tip
|
||||
Используйте quick action buttons на карточках для старта, завершения или запроса ревью, не открывая detail panel.
|
||||
:::
|
||||
|
||||
## Task logs
|
||||
## Сообщения и комментарии
|
||||
|
||||
| Канал | Когда использовать |
|
||||
|-------|-------------------|
|
||||
| Direct message | Перенаправить агента, задать быстрый вопрос |
|
||||
| Task comment | Заметки, относящиеся к конкретной задаче |
|
||||
|
||||
Комментарии сохраняют контекст для последующего ревью и появляются в timeline задачи.
|
||||
|
||||
::: tip Предпочитайте task comments
|
||||
Если заметка касается конкретной задачи, добавьте её как комментарий к задаче, а не как direct message. Это сохраняет историю, привязанную к работе.
|
||||
:::
|
||||
|
||||
## Логи задач
|
||||
|
||||
Task-specific logs изолируют runtime output, actions и messages по одному assignment. Они помогают понять:
|
||||
|
||||
- что агент запускал?
|
||||
- почему он изменил этот файл?
|
||||
- просил ли он помощи у teammate?
|
||||
- какая задача породила diff?
|
||||
- Что агент запускал?
|
||||
- Почему он изменил этот файл?
|
||||
- Просил ли он помощи у teammate?
|
||||
- Какая задача породила diff?
|
||||
|
||||
## Live processes
|
||||
## Параллельные паттерны работы
|
||||
|
||||
Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения.
|
||||
Teammates могут работать над независимыми задачами одновременно. Вы также можете создавать dependency links (`blocked-by`), чтобы одна задача ждала завершения другой. Следите за blocked lanes на доске и переназначайте владельцев, если один teammate простаивает, а другой перегружен.
|
||||
|
||||
## Процессы в реальном времени
|
||||
|
||||
Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения. Процессы остаются зарегистрированными, пока не будут явно остановлены или runtime не завершится.
|
||||
|
||||
## Межкомандное взаимодействие
|
||||
|
||||
Агенты могут отправлять сообщения другим командам, когда команды связаны. Используйте это для handoffs, shared libraries или проверки статуса между squad.
|
||||
|
|
|
|||
|
|
@ -1,35 +1,63 @@
|
|||
# Код-ревью
|
||||
|
||||
Code review в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
|
||||
Код-ревью в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
|
||||
|
||||
## Review surface
|
||||
## Поверхность ревью
|
||||
|
||||
Через review UI можно:
|
||||
Для каждой завершённой задачи, затронувшей файлы, review UI позволяет:
|
||||
|
||||
- смотреть changed files
|
||||
- принимать или отклонять отдельные hunks
|
||||
- оставлять comments
|
||||
- связывать diff с task logs и агентом
|
||||
- Смотреть changed files с контекстом до/после
|
||||
- Принимать или отклонять отдельные hunks
|
||||
- Оставлять inline comments
|
||||
- Связывать diff с описанием задачи и логами агента
|
||||
|
||||
## Hunk-level decisions
|
||||
## Решения на уровне hunk
|
||||
|
||||
Принимайте маленькие правильные изменения и отклоняйте отдельные ошибки без удаления всей работы. Это полезно, когда агент в целом решил задачу, но переборщил в одном файле.
|
||||
|
||||
## Agent review workflow
|
||||
::: tip Принимайте по частям
|
||||
Если diff в основном верен, сначала примите хорошие hunks и запросите правки только для проблемных частей. Это не даёт доске застопориться.
|
||||
:::
|
||||
|
||||
## Инициирование ревью
|
||||
|
||||
1. Откройте завершённую задачу
|
||||
2. Перейдите на вкладку **Changes**
|
||||
3. Если diff выглядит разумно, нажмите **Request Review**, чтобы переместить задачу в колонку review
|
||||
|
||||
Во время ревью задача ещё не считается завершённой, поэтому другие teammates или lead могут всё ещё комментировать её.
|
||||
|
||||
## Состояния ревью
|
||||
|
||||
| Состояние | Значение |
|
||||
|-----------|---------|
|
||||
| `none` | Задача новая, в работе или завершена, но ещё не на ревью |
|
||||
| `review` | Задача активно на ревью |
|
||||
| `needsFix` | Запрошены правки; владелец должен обновить до повторного approve |
|
||||
| `approved` | Ревью принято, задача финализирована |
|
||||
|
||||
## Рабочий процесс ревью агентами
|
||||
|
||||
Команды могут ревьюить работу друг друга до вашего финального решения. Это ловит очевидные регрессии, но risky areas всё равно стоит проверять вручную.
|
||||
|
||||
## Участники ревью
|
||||
|
||||
Team lead — ревьюер по умолчанию. Вы можете настроить дополнительных ревьюеров в настройках Kanban, если хотите, чтобы peers ревьюили работу друг друга.
|
||||
|
||||
## Что проверять вручную
|
||||
|
||||
Приоритет:
|
||||
Приоритетные области при ревью:
|
||||
|
||||
- provider auth и runtime detection
|
||||
- IPC, preload и filesystem boundaries
|
||||
- Git и worktree behavior
|
||||
- parsing и task lifecycle logic
|
||||
- persistence и code review flows
|
||||
- **Provider auth и runtime detection** — не сломает ли агент настройку runtime для других путей?
|
||||
- **IPC, preload и filesystem boundaries** — сохраняйте разделение ответственности Electron
|
||||
- **Git и worktree behavior** — проверяйте имена веток, коммиты и push
|
||||
- **Parsing и task lifecycle logic** — изменения в task references, chunking или filtering могут сломать доставку сообщений
|
||||
- **Persistence и code review flows** — изменения в хранении задач или review state должны оставаться консистентными через IPC layers
|
||||
|
||||
## Verification
|
||||
## Верификация
|
||||
|
||||
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.
|
||||
|
||||
::: warning Не запускайте автоформатирование по всему проекту
|
||||
Если задача не специфически про форматирование, избегайте `pnpm lint:fix` на несвязанных файлах. Это создаёт шум в review surface.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,26 +1,40 @@
|
|||
# Создание команды
|
||||
|
||||
Команда - это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
|
||||
Команда — это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
|
||||
|
||||
## Первая команда
|
||||
|
||||
Начните с малого:
|
||||
|
||||
| Роль | Задача |
|
||||
| --- | --- |
|
||||
| Lead | Делит работу, создаёт задачи, координирует teammates |
|
||||
| Builder | Реализует scoped tasks |
|
||||
| Reviewer | Проверяет результат, ловит регрессии, просит fixes |
|
||||
| Роль | Задача |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| Lead | Делит работу, создаёт задачи, координирует teammates |
|
||||
| Builder | Реализует scoped tasks |
|
||||
| Reviewer | Проверяет результат, ловит регрессии, просит fixes |
|
||||
|
||||
Такая форма даёт достаточно координации, но не создаёт лишний шум на первом запуске.
|
||||
|
||||
::: tip
|
||||
Команду можно расширить позже. Начните с малого, проверьте workflow, затем масштабируйте.
|
||||
:::
|
||||
|
||||
## Назначение провайдеров и моделей
|
||||
|
||||
Каждый участник команды работает через провайдер-бэкенд. В редакторе команды выберите провайдер (Claude, Codex или OpenCode) и модель для каждого участника. Приложение показывает только провайдеров, которые вы уже авторизовали.
|
||||
|
||||
Микс провайдеров в одной команде поддерживается — например, Claude lead с OpenCode builder-ами.
|
||||
|
||||
::: info
|
||||
Поддержка Gemini в разработке и появится в списке провайдеров, когда будет готова.
|
||||
:::
|
||||
|
||||
## Хороший team brief
|
||||
|
||||
В brief стоит указать:
|
||||
|
||||
- нужный outcome
|
||||
- важные files или feature areas
|
||||
- границы риска, например "не refactor unrelated modules"
|
||||
- границы риска, например «не refactor unrelated modules»
|
||||
- ожидания по review
|
||||
- verification commands, если они известны
|
||||
|
||||
|
|
@ -30,9 +44,39 @@
|
|||
Улучши download flow. Держи изменения внутри landing app, если shared helper явно не нужен. Создай задачи до реализации, проверь diff каждой задачи и запусти landing lint/build checks.
|
||||
```
|
||||
|
||||
## Изоляция через worktree
|
||||
|
||||
Участники на OpenCode могут использовать **изоляцию через worktree** — работать в отдельном Git worktree вместо основного рабочего каталога. Это предотвращает конфликты файлов, когда несколько агентов редактируют один проект.
|
||||
|
||||
::: warning
|
||||
Изоляция через worktree требует Git-репозиторий и пока доступна только для участников на OpenCode.
|
||||
:::
|
||||
|
||||
Чтобы включить, переключите опцию **Worktree isolation** при добавлении или редактировании участника на OpenCode.
|
||||
|
||||
## Уровень автономности
|
||||
|
||||
Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше - для provider auth, IPC, persistence, Git workflows и release tooling.
|
||||
Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше — для рискованных областей: provider auth, IPC, персистентность, Git-операции и release tooling.
|
||||
|
||||
### Уровень усилия (effort)
|
||||
|
||||
У каждого участника есть настройка **effort** — она определяет, сколько reasoning провайдер вкладывает перед ответом. Выше effort — тщательнее результат, но больше времени и токенов.
|
||||
|
||||
| Уровень | Когда использовать |
|
||||
| ------- | ------------------------------------------------------------------------- |
|
||||
| Low | Быстрые запросы, мелкие правки форматирования, рутинные изменения |
|
||||
| Medium | По умолчанию для большинства задач по реализации |
|
||||
| High | Сложные рефакторинги, кросс-модульные изменения, рискованные участки кода |
|
||||
|
||||
Приложение предлагает дополнительные уровни (minimal, xhigh, max) для провайдеров, которые их поддерживают. Если модель не поддерживает настройку effort, селектор отключён и используется значение по умолчанию провайдера.
|
||||
|
||||
### Быстрый режим (Fast mode)
|
||||
|
||||
Переключите **Fast mode** для отдельного участника, чтобы приоритизировать скорость над глубиной. Это использует нативный быстрый режим провайдера, когда он доступен. Установите **On** для рутинных задач, **Off** для аккуратной работы или **Inherit**, чтобы следовать командному значению по умолчанию.
|
||||
|
||||
### Ограничение контекста (Limit context)
|
||||
|
||||
Включите **Limit context**, чтобы уменьшить контекстное окно для участника. Это полезно для моделей Claude с расширенным контекстом (например, 1M токенов) — ограничение контекста избегает лишних токенов и улучшает задержку для задач, не требующих большого контекста.
|
||||
|
||||
## Контекст
|
||||
|
||||
|
|
@ -49,3 +93,8 @@ Agent Teams поддерживает разные уровни контроля.
|
|||
|
||||
Если lead создаёт размытые задачи, напишите ему direct message и попросите сделать задачи меньше и проверяемее.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
|
||||
- [Код-ревью](/ru/guide/code-review) — принять, отклонить или прокомментировать изменения агентов
|
||||
- [Диагностика](/ru/guide/troubleshooting) — частые проблемы и решения
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Agent Teams распространяется как desktop-приложение
|
|||
|
||||
## Готовые сборки
|
||||
|
||||
Берите последний GitHub release:
|
||||
Скачайте приложение на <a href="/ru/download/" target="_self">странице загрузок</a> или из последнего [GitHub release](https://github.com/777genius/agent-teams-ai/releases):
|
||||
|
||||
- macOS Apple Silicon: `.dmg`
|
||||
- macOS Intel: `.dmg`
|
||||
|
|
@ -17,29 +17,60 @@ Agent Teams распространяется как desktop-приложение
|
|||
|
||||
## Требования
|
||||
|
||||
Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication.
|
||||
Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication — ручная настройка CLI не нужна.
|
||||
|
||||
Для запуска из исходников:
|
||||
Для работы агентных рантаймов нужен доступ хотя бы к одному провайдеру:
|
||||
|
||||
| Провайдер | Способ доступа |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| Claude (Anthropic) | Claude Code CLI login или API key |
|
||||
| Codex (OpenAI) | Codex CLI login или API key |
|
||||
| Gemini (Google) | _В разработке_ |
|
||||
| OpenCode | API key для поддерживаемого бэкенда (например, OpenRouter) |
|
||||
|
||||
::: info
|
||||
Поддержка провайдера Gemini в разработке. Вы можете подготовить доступ сейчас, но он не появится в редакторе команды, пока не будет готов.
|
||||
:::
|
||||
|
||||
Для запуска из исходников также нужны:
|
||||
|
||||
| Инструмент | Версия |
|
||||
| --- | --- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
| ---------- | ------ |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
|
||||
## Запуск из исходников
|
||||
|
||||
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" />
|
||||
<InstallBlock command="git clone https://github.com/777genius/agent-teams-ai.git && cd agent-teams-ai && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" />
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
||||
cd claude_agent_teams_ui
|
||||
git clone https://github.com/777genius/agent-teams-ai.git
|
||||
cd agent-teams-ai
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Если нужна самая свежая локальная версия, используйте ветку репозитория, где сейчас идёт активная разработка.
|
||||
Ветка `main` содержит актуальную стабильную разработку. Переключайтесь на feature-ветки, только если нужна конкретная неопубликованная правка.
|
||||
|
||||
## Обновления
|
||||
## Автообновления
|
||||
|
||||
Для packaged builds берите последний release. Для запуска из исходников подтяните нужную ветку и повторите install, если поменялись зависимости.
|
||||
Пакетная сборка автоматически проверяет обновления при запуске и периодически во время работы. Когда обновление доступно, приложение предложит скачать и установить его. Проверить вручную можно через меню приложения.
|
||||
|
||||
::: tip
|
||||
При запуске из исходников автообновления недоступны. Подтягивайте свежие изменения и запускайте `pnpm install`, если зависимости изменились.
|
||||
:::
|
||||
|
||||
## Обновление из исходников
|
||||
|
||||
Подтяните ветку `main` и повторите install, если поменялись зависимости:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Быстрый старт](/ru/guide/quickstart) — от установки до первой запущенной команды
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
|
||||
- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief
|
||||
|
|
|
|||
|
|
@ -1,50 +1,63 @@
|
|||
# Быстрый старт
|
||||
|
||||
Этот гайд проводит от свежей установки до первой запущенной команды.
|
||||
Этот гайд проводит от свежей установки до первой запущенной команды за несколько минут.
|
||||
|
||||
## 1. Установите Agent Teams
|
||||
|
||||
Скачайте последний релиз под вашу платформу на лендинге или в GitHub releases.
|
||||
Скачайте последний релиз под вашу платформу на <a href="/ru/download/" target="_self">странице загрузок</a> или в [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
|
||||
|
||||
::: tip
|
||||
Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру, например Claude, Codex, OpenCode или API-key based providers.
|
||||
Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру — подробности в разделе [Установка](/ru/guide/installation).
|
||||
:::
|
||||
|
||||
## 2. Откройте проект
|
||||
|
||||
Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные project files и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
|
||||
Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные файлы проекта и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
|
||||
|
||||
## 3. Выберите runtime path
|
||||
::: tip
|
||||
Выберите проект под Git — так вы получите лучший опыт. Изоляция через worktree и ревью по diff зависят от Git.
|
||||
:::
|
||||
|
||||
Стандартные варианты:
|
||||
## 3. Выберите runtime
|
||||
|
||||
| Runtime | Когда подходит |
|
||||
| --- | --- |
|
||||
| Claude | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multimodel teams и большого числа provider backends |
|
||||
Мастер настройки автоматически определит установленные рантаймы на вашей машине. Стандартные варианты:
|
||||
|
||||
| Runtime | Когда подходит |
|
||||
| -------- | ------------------------------------------------------------------- |
|
||||
| Claude | Если вы уже используете Claude Code или у вас есть Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multi-model команд и большого числа provider backends |
|
||||
|
||||
::: info
|
||||
Поддержка Gemini в разработке и появится в списке рантаймов, когда будет готова.
|
||||
:::
|
||||
|
||||
Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup).
|
||||
|
||||
## 4. Создайте первую команду
|
||||
|
||||
Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума.
|
||||
|
||||
Рекомендованная структура и советы — в разделе [Создание команды](/ru/guide/create-team).
|
||||
|
||||
## 5. Дайте lead-агенту конкретную цель
|
||||
|
||||
Пишите задачу как инженерному лиду:
|
||||
|
||||
```text
|
||||
Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими refactor.
|
||||
Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими рефакторингами.
|
||||
```
|
||||
|
||||
Lead должен создать задачи, назначить работу и координировать teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages.
|
||||
Lead создаёт задачи, назначает работу и координирует teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages в любой момент.
|
||||
|
||||
## 6. Проверьте результат
|
||||
|
||||
Откройте задачи в review/done, посмотрите diff, примите или отклоните изменения. Если нужно понять мотивацию агента, откройте task logs.
|
||||
|
||||
Полный процесс ревью — в разделе [Код-ревью](/ru/guide/code-review).
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Создание команды](/ru/guide/create-team)
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup)
|
||||
- [Код-ревью](/ru/guide/code-review)
|
||||
|
||||
- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
|
||||
- [Код-ревью](/ru/guide/code-review) — ревью, одобрение и запрос правок
|
||||
|
|
|
|||
|
|
@ -1,33 +1,98 @@
|
|||
# Настройка рантайма
|
||||
|
||||
Agent Teams - coordination layer. Model work выполняется через локальные runtimes и providers.
|
||||
Agent Teams — coordination layer. Model work выполняется через локальные runtimes и providers.
|
||||
|
||||
## Предварительные требования
|
||||
|
||||
Перед запуском команды убедитесь, что:
|
||||
|
||||
- Runtime binary установлен и находится в `PATH`.
|
||||
- Ваш аккаунт провайдера имеет доступ к выбранной модели.
|
||||
- Путь к проекту существует и доступен для чтения.
|
||||
|
||||
::: tip
|
||||
Начните с одного teammate и одного провайдера. Подтвердите запуск одной команды, прежде чем добавлять multimodel lanes.
|
||||
:::
|
||||
|
||||
## Поддерживаемые пути
|
||||
|
||||
| Путь | Когда использовать |
|
||||
| --- | --- |
|
||||
| Claude | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multimodel routing и широкой provider coverage |
|
||||
| Путь | CLI по умолчанию | Типичные провайдеры | Когда использовать |
|
||||
|------|-------------------|---------------------|-------------------|
|
||||
| Claude | `claude` | Anthropic | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | `codex` | OpenAI | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | `opencode` | OpenRouter и многие другие | Для multimodel routing и широкой provider coverage |
|
||||
|
||||
Приложение по возможности определяет доступные runtimes и ведёт настройку через UI.
|
||||
|
||||
## Provider access
|
||||
## Доступ к провайдеру
|
||||
|
||||
У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: subscription, local runtime auth или API keys в зависимости от выбранного пути.
|
||||
У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: подписка, локальная авторизация рантайма или API-ключи в зависимости от выбранного пути.
|
||||
|
||||
## Multimodel mode
|
||||
- Для **Claude** и **Codex** используется auth соответствующего CLI.
|
||||
- Для **OpenCode** требуются provider-specific API keys в файле конфигурации (например, `openrouter`, `openai`, `anthropic`).
|
||||
|
||||
Multimodel mode может направлять работу через разные provider backends в OpenCode-compatible конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
|
||||
## Настройка авторизации
|
||||
|
||||
## Практические советы
|
||||
### Claude Code
|
||||
|
||||
- Первый runtime setup держите простым.
|
||||
- Подтвердите запуск одной команды до добавления многих providers.
|
||||
- Auth, model names и PATH issues считайте setup-проблемами, а не проблемами team prompt.
|
||||
- Если запуск завис, сначала откройте диагностику.
|
||||
Запустите стандартный auth flow в терминале:
|
||||
|
||||
```bash
|
||||
claude login
|
||||
```
|
||||
|
||||
Затем проверьте, что CLI доступен:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
```
|
||||
|
||||
### Codex
|
||||
|
||||
Установите и авторизуйтесь через CLI OpenAI:
|
||||
|
||||
```bash
|
||||
codex login
|
||||
```
|
||||
|
||||
### OpenCode
|
||||
|
||||
Создайте или отредактируйте `~/.opencode/config.json` (или эквивалентный путь на вашей платформе):
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Используйте точное имя провайдера, которое ожидает OpenCode. Если вы используете кастомное имя, убедитесь, что оно совпадает с provider ID в строке модели (например, `openrouter/moonshotai/kimi-k2.6` использует блок `openrouter`).
|
||||
|
||||
## Multimodel-режим
|
||||
|
||||
Multimodel-режим может направлять работу через разные provider backends в OpenCode-совместимой конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
|
||||
|
||||
::: info Model lanes
|
||||
Каждый teammate может использовать свою пару `providerId` + `model`. В UI редактирования команды разверните опции member, чтобы переопределить глобальные значения.
|
||||
:::
|
||||
|
||||
## Чеклист перед запуском
|
||||
|
||||
Перед запуском команды:
|
||||
|
||||
1. Выбранный runtime установлен
|
||||
2. Binary runtime находится в environment `PATH`
|
||||
3. Auth провайдера настроен для выбранного backend
|
||||
4. Провайдер имеет доступ к точной строке модели
|
||||
5. Путь к проекту существует и доступен для чтения
|
||||
|
||||
## Когда менять runtime path
|
||||
|
||||
Меняйте путь, когда текущий упирается в availability модели, rate limits, provider capabilities или роли команды. После смены проверьте одну маленькую задачу.
|
||||
|
||||
::: warning Считайте ошибки setup setup-проблемами
|
||||
Если auth падает, имя модели отклонено или binary runtime не найден — сначала исправьте настройку. Не меняйте team prompts или код проекта, чтобы обойти проблему конфигурации рантайма.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,40 +1,158 @@
|
|||
# Диагностика
|
||||
|
||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits.
|
||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing и provider limits.
|
||||
|
||||
## Команда не запускается
|
||||
|
||||
Проверьте:
|
||||
Проверьте последовательно:
|
||||
|
||||
- выбранный runtime установлен или авторизован
|
||||
- runtime доступен в environment PATH
|
||||
- у провайдера есть доступ к нужной модели
|
||||
- project path существует и читается
|
||||
1. **Runtime установлен** — выбранный CLI (`claude`, `codex`, `opencode`) установлен
|
||||
2. **Доступен в PATH** — бинарник доступен в переменной окружения `PATH`
|
||||
3. **Доступ к модели** — у провайдера есть доступ к запрошенной модели (особенно для OpenCode, важны точные имена провайдера и модели)
|
||||
4. **Путь к проекту** — директория проекта существует и доступна для чтения
|
||||
5. **Сеть / VPN** — некоторые провайдеры блокируют трафик при активном VPN
|
||||
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала смотрите launch logs.
|
||||
::: tip
|
||||
Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`.
|
||||
:::
|
||||
|
||||
### OpenCode: registered, но bootstrap не подтверждён
|
||||
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала inspect artifacts, прежде чем менять team prompts.
|
||||
|
||||
Посмотрите на последний artifact неудачного запуска:
|
||||
|
||||
```bash
|
||||
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
|
||||
```
|
||||
|
||||
Манифест внутри включает:
|
||||
|
||||
- `classification` — почему запуск считался неудачным
|
||||
- `bootstrapTransportBreadcrumb` — использованный путь доставки
|
||||
- Статусы старта участников
|
||||
- Редактированные логи и трейсы
|
||||
|
||||
Также проверьте lane manifest:
|
||||
|
||||
```bash
|
||||
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json
|
||||
jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane>/manifest.json
|
||||
```
|
||||
|
||||
::: tip Не гадайте по UI
|
||||
Всегда сопоставляйте UI-диагностику с сохранёнными файлами (`launch-state.json`, `bootstrap-journal.jsonl`) и runtime-специфичными доказательствами.
|
||||
:::
|
||||
|
||||
## Не видны ответы агента
|
||||
|
||||
Откройте task logs и teammate messages. Пропавшие replies часто связаны с runtime delivery, parsing или task filtering. Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
|
||||
Откройте task logs и teammate messages. Пропавшие replies часто связаны с:
|
||||
|
||||
- **Runtime delivery retry** — агент мог ответить, но сообщение не доставлено в приложение. Проверьте delivery ledger.
|
||||
- **Parsing или filtering** — вывод агента не содержал ожидаемых маркеров или task references.
|
||||
- **Task attribution** — работа выполнялась в рамках сессии, но не была привязана к задаче, потому что в выводе отсутствовал корректный task id.
|
||||
|
||||
::: warning Не считайте молчание игнорированием
|
||||
Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
|
||||
:::
|
||||
|
||||
## Changes не связаны с tasks
|
||||
|
||||
Используйте task-specific logs и code review links. Если diff выглядит detached, проверьте, был ли task id или task reference в output агента.
|
||||
Используйте task-specific logs и code review links. Если diff выглядит detached:
|
||||
|
||||
- Проверьте, был ли task id или task reference в output агента.
|
||||
- Убедитесь, что агент вызвал `task_add_comment` перед правками.
|
||||
- Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы.
|
||||
|
||||
Для OpenCode teammates авторитетным доказательством принадлежности сессии к задаче служат `opencode-sessions.json` и запись в lane manifest, а не только UI message stream.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
||||
Если провайдер сообщает известное время сброса (reset time), Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
||||
|
||||
| Поведение провайдера | Рекомендуемое действие |
|
||||
| --- | --- |
|
||||
| Отображается известное reset time | Дождитесь cooldown и продолжите |
|
||||
| Reset time не показан | Смените провайдера или runtime path |
|
||||
| Повторяющиеся 429 | Снизьте concurrency или используйте другую model lane |
|
||||
|
||||
## Проблемы авторизации CLI
|
||||
|
||||
### `claude login` не сохраняется
|
||||
|
||||
Если CLI авторизован в одном терминале, но приложение говорит, что нет — проверьте, что auth сохранён по ожидаемому пути конфигурации, и что процесс приложения видит тот же `$HOME`.
|
||||
|
||||
### OpenCode: ключ провайдера отклонён
|
||||
|
||||
- Убедитесь, что имя провайдера в `config.json` совпадает с префиксом провайдера в строке модели
|
||||
- Проверьте, что ключ не просрочен и не отозван в dashboard провайдера
|
||||
|
||||
### Диагностический лог авторизации
|
||||
|
||||
Каждый вызов `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs/<product-name>/` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
|
||||
|
||||
Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
|
||||
|
||||
## Lane bootstrap stuck
|
||||
|
||||
Для OpenCode secondary lanes:
|
||||
|
||||
- Отсутствие `inboxes/<member>.json` автоматически не является багом. OpenCode lanes не обязаны быть созданы через primary inbox перед стартом.
|
||||
- Если UI показывает, что команда всё ещё запускается, в то время как primary participants уже работоспособны, ожидание «all teammates joined» связано с secondary lanes.
|
||||
- Если зависает `Prepared communication channels for X/Y members`, проверьте, не включает ли `Y` некорректно secondary OpenCode members.
|
||||
|
||||
### Lane manifest empty entries
|
||||
|
||||
Если bridge сообщает, что bootstrap успешен, но `manifest.json` показывает `entries: []`, проблема в **evidence commit**, а не в поведении модели. Участник не должен считаться deliverable, пока `opencode-sessions.json` и запись в manifest не существуют.
|
||||
|
||||
## Распространённые состояния member
|
||||
|
||||
| Состояние | Значение |
|
||||
|-----------|---------|
|
||||
| `confirmed_alive` + `bootstrapConfirmed` | Здоров и готов к работе |
|
||||
| `registered` / `runtime_pending_bootstrap` | Процесс или lane существует, но bootstrap proof ещё не закоммичен |
|
||||
| `failed_to_start` + `runtime_process` | Процесс есть, но launch gate не прошёл. Смотрите diagnostics |
|
||||
| `failed_to_start` + `stale_metadata` | Сохранённый pid/session устарел или мёртв |
|
||||
|
||||
::: warning
|
||||
`member_briefing` сам по себе НЕ является runtime evidence. Для OpenCode авторитетным доказательством служит committed runtime evidence, такая как `opencode-sessions.json` и запись в manifest.
|
||||
:::
|
||||
|
||||
## Режим отладки рантайма
|
||||
|
||||
Для локальной отладки можно принудительно запускать teammates в tmux-панелях:
|
||||
|
||||
```bash
|
||||
# Запуск из терминала
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
|
||||
# Или добавьте в custom CLI args
|
||||
--teammate-mode tmux
|
||||
```
|
||||
|
||||
Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend.
|
||||
|
||||
## Безопасная очистка
|
||||
|
||||
При очистке stale processes:
|
||||
|
||||
1. Определите pid и убедитесь, что он принадлежит текущей команде / lane.
|
||||
2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch.
|
||||
3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut.
|
||||
|
||||
## Какие данные собрать
|
||||
|
||||
Соберите:
|
||||
|
||||
- task id
|
||||
- task id (short или full)
|
||||
- team name
|
||||
- runtime path
|
||||
- launch log excerpt
|
||||
- provider/model
|
||||
- runtime path (`claude`, `codex`, или `opencode`)
|
||||
- launch log excerpt (из `latest.json` или `bootstrap-journal.jsonl`)
|
||||
- provider / model
|
||||
- точный time window
|
||||
|
||||
Этого обычно хватает для диагностики launch и task lifecycle issues.
|
||||
|
||||
::: tip
|
||||
Если проблема не устраняется, откройте persisted files команды под `~/.claude/teams/<teamName>/` и сопоставьте UI diagnostics с live process state, прежде чем менять код.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ features:
|
|||
link: /ru/guide/code-review
|
||||
linkText: Ревью изменений
|
||||
- icon: "04"
|
||||
title: Runtime-aware setup
|
||||
title: Настройка рантайма
|
||||
details: Используйте Claude, Codex, OpenCode или multimodel-провайдеры через доступ, который у вас уже есть.
|
||||
link: /ru/guide/runtime-setup
|
||||
linkText: Настроить рантаймы
|
||||
|
|
|
|||
|
|
@ -1,32 +1,75 @@
|
|||
# Концепции
|
||||
|
||||
Основные термины Agent Teams.
|
||||
Основные термины Agent Teams. Эта страница задаёт общий словарь для приложения, доски задач, сообщений и review flow.
|
||||
|
||||
## Team
|
||||
|
||||
Team - группа агентов, настроенная для проекта. Обычно есть lead и один или несколько teammates со специализированными ролями.
|
||||
Team - именованная группа агентов, привязанная к одному project path. У команды есть lead, опциональные teammates, настройки runtime/provider, prompts, inboxes, tasks и локальное состояние запуска.
|
||||
|
||||
## Lead
|
||||
|
||||
Lead координирует работу: разбивает цель на tasks, назначает teammates, отслеживает blockers и просит review.
|
||||
Lead - координатор команды. Он превращает цель пользователя в tasks, назначает или перенаправляет teammates, отслеживает blockers, запрашивает review и двигает работу по board.
|
||||
|
||||
Сообщения lead доставляются иначе, чем сообщения teammate: приложение ретранслирует записи inbox в lead runtime, а teammates читают свои inbox-файлы между turns.
|
||||
|
||||
## Teammate
|
||||
|
||||
Teammate - не-lead агент в команде. Обычно teammate отвечает за сфокусированную роль: builder, reviewer, researcher или tester. Teammate может получать direct messages, task assignments, task comments и review requests.
|
||||
|
||||
## Task
|
||||
|
||||
Task - устойчивая единица работы. У неё есть status, description, comments, logs, attachments и reviewable changes.
|
||||
Task - долговечная единица работы. У неё есть id, status, owner, description, comments, logs, attachments, task references и reviewable changes.
|
||||
|
||||
## Solo mode
|
||||
Типичные состояния task: `todo`, `in_progress`, `done`, `review`, `approved`. Файл task хранит рабочее состояние, а review/approval позиция может дополнительно храниться в kanban overlay state.
|
||||
|
||||
Solo mode запускает команду из одного агента. Полезно для маленьких задач, меньшего token usage и проверки prompt перед расширением до команды.
|
||||
## Kanban
|
||||
|
||||
## Cross-team communication
|
||||
Kanban - board view для командной работы. Он помогает смотреть tasks по состояниям, открывать детали, читать logs, ревьюить diffs, approve finished work или request changes.
|
||||
|
||||
Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы.
|
||||
## Inbox
|
||||
|
||||
## Autonomy level
|
||||
Inbox - локальный message-файл участника команды. Agent Teams использует inboxes для user messages, lead messages, teammate messages, runtime delivery metadata, cross-team messages и части system notifications.
|
||||
|
||||
Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше автономности быстрее, меньше - безопаснее для sensitive code paths.
|
||||
Messages - долговечные локальные записи. Но доставка всё равно зависит от того, жив ли выбранный runtime и сможет ли он обработать следующий turn.
|
||||
|
||||
## Agent Block
|
||||
|
||||
Agent Block - скрытый agent-only instruction text, обёрнутый в `<info_for_agent>...</info_for_agent>`. UI убирает такие блоки из обычного human-facing display, но agents и runtime delivery могут использовать их для coordination details.
|
||||
|
||||
Текущий canonical marker - `info_for_agent`; в старых документах могут встречаться legacy agent block formats.
|
||||
|
||||
## Context Phase
|
||||
|
||||
Context Phase - сегмент session context timeline. Compaction начинает новую phase, поэтому token/context usage можно анализировать до и после reset.
|
||||
|
||||
Context tracking разделяет категории: project instructions, mentioned files, tool output, thinking text, team coordination и user messages. Эти числа нужны для диагностики, а не как provider billing statement.
|
||||
|
||||
## Runtime
|
||||
|
||||
Runtime - локальный execution path, который соединяет Agent Teams с model/provider workflow, например Claude, Codex или OpenCode.
|
||||
Runtime - локальный execution path, который выполняет agent turn. Поддерживаемые runtime paths: Claude Code, Codex и OpenCode.
|
||||
|
||||
Runtime отвечает за model execution behavior, auth details, tool execution semantics, rate limits, model availability и часть transcript/log formats.
|
||||
|
||||
## Provider
|
||||
|
||||
Provider - путь доступа к модели за runtime. Текущие provider ids: Anthropic, Codex, Gemini и OpenCode. OpenCode может маршрутизировать к множеству model providers через собственную конфигурацию.
|
||||
|
||||
Agent Teams orchestrates tasks and messages, но не заменяет provider authentication или provider policy.
|
||||
|
||||
## Solo mode
|
||||
|
||||
Solo mode запускает команду из одного агента. Полезно для небольших задач, меньшего coordination overhead и проверки prompt перед расширением до команды.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы, но их не хочется объединять в одну большую команду.
|
||||
|
||||
## Autonomy level
|
||||
|
||||
Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше autonomy быстрее, меньше - безопаснее для sensitive code paths, persistence, provider auth, Git operations и releases.
|
||||
|
||||
## Review
|
||||
|
||||
Review - task-scoped acceptance flow. Task может перейти в review, получить comments или requested changes, а затем перейти в approved, когда результат принят.
|
||||
|
||||
Review привязан к local diffs и task history, поэтому лучше работает с узкими tasks и явным упоминанием task, над которой агент работает.
|
||||
|
|
|
|||
|
|
@ -4,26 +4,62 @@
|
|||
|
||||
Да. Приложение бесплатное и open source. Provider или runtime access может стоить денег в зависимости от выбранного пути.
|
||||
|
||||
## Нужно ли заранее ставить Claude или Codex?
|
||||
## Agent Teams включает доступ к моделям?
|
||||
|
||||
Нет. Agent Teams - локальный orchestration и UI layer. Model access приходит через выбранный runtime/provider path, например Claude Code, Codex или OpenCode.
|
||||
|
||||
## Какие runtimes поддерживаются?
|
||||
|
||||
Поддерживаемые runtime paths: Claude Code, Codex и OpenCode. App также отслеживает provider ids вроде Anthropic, Codex, Gemini и OpenCode, когда runtime их отдаёт.
|
||||
|
||||
## Нужно ли заранее ставить Claude Code или Codex?
|
||||
|
||||
Не всегда. Приложение ведёт runtime detection и setup через UI. Некоторые пути всё равно требуют внешнюю авторизацию runtime.
|
||||
|
||||
OpenCode setup отделён от Claude Code и Codex setup. Если launch fails, сначала проверьте runtime status и provider auth, а не меняйте team prompt.
|
||||
|
||||
## Приложение загружает мой код на серверы Agent Teams?
|
||||
|
||||
Нет. Agent Teams не является cloud code-sync сервисом. Но provider-backed model calls могут получать prompt context в зависимости от выбранного runtime.
|
||||
|
||||
## Где хранятся team files?
|
||||
|
||||
Team coordination data хранится локально в `~/.claude/teams/<team>/`, task files - в `~/.claude/tasks/<team>/`, а project session data - в `~/.claude/projects/<encoded-project>/`, когда она доступна.
|
||||
|
||||
## Что может выйти с моей машины?
|
||||
|
||||
Prompt context, selected file contents, tool results, command output, task text, comments и attachments могут уйти через runtime/provider path, когда агент использует provider-backed model. Точное поведение зависит от runtime и provider.
|
||||
|
||||
## Агенты могут общаться друг с другом?
|
||||
|
||||
Да. Агенты могут писать teammates, комментировать tasks и координироваться между teams.
|
||||
Да. Агенты могут писать teammates, комментировать tasks, координироваться между teams и использовать task references, чтобы разговор оставался привязанным к работе.
|
||||
|
||||
## Можно ревьюить код перед принятием?
|
||||
|
||||
Да. Review flow построен вокруг task-scoped diffs и hunk-level decisions.
|
||||
|
||||
## Что такое Agent Block?
|
||||
|
||||
Agent Block - скрытый agent-only text в маркерах вроде `<info_for_agent>...</info_for_agent>`. App убирает его из обычного user-facing display, но сохраняет для agent coordination.
|
||||
|
||||
## Что такое solo mode?
|
||||
|
||||
Solo mode - команда из одного агента. Подходит для небольших задач и меньшего coordination overhead.
|
||||
|
||||
## Могут ли разные teammates использовать разных providers?
|
||||
|
||||
Да, provider/model settings могут задаваться per team member, если выбранный runtime path это поддерживает. OpenCode - основной путь для широкой multi-provider routing.
|
||||
|
||||
## Почему task может быть review или approved отдельно от done?
|
||||
|
||||
Work state и review state связаны, но не идентичны. Task может быть done с точки зрения агента, а затем пройти review и approval в kanban UI.
|
||||
|
||||
## Что делать, если launch завис?
|
||||
|
||||
Откройте диагностику, соберите runtime logs и проверьте provider auth до изменения prompts.
|
||||
Откройте troubleshooting, соберите launch diagnostics, проверьте `~/.claude/teams/<team>/` и runtime/provider auth до изменения prompts.
|
||||
|
||||
Для OpenCode проверьте lane/session evidence, прежде чем считать, что teammate online, но игнорирует messages.
|
||||
|
||||
## Почему logs отличаются между runtimes?
|
||||
|
||||
Claude Code, Codex и OpenCode отдают разные transcript formats и runtime evidence. Agent Teams нормализует то, что может, но log completeness и attribution могут отличаться по runtime.
|
||||
|
|
|
|||
|
|
@ -1,30 +1,56 @@
|
|||
# Приватность и локальные данные
|
||||
|
||||
Agent Teams local-first, но выбранный provider path всё равно важен.
|
||||
Agent Teams local-first, но выбранный runtime/provider path всё равно важен. Эта страница описывает, что desktop app хранит локально и что может покинуть машину, когда agents вызывают provider-backed models.
|
||||
|
||||
## Что остаётся локально
|
||||
|
||||
Desktop app работает на вашей машине и читает локальные project/runtime data для UI:
|
||||
Desktop app работает на вашей машине и читает local project/runtime data для UI. Обычно локально есть:
|
||||
|
||||
- project files
|
||||
- task metadata
|
||||
- team configuration и member metadata
|
||||
- task metadata, task comments и task references
|
||||
- inbox messages
|
||||
- runtime/session logs
|
||||
- launch state и bootstrap diagnostics
|
||||
- review state
|
||||
- local app settings
|
||||
|
||||
Важные local locations:
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state и review-related team files. |
|
||||
| `~/.claude/tasks/<team>/` | Durable task JSON files для team board. |
|
||||
| `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files для session history, context analysis и transcript-backed UI. |
|
||||
|
||||
Точные файлы зависят от runtime и версии app. Для launch debugging самые свежие evidence обычно лежат в соответствующей папке `~/.claude/teams/<team>/`.
|
||||
|
||||
## Что может выйти с машины
|
||||
|
||||
Когда агент обращается к provider-backed model, prompt context и tool results могут отправляться через выбранный provider/runtime path. Это зависит от runtime и provider.
|
||||
Agent Teams сам по себе не является cloud code-sync сервисом для репозитория. Ему не нужно загружать весь project на Agent Teams server, чтобы показывать board, inbox, logs или review UI.
|
||||
|
||||
Но когда агент обращается к provider-backed model, prompt context, selected file contents, task text, comments, tool results, command output и другой runtime-provided context могут отправляться через выбранный runtime/provider path. Что именно отправится, зависит от runtime, model, tool calls, prompt и provider configuration.
|
||||
|
||||
Provider authentication, provider-side retention, training, logging, regional processing и billing регулируются выбранным provider/runtime. Для sensitive projects проверяйте их policies.
|
||||
|
||||
## Чего app не гарантирует
|
||||
|
||||
- App не может гарантировать, что provider-backed model calls никогда не получат private code.
|
||||
- App не может переопределить provider retention или billing policies.
|
||||
- App не может сделать remote provider полностью local model.
|
||||
- App не защитит secrets, если агенту поручили вставить их в prompts, task comments, files или commands.
|
||||
- App не может заставить все runtimes отдавать одинаковый transcript или audit detail.
|
||||
|
||||
## Практические правила
|
||||
|
||||
- Не прикладывайте secrets к tasks.
|
||||
- Не прикладывайте secrets к tasks, comments или direct messages.
|
||||
- Проверяйте provider policies для sensitive projects.
|
||||
- Используйте меньшую autonomy для risky repositories.
|
||||
- Держите task scope узким при работе с private code.
|
||||
- Для диагностики опирайтесь на local evidence и logs.
|
||||
- Проверяйте generated prompts, task descriptions и attached files перед работой с confidential material.
|
||||
- Выбирайте provider/model paths, которые соответствуют вашим privacy requirements.
|
||||
|
||||
## Open source
|
||||
|
||||
Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking и review flows.
|
||||
|
||||
Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking, inboxes, runtime diagnostics и review flows.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Провайдеры и рантаймы
|
||||
|
||||
Agent Teams отделяет orchestration от model access.
|
||||
Agent Teams отделяет orchestration от model access. Приложение управляет teams, tasks, messages, launch state и review UI; выбранный runtime/provider path выполняет реальную model work.
|
||||
|
||||
## Что даёт приложение
|
||||
|
||||
|
|
@ -12,6 +12,8 @@ Agent Teams даёт:
|
|||
- task logs
|
||||
- review UI
|
||||
- local project integration
|
||||
- runtime detection и capability checks
|
||||
- local logs и diagnostics
|
||||
|
||||
## Что даёт runtime
|
||||
|
||||
|
|
@ -21,20 +23,52 @@ Runtime отвечает за:
|
|||
- provider authentication
|
||||
- tool execution behavior
|
||||
- rate limits и capabilities конкретной модели
|
||||
- runtime-specific transcripts и delivery evidence
|
||||
|
||||
## Частые варианты
|
||||
## Поддерживаемые runtime paths
|
||||
|
||||
| Runtime | Заметки |
|
||||
| Runtime path | Provider/model path | Когда подходит | Заметки |
|
||||
| --- | --- |
|
||||
| Claude | Хорошо для Claude Code users и Anthropic access |
|
||||
| Codex | Хорошо для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Хорошо для multimodel routing и широкой provider coverage |
|
||||
| Claude Code | Anthropic / Claude models | Для Claude Code users и Anthropic-backed workflows | Базовый local-first путь для Claude teams. Нужен локально доступный runtime и account access. |
|
||||
| Codex | Codex / OpenAI-backed models | Для Codex-native workflows | Использует Codex runtime integration и Codex auth/account state, когда они доступны. Часть diagnostics отличается от Claude transcripts. |
|
||||
| OpenCode | OpenCode-managed model routing | Для multi-provider teams и широкой model coverage | OpenCode может маршрутизировать через множество model providers. Agent Teams считает OpenCode lanes runtime-specific evidence и не угадывает attribution при ambiguous lane identity. |
|
||||
|
||||
## Provider ids
|
||||
|
||||
В team/runtime configuration приложение сейчас распознаёт такие provider ids:
|
||||
|
||||
| Provider id | Смысл |
|
||||
| --- | --- |
|
||||
| `anthropic` | Anthropic / Claude Code path |
|
||||
| `codex` | Codex path |
|
||||
| `gemini` | Gemini provider path, когда его отдаёт runtime |
|
||||
| `opencode` | OpenCode path, включая OpenCode-managed provider routing |
|
||||
|
||||
Эта таблица не гарантирует, что каждый provider authenticated, installed или доступен для каждой модели на каждой машине. Runtime status и capability checks - source of truth для конкретного launch.
|
||||
|
||||
## Multi-provider strategy
|
||||
|
||||
Agent Teams остаётся provider-aware, но не provider-owned:
|
||||
|
||||
- teams, tasks, inboxes, comments, review state и launch diagnostics хранятся в local Agent Teams storage
|
||||
- каждый member может нести provider/model settings через team launch metadata
|
||||
- model availability, auth, rate limits и tool behavior остаются ответственностью runtime/provider
|
||||
- OpenCode - основной путь, когда одной team нужны разные provider/model lanes
|
||||
|
||||
## Стоимость providers
|
||||
|
||||
Agent Teams бесплатен. Стоимость provider usage зависит от выбранного runtime/provider.
|
||||
Agent Teams бесплатен и open source. Provider usage зависит от выбранного runtime/provider: subscription limits, API keys, account auth, rate limits и provider policies остаются внешними для приложения.
|
||||
|
||||
## Capability checks
|
||||
|
||||
Во время setup приложение может выполнять access и capability checks. Это помогает найти отсутствующую авторизацию до того, как team launch застрянет в provisioning.
|
||||
|
||||
Capability checks могут показать, что provider существует, но не authenticated; model list недоступен; runtime path отсутствует; или конкретная extension capability unsupported. Считайте это setup diagnostics, а не task failures.
|
||||
|
||||
## Ожидаемые ограничения
|
||||
|
||||
- Runtime support не означает одинаковый feature parity для Claude Code, Codex и OpenCode.
|
||||
- Log и transcript coverage отличаются по runtime.
|
||||
- Для OpenCode lanes нужна стабильная lane/session evidence, прежде чем app сможет безопасно attribute runtime logs.
|
||||
- Provider model names и availability могут меняться вне приложения.
|
||||
- Team prompt не исправит missing auth, missing PATH entries, provider outages или exhausted rate limits.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
|
||||
|
||||
setHeader(event, "content-type", "text/plain; charset=utf-8");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0];
|
|||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
|
||||
|
||||
setHeader(event, "content-type", "application/xml; charset=utf-8");
|
||||
|
||||
|
|
|
|||
20
package.json
20
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "claude-agent-teams-ui",
|
||||
"name": "agent-teams-ai",
|
||||
"type": "module",
|
||||
"version": "1.3.0",
|
||||
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
|
||||
|
|
@ -8,13 +8,13 @@
|
|||
"name": "Илия (777genius)",
|
||||
"email": "quantjumppro@gmail.com"
|
||||
},
|
||||
"homepage": "https://github.com/777genius/claude_agent_teams_ui",
|
||||
"homepage": "https://github.com/777genius/agent-teams-ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/777genius/claude_agent_teams_ui.git"
|
||||
"url": "https://github.com/777genius/agent-teams-ai.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/777genius/claude_agent_teams_ui/issues"
|
||||
"url": "https://github.com/777genius/agent-teams-ai/issues"
|
||||
},
|
||||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"opencode:prove-semantic-model-matrix": "node ./scripts/prove-opencode-semantic-model-matrix.mjs",
|
||||
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
|
||||
"team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs",
|
||||
"team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs",
|
||||
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
||||
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
|
||||
|
|
@ -155,6 +156,7 @@
|
|||
"motion": "12.38.0",
|
||||
"node-diff3": "^3.2.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"pica": "9.0.1",
|
||||
"pidusage": "4.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -270,7 +272,7 @@
|
|||
"main": "dist-electron/main/index.cjs"
|
||||
},
|
||||
"mac": {
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}-mac.${ext}",
|
||||
"artifactName": "Agent.Teams.AI-${version}-${arch}-mac.${ext}",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"minimumSystemVersion": "12.0",
|
||||
"target": [
|
||||
|
|
@ -286,7 +288,7 @@
|
|||
},
|
||||
"dmg": {
|
||||
"sign": false,
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}"
|
||||
"artifactName": "Agent.Teams.AI-${version}-${arch}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
|
|
@ -305,13 +307,13 @@
|
|||
"category": "Development"
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}.${ext}"
|
||||
"artifactName": "Agent.Teams.AI-${version}.${ext}"
|
||||
},
|
||||
"deb": {
|
||||
"afterInstall": "resources/afterInstall.sh"
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "Claude.Agent.Teams.UI.Setup.${version}.${ext}",
|
||||
"artifactName": "Agent.Teams.AI.Setup.${version}.${ext}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
|
|
@ -320,7 +322,7 @@
|
|||
{
|
||||
"provider": "github",
|
||||
"owner": "777genius",
|
||||
"repo": "claude_agent_teams_ui",
|
||||
"repo": "agent-teams-ai",
|
||||
"releaseType": "release"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export interface UseGraphSimulationResult {
|
|||
} | null;
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
|
||||
getActivityWorldRect: (nodeId: string) => StableRect | null;
|
||||
getLogWorldRect: (nodeId: string) => StableRect | null;
|
||||
getExtraWorldBounds: () => WorldBounds[];
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
const dragOwnerPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const launchAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const activityRectByNodeIdRef = useRef(new Map<string, StableRect>());
|
||||
const logRectByNodeIdRef = useRef(new Map<string, StableRect>());
|
||||
const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
|
||||
|
||||
const prevNodeIdsRef = useRef(new Set<string>());
|
||||
|
|
@ -112,6 +114,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
});
|
||||
return;
|
||||
|
|
@ -132,6 +135,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
fillMissingFallbackPositions: true,
|
||||
});
|
||||
|
|
@ -144,6 +148,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
layoutSnapshotRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -264,6 +269,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef.current.clear();
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = [];
|
||||
layoutSnapshotRef.current = null;
|
||||
lastValidSnapshotByTeamRef.current.clear();
|
||||
|
|
@ -283,6 +289,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getLogWorldRect: (nodeId: string) => logRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
}),
|
||||
[
|
||||
|
|
@ -352,6 +359,7 @@ function commitSnapshotGeometry(args: {
|
|||
dragOwnerPositionsRef: { current: ReadonlyMap<string, { x: number; y: number }> };
|
||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
logRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
extraWorldBoundsRef: { current: WorldBounds[] };
|
||||
fillMissingFallbackPositions?: boolean;
|
||||
}): void {
|
||||
|
|
@ -364,6 +372,7 @@ function commitSnapshotGeometry(args: {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
fillMissingFallbackPositions = false,
|
||||
} = args;
|
||||
|
|
@ -377,10 +386,12 @@ function commitSnapshotGeometry(args: {
|
|||
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
|
||||
|
||||
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
|
||||
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
|
||||
logRectByNodeIdRef.current.set(frame.ownerId, frame.logColumnRect);
|
||||
}
|
||||
|
||||
if (snapshot.leadNodeId) {
|
||||
|
|
@ -388,6 +399,7 @@ function commitSnapshotGeometry(args: {
|
|||
snapshot.leadNodeId,
|
||||
snapshot.leadSlotFrame.activityColumnRect
|
||||
);
|
||||
logRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadSlotFrame.logColumnRect);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -396,6 +408,7 @@ function resetToFallbackLayout(args: {
|
|||
layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null };
|
||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
logRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
extraWorldBoundsRef: { current: WorldBounds[] };
|
||||
}): void {
|
||||
const {
|
||||
|
|
@ -403,12 +416,14 @@ function resetToFallbackLayout(args: {
|
|||
layoutSnapshotRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
} = args;
|
||||
|
||||
layoutSnapshotRef.current = null;
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = [];
|
||||
fallbackPositionNodes(nodes);
|
||||
KanbanLayoutEngine.layout(nodes);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -33,6 +33,7 @@ export interface GraphConfigPort {
|
|||
|
||||
// ─── Filters (show/hide node kinds) ────────────────────────────────────
|
||||
showActivity?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTasks?: boolean;
|
||||
showProcesses?: boolean;
|
||||
showCompletedTasks?: boolean;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export interface GraphLayoutPort {
|
|||
version: GraphLayoutVersion;
|
||||
mode?: GraphLayoutMode;
|
||||
showActivity?: boolean;
|
||||
showLogs?: boolean;
|
||||
ownerOrder: string[];
|
||||
slotAssignments: Record<string, GraphOwnerSlotAssignment>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Activity,
|
||||
Columns3,
|
||||
Expand,
|
||||
FileText,
|
||||
Settings2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
|
|
@ -29,6 +30,7 @@ import type { GraphLayoutMode } from '../ports/types';
|
|||
|
||||
export interface GraphFilterState {
|
||||
showActivity: boolean;
|
||||
showLogs: boolean;
|
||||
showTasks: boolean;
|
||||
showProcesses: boolean;
|
||||
showEdges: boolean;
|
||||
|
|
@ -269,6 +271,13 @@ export function GraphControls({
|
|||
label="Activity"
|
||||
block
|
||||
/>
|
||||
<ToolbarToggle
|
||||
active={filters.showLogs}
|
||||
onClick={() => toggle('showLogs')}
|
||||
icon={<FileText size={13} />}
|
||||
label="Logs"
|
||||
block
|
||||
/>
|
||||
<ToolbarToggle
|
||||
active={filters.showTasks}
|
||||
onClick={() => toggle('showTasks')}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export interface GraphViewProps {
|
|||
leadNodeId: string
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
getLogWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
getTransientHandoffSnapshot: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -123,6 +124,7 @@ export function GraphView({
|
|||
const [interactionLocked, setInteractionLocked] = useState(false);
|
||||
const [filters, setFilters] = useState<GraphFilterState>({
|
||||
showActivity: config?.showActivity ?? true,
|
||||
showLogs: config?.showLogs ?? config?.showActivity ?? true,
|
||||
showTasks: config?.showTasks ?? true,
|
||||
showProcesses: config?.showProcesses ?? true,
|
||||
showEdges: true,
|
||||
|
|
@ -137,9 +139,10 @@ export function GraphView({
|
|||
? {
|
||||
...data.layout,
|
||||
showActivity: filters.showActivity,
|
||||
showLogs: filters.showLogs,
|
||||
}
|
||||
: data.layout,
|
||||
[data.layout, filters.showActivity]
|
||||
[data.layout, filters.showActivity, filters.showLogs]
|
||||
);
|
||||
|
||||
// Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change
|
||||
|
|
@ -295,6 +298,10 @@ export function GraphView({
|
|||
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||
[]
|
||||
);
|
||||
const getLogWorldRect = useCallback(
|
||||
(ownerNodeId: string) => simulationRef.current.getLogWorldRect(ownerNodeId),
|
||||
[]
|
||||
);
|
||||
const getTransientHandoffSnapshot = useCallback(
|
||||
(options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -1092,6 +1099,7 @@ export function GraphView({
|
|||
filters,
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityWorldRect,
|
||||
getLogWorldRect,
|
||||
getTransientHandoffSnapshot,
|
||||
getCameraZoom,
|
||||
worldToScreen: camera.worldToScreen,
|
||||
|
|
|
|||
|
|
@ -239,6 +239,9 @@ importers:
|
|||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
pica:
|
||||
specifier: 9.0.1
|
||||
version: 9.0.1
|
||||
pidusage:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
|
|
@ -492,6 +495,9 @@ importers:
|
|||
'@shikijs/transformers':
|
||||
specifier: 3.22.0
|
||||
version: 3.22.0
|
||||
autoprefixer:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(postcss@8.5.8)
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
|
|
@ -504,6 +510,9 @@ importers:
|
|||
sass:
|
||||
specifier: ^1.97.2
|
||||
version: 1.98.0
|
||||
tailwindcss:
|
||||
specifier: ^3.4.19
|
||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-vuetify:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
||||
|
|
@ -5243,8 +5252,8 @@ packages:
|
|||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
autoprefixer@10.4.27:
|
||||
resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
|
||||
autoprefixer@10.5.0:
|
||||
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
|
|
@ -5327,6 +5336,11 @@ packages:
|
|||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.10.28:
|
||||
resolution: {integrity: sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
baseline-browser-mapping@2.9.14:
|
||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||
hasBin: true
|
||||
|
|
@ -5384,6 +5398,11 @@ packages:
|
|||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
browserslist@4.28.2:
|
||||
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
|
|
@ -5490,6 +5509,9 @@ packages:
|
|||
caniuse-lite@1.0.30001781:
|
||||
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
|
||||
|
||||
caniuse-lite@1.0.30001792:
|
||||
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
|
|
@ -6257,6 +6279,9 @@ packages:
|
|||
electron-to-chromium@1.5.267:
|
||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||
|
||||
electron-to-chromium@1.5.352:
|
||||
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
|
||||
|
||||
electron-updater@6.7.3:
|
||||
resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==}
|
||||
|
||||
|
|
@ -7111,6 +7136,9 @@ packages:
|
|||
resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
glur@1.1.2:
|
||||
resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -8352,6 +8380,9 @@ packages:
|
|||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
multimath@2.0.0:
|
||||
resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
|
|
@ -8450,6 +8481,9 @@ packages:
|
|||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
node-releases@2.0.38:
|
||||
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
|
||||
|
||||
nopt@8.1.0:
|
||||
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
|
@ -8757,6 +8791,9 @@ packages:
|
|||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pica@9.0.1:
|
||||
resolution: {integrity: sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
|
|
@ -10948,6 +10985,9 @@ packages:
|
|||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
webworkify@1.5.0:
|
||||
resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==}
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -13100,7 +13140,7 @@ snapshots:
|
|||
'@rollup/plugin-replace': 6.0.3(rollup@4.60.0)
|
||||
'@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
||||
autoprefixer: 10.4.27(postcss@8.5.8)
|
||||
autoprefixer: 10.5.0(postcss@8.5.8)
|
||||
consola: 3.4.2
|
||||
cssnano: 7.1.3(postcss@8.5.8)
|
||||
defu: 6.1.4
|
||||
|
|
@ -16245,10 +16285,10 @@ snapshots:
|
|||
postcss: 8.5.6
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
autoprefixer@10.4.27(postcss@8.5.8):
|
||||
autoprefixer@10.5.0(postcss@8.5.8):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
caniuse-lite: 1.0.30001781
|
||||
browserslist: 4.28.2
|
||||
caniuse-lite: 1.0.30001792
|
||||
fraction.js: 5.3.4
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.5.8
|
||||
|
|
@ -16318,6 +16358,8 @@ snapshots:
|
|||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.10.28: {}
|
||||
|
||||
baseline-browser-mapping@2.9.14: {}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
|
|
@ -16388,6 +16430,14 @@ snapshots:
|
|||
node-releases: 2.0.27
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
browserslist@4.28.2:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.28
|
||||
caniuse-lite: 1.0.30001792
|
||||
electron-to-chromium: 1.5.352
|
||||
node-releases: 2.0.38
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-crc32@1.0.0: {}
|
||||
|
|
@ -16535,6 +16585,8 @@ snapshots:
|
|||
|
||||
caniuse-lite@1.0.30001781: {}
|
||||
|
||||
caniuse-lite@1.0.30001792: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chai@5.3.3:
|
||||
|
|
@ -17324,6 +17376,8 @@ snapshots:
|
|||
|
||||
electron-to-chromium@1.5.267: {}
|
||||
|
||||
electron-to-chromium@1.5.352: {}
|
||||
|
||||
electron-updater@6.7.3:
|
||||
dependencies:
|
||||
builder-util-runtime: 9.5.1
|
||||
|
|
@ -18633,6 +18687,8 @@ snapshots:
|
|||
slash: 5.1.0
|
||||
unicorn-magic: 0.4.0
|
||||
|
||||
glur@1.1.2: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
got@11.8.6:
|
||||
|
|
@ -20213,6 +20269,11 @@ snapshots:
|
|||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
multimath@2.0.0:
|
||||
dependencies:
|
||||
glur: 1.1.2
|
||||
object-assign: 4.1.1
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
|
@ -20391,6 +20452,8 @@ snapshots:
|
|||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
node-releases@2.0.38: {}
|
||||
|
||||
nopt@8.1.0:
|
||||
dependencies:
|
||||
abbrev: 3.0.1
|
||||
|
|
@ -20918,6 +20981,13 @@ snapshots:
|
|||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
pica@9.0.1:
|
||||
dependencies:
|
||||
glur: 1.1.2
|
||||
multimath: 2.0.0
|
||||
object-assign: 4.1.1
|
||||
webworkify: 1.5.0
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
|
@ -22988,6 +23058,12 @@ snapshots:
|
|||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uqr@0.1.2: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
|
|
@ -23446,6 +23522,8 @@ snapshots:
|
|||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webworkify@1.5.0: {}
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.21",
|
||||
"sourceRef": "v0.0.21",
|
||||
"version": "0.0.27",
|
||||
"sourceRef": "v0.0.27",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseRepository": "777genius/agent-teams-ai",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.27.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.27.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.27.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.27.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,18 @@ import { fileURLToPath } from 'node:url';
|
|||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_CLI_LAUNCH_LIVE_E2E: '1',
|
||||
CLAUDE_TEAM_CLI_FLAVOR: process.env.CLAUDE_TEAM_CLI_FLAVOR || 'agent_teams_orchestrator',
|
||||
CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH:
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH || path.join(siblingOrchestrator, 'cli'),
|
||||
};
|
||||
|
||||
console.log('Running agent CLI launch live smoke');
|
||||
console.log(`Claude runtime: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
|
|
|
|||
308
scripts/prove-provider-launch-stress.mjs
Normal file
308
scripts/prove-provider-launch-stress.mjs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
#!/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 process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { preflightOpenCodeLiveEnvironment } from './lib/opencode-live-preflight.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
const requestedOrder =
|
||||
process.env.PROVIDER_LAUNCH_STRESS_ORDER?.trim() || 'anthropic,codex,opencode,mixed';
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PROVIDER_LAUNCH_STRESS_LIVE: '1',
|
||||
PROVIDER_LAUNCH_STRESS_ORDER: requestedOrder,
|
||||
PROVIDER_LAUNCH_STRESS_MEMBER_COUNT:
|
||||
process.env.PROVIDER_LAUNCH_STRESS_MEMBER_COUNT?.trim() || '5',
|
||||
PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH:
|
||||
process.env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH?.trim() ||
|
||||
(process.env.ANTHROPIC_API_KEY?.trim() ? 'api-key' : 'subscription'),
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running provider launch stress live smoke');
|
||||
console.log(`Requested order: ${env.PROVIDER_LAUNCH_STRESS_ORDER}`);
|
||||
console.log(`Members per scenario: ${env.PROVIDER_LAUNCH_STRESS_MEMBER_COUNT}`);
|
||||
console.log(`Anthropic auth: ${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH}`);
|
||||
console.log(
|
||||
`Models: anthropic=${env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_MODEL || 'haiku'}, codex=${
|
||||
env.PROVIDER_LAUNCH_STRESS_CODEX_MODEL || 'gpt-5.4-mini'
|
||||
}, opencode=${env.PROVIDER_LAUNCH_STRESS_OPENCODE_MODEL || 'openai/gpt-5.4-mini'}`
|
||||
);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const preflight = await preflightProviderLaunchStress({ repoRoot, requestedOrder });
|
||||
for (const line of preflight.messages) {
|
||||
console.log(line);
|
||||
}
|
||||
if (preflight.order.length === 0) {
|
||||
console.warn('SKIPPED: no requested provider launch stress scenarios are available.');
|
||||
process.exit(process.env.PROVIDER_LAUNCH_STRESS_STRICT === '1' ? 1 : 0);
|
||||
}
|
||||
if (preflight.skipped.length > 0 && process.env.PROVIDER_LAUNCH_STRESS_STRICT === '1') {
|
||||
console.error('Provider launch stress preflight failed in strict mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
env.PROVIDER_LAUNCH_STRESS_ORDER = preflight.order.join(',');
|
||||
console.log(`Runnable order: ${env.PROVIDER_LAUNCH_STRESS_ORDER}`);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/services/team/ProviderLaunchStress.live-e2e.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run provider launch stress smoke: ${result.error.message}`);
|
||||
packageLatestLaunchFailureArtifacts();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
packageLatestLaunchFailureArtifacts();
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
async function preflightProviderLaunchStress(input) {
|
||||
const requested = parseScenarioOrder(input.requestedOrder);
|
||||
const needs = {
|
||||
anthropic: requested.includes('anthropic') || requested.includes('mixed'),
|
||||
codex: requested.includes('codex') || requested.includes('mixed'),
|
||||
opencode: requested.includes('opencode') || requested.includes('mixed'),
|
||||
};
|
||||
const checks = {
|
||||
anthropic: needs.anthropic ? await preflightAnthropic(input.repoRoot) : { ok: true },
|
||||
codex: needs.codex ? preflightCodex() : { ok: true },
|
||||
opencode: needs.opencode
|
||||
? await preflightOpenCodeLiveEnvironment({ repoRoot: input.repoRoot })
|
||||
: { ok: true },
|
||||
};
|
||||
const skipped = [];
|
||||
const order = [];
|
||||
for (const scenario of requested) {
|
||||
const unavailable = scenarioDependencies(scenario).filter((provider) => !checks[provider].ok);
|
||||
if (unavailable.length > 0) {
|
||||
skipped.push({
|
||||
scenario,
|
||||
reason: unavailable
|
||||
.map((provider) => `${provider}: ${checks[provider].reason}`)
|
||||
.join('; '),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
order.push(scenario);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
skipped,
|
||||
messages: [
|
||||
...Object.entries(checks)
|
||||
.filter(([provider]) => needs[provider])
|
||||
.map(([provider, check]) =>
|
||||
check.ok
|
||||
? `Preflight ${provider}: ok`
|
||||
: `Preflight ${provider}: unavailable - ${check.reason}`
|
||||
),
|
||||
...skipped.map((item) => `Skipping ${item.scenario}: ${item.reason}`),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseScenarioOrder(value) {
|
||||
const parsed = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => ['anthropic', 'codex', 'opencode', 'mixed'].includes(item));
|
||||
return parsed.length > 0 ? parsed : ['anthropic', 'codex', 'opencode', 'mixed'];
|
||||
}
|
||||
|
||||
function scenarioDependencies(scenario) {
|
||||
if (scenario === 'mixed') return ['anthropic', 'codex', 'opencode'];
|
||||
return [scenario];
|
||||
}
|
||||
|
||||
async function preflightAnthropic(repoRoot) {
|
||||
const mode = env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH.toLowerCase();
|
||||
if (mode === 'api-key') {
|
||||
return env.ANTHROPIC_API_KEY?.trim()
|
||||
? { ok: true }
|
||||
: { ok: false, reason: 'ANTHROPIC_API_KEY is not configured' };
|
||||
}
|
||||
|
||||
const version = spawnSync('claude', ['--version'], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000,
|
||||
maxBuffer: 128_000,
|
||||
});
|
||||
if (version.status !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: compactOutput(version.stderr || version.stdout || version.error?.message || 'claude --version failed'),
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function preflightCodex() {
|
||||
const codexHome = path.resolve(
|
||||
env.PROVIDER_LAUNCH_STRESS_CODEX_HOME?.trim() || env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex')
|
||||
);
|
||||
if (hasCodexSubscriptionAuth(codexHome)) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: `Codex subscription auth not found in ${codexHome}` };
|
||||
}
|
||||
|
||||
function hasCodexSubscriptionAuth(codexHome) {
|
||||
const legacyAuth = readJsonIfExists(path.join(codexHome, 'auth.json'));
|
||||
if (isCodexChatGptSubscriptionAuth(legacyAuth)) return true;
|
||||
|
||||
const accountsDir = path.join(codexHome, 'accounts');
|
||||
const registry = readJsonIfExists(path.join(accountsDir, 'registry.json'));
|
||||
const activeAccountId =
|
||||
readStringProperty(registry, 'active_account_id') ??
|
||||
readStringProperty(registry, 'activeAccountId') ??
|
||||
readStringProperty(registry, 'current_account_id') ??
|
||||
readStringProperty(registry, 'currentAccountId');
|
||||
const candidates = new Set();
|
||||
if (activeAccountId) {
|
||||
candidates.add(path.join(accountsDir, `${activeAccountId}.auth.json`));
|
||||
candidates.add(path.join(accountsDir, activeAccountId));
|
||||
}
|
||||
for (const entry of safeReaddirFileNames(accountsDir)) {
|
||||
if (entry.endsWith('.auth.json')) candidates.add(path.join(accountsDir, entry));
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (isCodexChatGptSubscriptionAuth(readJsonIfExists(candidate))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readJsonIfExists(filePath) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStringProperty(source, key) {
|
||||
const value = source?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isCodexChatGptSubscriptionAuth(source) {
|
||||
if (!source) return false;
|
||||
const direct = readStringProperty(source, 'refresh_token');
|
||||
const tokens = source.tokens;
|
||||
const nested =
|
||||
tokens && typeof tokens === 'object' && !Array.isArray(tokens)
|
||||
? readStringProperty(tokens, 'refresh_token')
|
||||
: null;
|
||||
return Boolean(direct || nested);
|
||||
}
|
||||
|
||||
function packageLatestLaunchFailureArtifacts() {
|
||||
const artifacts = findLatestLaunchFailureArtifactDirs();
|
||||
if (artifacts.length === 0) {
|
||||
console.error('No launch failure artifact pack found under ~/.claude/teams.');
|
||||
return;
|
||||
}
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-team-launch-failure-artifacts-'));
|
||||
try {
|
||||
for (const artifact of artifacts) {
|
||||
const destination = path.join(staging, `${artifact.teamName}-${path.basename(artifact.dir)}`);
|
||||
fs.cpSync(artifact.dir, destination, { recursive: true });
|
||||
}
|
||||
const bundle = path.join(
|
||||
os.tmpdir(),
|
||||
`agent-team-launch-failure-artifacts-${new Date().toISOString().replace(/[:.]/g, '-')}.tar.gz`
|
||||
);
|
||||
const tar = spawnSync('tar', ['-czf', bundle, '-C', staging, '.'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
maxBuffer: 256_000,
|
||||
});
|
||||
if (tar.status !== 0) {
|
||||
console.error(`Failed to create artifact bundle: ${compactOutput(tar.stderr || tar.stdout || tar.error?.message || 'tar failed')}`);
|
||||
return;
|
||||
}
|
||||
console.error(`Launch failure artifact bundle: ${bundle}`);
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function findLatestLaunchFailureArtifactDirs() {
|
||||
const teamsRoot = path.join(os.homedir(), '.claude', 'teams');
|
||||
const results = [];
|
||||
for (const teamName of safeReaddirNames(teamsRoot)) {
|
||||
const latestPath = path.join(teamsRoot, teamName, 'launch-failure-artifacts', 'latest.json');
|
||||
const latest = readJsonIfExists(latestPath);
|
||||
const manifestPath =
|
||||
typeof latest?.manifestPath === 'string' ? latest.manifestPath : null;
|
||||
const dir = manifestPath ? path.dirname(manifestPath) : null;
|
||||
if (!dir || !fs.existsSync(dir)) continue;
|
||||
const stat = fs.statSync(dir);
|
||||
results.push({ teamName, dir, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
return results.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, 4);
|
||||
}
|
||||
|
||||
function safeReaddirNames(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function safeReaddirFileNames(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function compactOutput(value) {
|
||||
return String(value).replace(/\s+/g, ' ').trim().slice(0, 1_200);
|
||||
}
|
||||
518
scripts/smoke/agent-attachments-smoke.mjs
Normal file
518
scripts/smoke/agent-attachments-smoke.mjs
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
const PROMPT = 'Look at the attached image. Reply with exactly one word: red, green, or blue.';
|
||||
const MULTI_IMAGE_PROMPT =
|
||||
'Look at every attached image. Each image has a dominant background color. Reply with exactly one word: red, green, or blue. Use red if the dominant background color is red in all images.';
|
||||
const TIMEOUT_MS = 90_000;
|
||||
|
||||
const CASES = [
|
||||
{
|
||||
id: 'claude-subscription-streaming',
|
||||
runtime: 'claude',
|
||||
model: process.env.CLAUDE_ATTACHMENTS_SMOKE_CLAUDE_MODEL || 'claude-haiku-4-5',
|
||||
command: async (imagePath, cwd, testCase, imagePaths) => ({
|
||||
bin: 'claude',
|
||||
args: [
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--no-session-persistence',
|
||||
'--model',
|
||||
testCase.model,
|
||||
],
|
||||
cwd,
|
||||
stdin: await buildClaudeStreamJsonPrompt(imagePaths, PROMPT),
|
||||
}),
|
||||
expected: /red/i,
|
||||
},
|
||||
{
|
||||
id: 'claude-subscription-streaming-multi-image',
|
||||
runtime: 'claude',
|
||||
model: process.env.CLAUDE_ATTACHMENTS_SMOKE_CLAUDE_MODEL || 'claude-haiku-4-5',
|
||||
imageCount: 3,
|
||||
imageWidth: 1600,
|
||||
imageHeight: 1100,
|
||||
command: async (imagePath, cwd, testCase, imagePaths) => ({
|
||||
bin: 'claude',
|
||||
args: [
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--no-session-persistence',
|
||||
'--model',
|
||||
testCase.model,
|
||||
],
|
||||
cwd,
|
||||
stdin: await buildClaudeStreamJsonPrompt(imagePaths, MULTI_IMAGE_PROMPT),
|
||||
}),
|
||||
expected: /red/i,
|
||||
},
|
||||
{
|
||||
id: 'codex-native-gpt-5-4-mini',
|
||||
runtime: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
command: (imagePath, cwd) => ({
|
||||
bin: 'codex',
|
||||
args: [
|
||||
'exec',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'-C',
|
||||
cwd,
|
||||
'--model',
|
||||
'gpt-5.4-mini',
|
||||
'--image',
|
||||
imagePath,
|
||||
'-',
|
||||
],
|
||||
stdin: PROMPT,
|
||||
}),
|
||||
expected: /red/i,
|
||||
},
|
||||
{
|
||||
id: 'codex-native-gpt-5-4-mini-multi-image',
|
||||
runtime: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
imageCount: 3,
|
||||
imageWidth: 1600,
|
||||
imageHeight: 1100,
|
||||
command: (imagePath, cwd, testCase, imagePaths) => ({
|
||||
bin: 'codex',
|
||||
args: [
|
||||
'exec',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'-C',
|
||||
cwd,
|
||||
'--model',
|
||||
'gpt-5.4-mini',
|
||||
...imagePaths.flatMap((candidate) => ['--image', candidate]),
|
||||
'-',
|
||||
],
|
||||
stdin: MULTI_IMAGE_PROMPT,
|
||||
}),
|
||||
expected: /red/i,
|
||||
},
|
||||
{
|
||||
id: 'opencode-openai-gpt-5-4-mini',
|
||||
runtime: 'opencode',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
command: (imagePath, cwd) => ({
|
||||
bin: 'opencode',
|
||||
args: [
|
||||
'run',
|
||||
'--pure',
|
||||
'--format',
|
||||
'json',
|
||||
'--dir',
|
||||
cwd,
|
||||
'--model',
|
||||
'openai/gpt-5.4-mini',
|
||||
PROMPT,
|
||||
'-f',
|
||||
imagePath,
|
||||
],
|
||||
}),
|
||||
expected: /red/i,
|
||||
},
|
||||
{
|
||||
id: 'opencode-openrouter-kimi-k2-6-multi-image',
|
||||
runtime: 'opencode',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
envRequired: ['OPENROUTER_API_KEY'],
|
||||
imageCount: 3,
|
||||
imageWidth: 1600,
|
||||
imageHeight: 1100,
|
||||
command: (imagePath, cwd, testCase, imagePaths) => ({
|
||||
bin: 'opencode',
|
||||
args: [
|
||||
'run',
|
||||
'--pure',
|
||||
'--format',
|
||||
'json',
|
||||
'--dir',
|
||||
cwd,
|
||||
'--model',
|
||||
'openrouter/moonshotai/kimi-k2.6',
|
||||
MULTI_IMAGE_PROMPT,
|
||||
...imagePaths.flatMap((candidate) => ['-f', candidate]),
|
||||
],
|
||||
}),
|
||||
expected: /red/i,
|
||||
},
|
||||
{
|
||||
id: 'opencode-openrouter-kimi-k2-6',
|
||||
runtime: 'opencode',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
envRequired: ['OPENROUTER_API_KEY'],
|
||||
command: (imagePath, cwd) => ({
|
||||
bin: 'opencode',
|
||||
args: [
|
||||
'run',
|
||||
'--pure',
|
||||
'--format',
|
||||
'json',
|
||||
'--dir',
|
||||
cwd,
|
||||
'--model',
|
||||
'openrouter/moonshotai/kimi-k2.6',
|
||||
PROMPT,
|
||||
'-f',
|
||||
imagePath,
|
||||
],
|
||||
}),
|
||||
expected: /red/i,
|
||||
},
|
||||
{
|
||||
id: 'opencode-openrouter-glm-4-5v',
|
||||
runtime: 'opencode',
|
||||
model: 'openrouter/z-ai/glm-4.5v',
|
||||
envRequired: ['OPENROUTER_API_KEY'],
|
||||
command: (imagePath, cwd) => ({
|
||||
bin: 'opencode',
|
||||
args: [
|
||||
'run',
|
||||
'--pure',
|
||||
'--format',
|
||||
'json',
|
||||
'--dir',
|
||||
cwd,
|
||||
'--model',
|
||||
'openrouter/z-ai/glm-4.5v',
|
||||
PROMPT,
|
||||
'-f',
|
||||
imagePath,
|
||||
],
|
||||
}),
|
||||
expected: /red/i,
|
||||
},
|
||||
{
|
||||
id: 'opencode-openrouter-glm-5-1-negative',
|
||||
runtime: 'opencode',
|
||||
model: 'openrouter/z-ai/glm-5.1',
|
||||
envRequired: ['OPENROUTER_API_KEY'],
|
||||
command: (imagePath, cwd) => ({
|
||||
bin: 'opencode',
|
||||
args: [
|
||||
'run',
|
||||
'--pure',
|
||||
'--format',
|
||||
'json',
|
||||
'--dir',
|
||||
cwd,
|
||||
'--model',
|
||||
'openrouter/z-ai/glm-5.1',
|
||||
PROMPT,
|
||||
'-f',
|
||||
imagePath,
|
||||
],
|
||||
}),
|
||||
expectedUnsupported: true,
|
||||
},
|
||||
];
|
||||
|
||||
function crc32(bytes) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const typeBytes = Buffer.from(type, 'ascii');
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 0);
|
||||
return Buffer.concat([length, typeBytes, data, crc]);
|
||||
}
|
||||
|
||||
function createRedCardPng(width = 320, height = 240) {
|
||||
const raw = Buffer.alloc((width * 4 + 1) * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const row = y * (width * 4 + 1);
|
||||
raw[row] = 0;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = row + 1 + x * 4;
|
||||
const marker = x > 135 && x < 185 && y > 95 && y < 145;
|
||||
raw[offset] = 230;
|
||||
raw[offset + 1] = marker ? 245 : 20;
|
||||
raw[offset + 2] = marker ? 245 : 20;
|
||||
raw[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', deflateSync(raw)),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function buildClaudeStreamJsonPrompt(imagePaths, prompt) {
|
||||
const imageBlocks = [];
|
||||
for (const imagePath of imagePaths) {
|
||||
const data = await readFile(imagePath, 'base64');
|
||||
imageBlocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
// Claude stream-json expects image bytes inside a structured image block.
|
||||
// Do not replace this with base64-in-text fallback because that tests a different path.
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
return `${JSON.stringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
...imageBlocks,
|
||||
],
|
||||
},
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const selected = [];
|
||||
let all = false;
|
||||
let list = false;
|
||||
let jsonPath = null;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--case' && argv[index + 1]) {
|
||||
selected.push(argv[index + 1]);
|
||||
index += 1;
|
||||
} else if (arg === '--list') {
|
||||
list = true;
|
||||
} else if (arg === '--all') {
|
||||
all = true;
|
||||
} else if (arg === '--json' && argv[index + 1]) {
|
||||
jsonPath = argv[index + 1];
|
||||
index += 1;
|
||||
} else {
|
||||
throw new Error(`Unknown or incomplete argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (all && selected.length) {
|
||||
throw new Error('Use either --all or one or more --case arguments, not both');
|
||||
}
|
||||
return { all, jsonPath, list, selected };
|
||||
}
|
||||
|
||||
function runCommand(command) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command.bin, command.args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
cwd: command.cwd,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
resolve({ ok: false, timedOut: true, exitCode: null, stdout, stderr });
|
||||
}, TIMEOUT_MS);
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, timedOut: false, exitCode: null, stdout, stderr: error.message });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: code === 0, timedOut: false, exitCode: code, stdout, stderr });
|
||||
});
|
||||
if (command.stdin) {
|
||||
child.stdin.end(command.stdin);
|
||||
} else {
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function redactSmokeText(value) {
|
||||
let redacted = value
|
||||
.replace(/(data:image\/[a-z0-9.+-]+;base64,)[a-z0-9+/=]+/gi, '$1[redacted]')
|
||||
.replace(/("[Dd]ata"\s*:\s*")[a-z0-9+/=]{80,}(")/g, '$1[redacted]$2')
|
||||
.replace(/("[Ss]ignature"\s*:\s*")[^"]{80,}(")/g, '$1[redacted]$2')
|
||||
.replace(/\b[A-Za-z0-9+/]{400,}={0,2}\b/g, '[redacted long encoded payload]')
|
||||
.replace(/(Authorization\s*[:=]\s*Bearer\s+)[^\s"']+/gi, '$1[redacted]')
|
||||
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{20,}/g, '$1[redacted]')
|
||||
.replace(/\b(sk-(?:ant|or|proj|live|test|codex|openai)[A-Za-z0-9._~+/=-]{12,})\b/g, '[redacted api key]');
|
||||
|
||||
for (const [name, secret] of Object.entries(process.env)) {
|
||||
if (!secret || secret.length < 8) continue;
|
||||
if (!/(API[_-]?KEY|TOKEN|SECRET|AUTH|PASSWORD|OPENROUTER|ANTHROPIC|OPENAI|CODEX)/i.test(name)) {
|
||||
continue;
|
||||
}
|
||||
redacted = redacted.split(secret).join(`[redacted ${name}]`);
|
||||
}
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function extractAssistantTextFromJsonLine(line) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof parsed.result === 'string') {
|
||||
return parsed.result;
|
||||
}
|
||||
if (parsed.type === 'item.completed' && typeof parsed.item?.text === 'string') {
|
||||
return parsed.item.text;
|
||||
}
|
||||
if (parsed.type === 'text' && typeof parsed.part?.text === 'string') {
|
||||
return parsed.part.text;
|
||||
}
|
||||
const content = parsed.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractAssistantText(output) {
|
||||
const texts = [];
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('{')) continue;
|
||||
const text = extractAssistantTextFromJsonLine(trimmed);
|
||||
if (text?.trim()) {
|
||||
texts.push(text.trim());
|
||||
}
|
||||
}
|
||||
return texts.join('\n').trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.list) {
|
||||
console.log(CASES.map((testCase) => testCase.id).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
args.all || !args.selected.length
|
||||
? CASES
|
||||
: CASES.filter((testCase) => args.selected.includes(testCase.id));
|
||||
const missing = args.selected.filter((id) => !CASES.some((testCase) => testCase.id === id));
|
||||
if (missing.length) {
|
||||
throw new Error(`Unknown smoke case: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const cwd = await mkdtemp(path.join(tmpdir(), 'agent-attachments-smoke-'));
|
||||
await mkdir(cwd, { recursive: true });
|
||||
|
||||
const results = [];
|
||||
const imagePathsByCase = {};
|
||||
for (const testCase of selected) {
|
||||
const missingEnv = (testCase.envRequired ?? []).filter((name) => !process.env[name]);
|
||||
if (missingEnv.length) {
|
||||
results.push({
|
||||
id: testCase.id,
|
||||
runtime: testCase.runtime,
|
||||
model: testCase.model,
|
||||
status: 'skipped',
|
||||
reason: `missing env: ${missingEnv.join(', ')}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const imagePaths = await prepareSmokeImages(cwd, testCase);
|
||||
imagePathsByCase[testCase.id] = imagePaths;
|
||||
const command = await testCase.command(imagePaths[0], cwd, testCase, imagePaths);
|
||||
const result = await runCommand(command);
|
||||
const output = `${result.stdout}\n${result.stderr}`;
|
||||
const assistantText = extractAssistantText(output);
|
||||
const matched = testCase.expected
|
||||
? result.ok && assistantText.length > 0 && testCase.expected.test(assistantText)
|
||||
: false;
|
||||
const unsupportedMatched = testCase.expectedUnsupported
|
||||
? result.ok &&
|
||||
assistantText.length > 0 &&
|
||||
/cannot|unable|unsupported|text-only|vision|image|не могу|не поддерживает|изображен/i.test(
|
||||
assistantText
|
||||
)
|
||||
: false;
|
||||
results.push({
|
||||
id: testCase.id,
|
||||
runtime: testCase.runtime,
|
||||
model: testCase.model,
|
||||
status:
|
||||
(testCase.expectedUnsupported ? unsupportedMatched : result.ok && matched)
|
||||
? 'passed'
|
||||
: 'failed',
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
assistantText: redactSmokeText(assistantText.slice(-1000)),
|
||||
stdoutTail: redactSmokeText(result.stdout.slice(-4000)),
|
||||
stderrTail: redactSmokeText(result.stderr.slice(-4000)),
|
||||
});
|
||||
}
|
||||
|
||||
const firstImagePath = Object.values(imagePathsByCase)[0]?.[0] ?? null;
|
||||
const report = { imagePath: firstImagePath, imagePathsByCase, results };
|
||||
if (args.jsonPath) {
|
||||
await writeFile(path.resolve(args.jsonPath), `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (results.some((result) => result.status === 'failed')) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareSmokeImages(cwd, testCase) {
|
||||
const imageCount = testCase.imageCount ?? 1;
|
||||
const width = testCase.imageWidth ?? 320;
|
||||
const height = testCase.imageHeight ?? 240;
|
||||
const paths = [];
|
||||
for (let index = 0; index < imageCount; index += 1) {
|
||||
const imagePath = path.join(cwd, `red-card-${testCase.id}-${index + 1}.png`);
|
||||
await writeFile(imagePath, createRedCardPng(width, height));
|
||||
paths.push(imagePath);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
16
src/features/agent-attachments/contracts/index.ts
Normal file
16
src/features/agent-attachments/contracts/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export type {
|
||||
AgentAttachmentCapability,
|
||||
AgentAttachmentCapabilityTarget,
|
||||
AgentAttachmentErrorJson,
|
||||
AgentAttachmentPayload,
|
||||
AttachmentDeliveryFailureCode,
|
||||
AttachmentValidationResult,
|
||||
AttachmentWarning,
|
||||
AttachmentWarningCode,
|
||||
ImageOptimizationBudget,
|
||||
} from '../core/domain';
|
||||
export { AGENT_ATTACHMENT_SCHEMA_VERSION } from '../core/domain';
|
||||
export {
|
||||
estimateAgentAttachmentSerializedPayloadBytes,
|
||||
MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES,
|
||||
} from '../core/domain';
|
||||
59
src/features/agent-attachments/core/domain/budgets.test.ts
Normal file
59
src/features/agent-attachments/core/domain/budgets.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
allocateImageBudgets,
|
||||
estimateAgentAttachmentSerializedPayloadBytes,
|
||||
planResizeDimensions,
|
||||
sortAttachmentsForDelivery,
|
||||
} from './budgets';
|
||||
|
||||
describe('agent attachment budgets', () => {
|
||||
it('does not upscale small images', () => {
|
||||
expect(planResizeDimensions({ width: 320, height: 200 }, { maxEdge: 1600 })).toEqual({
|
||||
width: 320,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('downscales by longest edge', () => {
|
||||
expect(planResizeDimensions({ width: 4000, height: 2000 }, { maxEdge: 2000 })).toEqual({
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('allocates fair per-image budgets within total cap', () => {
|
||||
expect(
|
||||
allocateImageBudgets({
|
||||
images: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
|
||||
totalMaxBytes: 900,
|
||||
perImageMaxBytes: 500,
|
||||
})
|
||||
).toEqual([
|
||||
{ imageId: 'a', targetBytes: 300 },
|
||||
{ imageId: 'b', targetBytes: 300 },
|
||||
{ imageId: 'c', targetBytes: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves explicit attachment order for delivery', () => {
|
||||
const sorted = sortAttachmentsForDelivery([
|
||||
{ id: 'b', order: 2 },
|
||||
{ id: 'a', order: 1 },
|
||||
]);
|
||||
expect(sorted.map((item) => item.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('estimates serialized provider payload bytes including base64 overhead', () => {
|
||||
const bytes = estimateAgentAttachmentSerializedPayloadBytes({
|
||||
text: 'hello',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'red.png',
|
||||
mimeType: 'image/png',
|
||||
data: 'a'.repeat(128),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(bytes).toBeGreaterThan(128);
|
||||
});
|
||||
});
|
||||
105
src/features/agent-attachments/core/domain/budgets.ts
Normal file
105
src/features/agent-attachments/core/domain/budgets.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { ImageBudgetAllocation, ImageDimensions, ImageOptimizationBudget } from './types';
|
||||
|
||||
export const DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET: ImageOptimizationBudget = {
|
||||
maxInputBytes: 20 * 1024 * 1024,
|
||||
maxInputPixels: 32_000_000,
|
||||
maxOutputBytesPerImage: 4 * 1024 * 1024,
|
||||
maxOutputBytesTotal: 8 * 1024 * 1024,
|
||||
maxOutputEdge: 2400,
|
||||
jpegQualityAttempts: [0.86, 0.82, 0.78, 0.74, 0.72],
|
||||
};
|
||||
|
||||
export const MAX_AGENT_ATTACHMENT_SERIALIZED_PAYLOAD_BYTES = 7_500_000;
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
export function estimateAgentAttachmentSerializedPayloadBytes(input: {
|
||||
text?: string;
|
||||
attachments: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
filename?: string;
|
||||
}[];
|
||||
}): number {
|
||||
const contentBlocks: unknown[] = [{ type: 'text', text: input.text ?? '' }];
|
||||
for (const attachment of input.attachments) {
|
||||
const isImage = attachment.mimeType.startsWith('image/');
|
||||
contentBlocks.push({
|
||||
type: isImage ? 'image' : 'document',
|
||||
...(isImage
|
||||
? {}
|
||||
: {
|
||||
title: attachment.filename ?? 'attachment',
|
||||
}),
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: attachment.mimeType,
|
||||
data: attachment.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return utf8Encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: contentBlocks,
|
||||
},
|
||||
})
|
||||
).byteLength;
|
||||
}
|
||||
|
||||
export function calculatePixelCount(dimensions: ImageDimensions): number {
|
||||
return dimensions.width * dimensions.height;
|
||||
}
|
||||
|
||||
export function planResizeDimensions(
|
||||
dimensions: ImageDimensions,
|
||||
options: { maxEdge: number; allowUpscale?: boolean }
|
||||
): ImageDimensions {
|
||||
const width = Math.max(1, Math.floor(dimensions.width));
|
||||
const height = Math.max(1, Math.floor(dimensions.height));
|
||||
const maxEdge = Math.max(1, Math.floor(options.maxEdge));
|
||||
const longest = Math.max(width, height);
|
||||
|
||||
if (longest <= maxEdge && options.allowUpscale !== true) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const scale = maxEdge / longest;
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
export function allocateImageBudgets(input: {
|
||||
images: { id: string }[];
|
||||
totalMaxBytes: number;
|
||||
perImageMaxBytes: number;
|
||||
}): ImageBudgetAllocation[] {
|
||||
const count = Math.max(1, input.images.length);
|
||||
const fairShare = Math.floor(input.totalMaxBytes / count);
|
||||
const targetBytes = Math.max(1, Math.min(input.perImageMaxBytes, fairShare));
|
||||
|
||||
return input.images.map((image) => ({ imageId: image.id, targetBytes }));
|
||||
}
|
||||
|
||||
export function assertImageInputWithinBudget(input: {
|
||||
sizeBytes: number;
|
||||
dimensions: ImageDimensions;
|
||||
budget?: ImageOptimizationBudget;
|
||||
}): void {
|
||||
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
||||
if (input.sizeBytes > budget.maxInputBytes) {
|
||||
throw new Error('Image input exceeds byte budget');
|
||||
}
|
||||
if (calculatePixelCount(input.dimensions) > budget.maxInputPixels) {
|
||||
throw new Error('Image input exceeds pixel budget');
|
||||
}
|
||||
}
|
||||
|
||||
export function sortAttachmentsForDelivery<T extends { order: number }>(attachments: T[]): T[] {
|
||||
return [...attachments].sort((left, right) => left.order - right.order);
|
||||
}
|
||||
117
src/features/agent-attachments/core/domain/capabilities.ts
Normal file
117
src/features/agent-attachments/core/domain/capabilities.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { AgentAttachmentCapability, AgentAttachmentCapabilityTarget } from './types';
|
||||
|
||||
const DEFAULT_IMAGE_BYTES_PER_PROVIDER = 4 * 1024 * 1024;
|
||||
const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 1024 * 1024;
|
||||
const DEFAULT_FILE_BYTES_PER_PROVIDER = 4 * 1024 * 1024;
|
||||
|
||||
function supportedImagesOnly(displayText: string): AgentAttachmentCapability {
|
||||
return {
|
||||
supportsImages: true,
|
||||
supportsFiles: false,
|
||||
supportedImageMimeTypes: ['image/png', 'image/jpeg'],
|
||||
supportedFileMimeTypes: [],
|
||||
maxImages: 5,
|
||||
maxFiles: 0,
|
||||
maxBytesPerImage: DEFAULT_IMAGE_BYTES_PER_PROVIDER,
|
||||
maxBytesPerFile: 0,
|
||||
maxBytesTotal: DEFAULT_IMAGE_BYTES_TOTAL,
|
||||
reason: 'known_provider_support',
|
||||
displayText,
|
||||
filesDisplayText:
|
||||
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.',
|
||||
};
|
||||
}
|
||||
|
||||
function supportedClaude(displayText: string): AgentAttachmentCapability {
|
||||
return {
|
||||
...supportedImagesOnly(displayText),
|
||||
supportsFiles: true,
|
||||
supportedFileMimeTypes: ['application/pdf', 'text/*'],
|
||||
maxFiles: 5,
|
||||
maxBytesPerFile: DEFAULT_FILE_BYTES_PER_PROVIDER,
|
||||
filesDisplayText: 'Claude can receive text files and PDFs through structured document blocks.',
|
||||
};
|
||||
}
|
||||
|
||||
function unsupported(
|
||||
reason: AgentAttachmentCapability['reason'],
|
||||
displayText: string
|
||||
): AgentAttachmentCapability {
|
||||
return {
|
||||
supportsImages: false,
|
||||
supportsFiles: false,
|
||||
supportedImageMimeTypes: [],
|
||||
supportedFileMimeTypes: [],
|
||||
maxImages: 0,
|
||||
maxFiles: 0,
|
||||
maxBytesPerImage: 0,
|
||||
maxBytesPerFile: 0,
|
||||
maxBytesTotal: 0,
|
||||
reason,
|
||||
displayText,
|
||||
filesDisplayText:
|
||||
'Selected provider does not support non-image file attachments through this delivery path.',
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalizeOpenCodeModel(input: { providerId: string; model?: string | null }): {
|
||||
providerId: string;
|
||||
model: string;
|
||||
} {
|
||||
const providerId = input.providerId.trim().toLowerCase();
|
||||
const model = (input.model ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^openrouter\//, '')
|
||||
.replace(/^openai\//, '');
|
||||
return { providerId, model };
|
||||
}
|
||||
|
||||
export function resolveAgentAttachmentCapability(
|
||||
target: AgentAttachmentCapabilityTarget
|
||||
): AgentAttachmentCapability {
|
||||
const providerId = target.providerId.trim().toLowerCase();
|
||||
|
||||
if (providerId === 'anthropic') {
|
||||
return supportedClaude('Claude can receive image attachments through structured image blocks.');
|
||||
}
|
||||
|
||||
if (providerId === 'codex') {
|
||||
return supportedImagesOnly(
|
||||
'Codex can receive image attachments through the native image channel.'
|
||||
);
|
||||
}
|
||||
|
||||
if (providerId === 'opencode') {
|
||||
const { model } = canonicalizeOpenCodeModel(target);
|
||||
if (model === 'gpt-5.4-mini') {
|
||||
return {
|
||||
...supportedImagesOnly(
|
||||
'OpenCode model openai/gpt-5.4-mini is verified for image attachments.'
|
||||
),
|
||||
reason: 'known_vision_model',
|
||||
};
|
||||
}
|
||||
if (model === 'moonshotai/kimi-k2.6' || model === 'z-ai/glm-4.5v') {
|
||||
return {
|
||||
...supportedImagesOnly(`OpenCode model ${model} is verified for image attachments.`),
|
||||
reason: 'known_vision_model',
|
||||
};
|
||||
}
|
||||
if (model === 'z-ai/glm-5.1') {
|
||||
return unsupported(
|
||||
'known_non_vision_model',
|
||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||
);
|
||||
}
|
||||
return unsupported(
|
||||
'unknown_model',
|
||||
'This OpenCode model has unknown image support. Image delivery is blocked for reliability.'
|
||||
);
|
||||
}
|
||||
|
||||
return unsupported(
|
||||
'unsupported_provider',
|
||||
'Selected provider does not support image attachments through this delivery path.'
|
||||
);
|
||||
}
|
||||
30
src/features/agent-attachments/core/domain/errors.ts
Normal file
30
src/features/agent-attachments/core/domain/errors.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { AgentAttachmentErrorJson, AttachmentDeliveryFailureCode } from './types';
|
||||
|
||||
export class AgentAttachmentError extends Error {
|
||||
constructor(
|
||||
readonly code: AttachmentDeliveryFailureCode,
|
||||
message: string,
|
||||
readonly options: {
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
attachmentId?: string;
|
||||
retryable?: boolean;
|
||||
safeDetails?: Record<string, string | number | boolean | null>;
|
||||
} = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AgentAttachmentError';
|
||||
}
|
||||
|
||||
toJSON(): AgentAttachmentErrorJson {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
retryable: this.options.retryable ?? false,
|
||||
...(this.options.providerId ? { providerId: this.options.providerId } : {}),
|
||||
...(this.options.model ? { model: this.options.model } : {}),
|
||||
...(this.options.attachmentId ? { attachmentId: this.options.attachmentId } : {}),
|
||||
...(this.options.safeDetails ? { safeDetails: this.options.safeDetails } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
6
src/features/agent-attachments/core/domain/index.ts
Normal file
6
src/features/agent-attachments/core/domain/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './budgets';
|
||||
export * from './capabilities';
|
||||
export * from './errors';
|
||||
export * from './storageIds';
|
||||
export * from './types';
|
||||
export * from './validation';
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { assertSafeAttachmentStorageId, isSafeAttachmentStorageId } from './storageIds';
|
||||
|
||||
describe('agent attachment storage ids', () => {
|
||||
it('accepts compact stable ids', () => {
|
||||
expect(isSafeAttachmentStorageId('msg_abc-123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects traversal-like ids', () => {
|
||||
expect(() => assertSafeAttachmentStorageId('messageId', '../secret')).toThrow(
|
||||
/Invalid messageId/
|
||||
);
|
||||
expect(() => assertSafeAttachmentStorageId('attachmentId', 'a/b')).toThrow(
|
||||
/Invalid attachmentId/
|
||||
);
|
||||
});
|
||||
});
|
||||
11
src/features/agent-attachments/core/domain/storageIds.ts
Normal file
11
src/features/agent-attachments/core/domain/storageIds.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const SAFE_ATTACHMENT_STORAGE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,120}$/;
|
||||
|
||||
export function isSafeAttachmentStorageId(value: string): boolean {
|
||||
return SAFE_ATTACHMENT_STORAGE_ID_RE.test(value);
|
||||
}
|
||||
|
||||
export function assertSafeAttachmentStorageId(label: string, value: string): void {
|
||||
if (!isSafeAttachmentStorageId(value)) {
|
||||
throw new Error(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
125
src/features/agent-attachments/core/domain/types.ts
Normal file
125
src/features/agent-attachments/core/domain/types.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
export const AGENT_ATTACHMENT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type AgentAttachmentKind = 'image' | 'file' | 'unsupported';
|
||||
|
||||
export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
export type ProviderImageMimeType = 'image/png' | 'image/jpeg';
|
||||
export type ProviderFileMimeType = 'application/pdf' | 'text/*';
|
||||
|
||||
export type AttachmentDeliveryFailureCode =
|
||||
| 'attachment_too_large'
|
||||
| 'attachment_type_unsupported'
|
||||
| 'attachment_model_unsupported'
|
||||
| 'attachment_optimization_failed'
|
||||
| 'attachment_artifact_missing'
|
||||
| 'attachment_artifact_path_unsafe'
|
||||
| 'attachment_provider_rejected'
|
||||
| 'attachment_runtime_transport_failed';
|
||||
|
||||
export type AttachmentWarningCode =
|
||||
| 'image_was_resized'
|
||||
| 'image_was_reencoded'
|
||||
| 'image_quality_reduced'
|
||||
| 'model_support_unknown'
|
||||
| 'model_does_not_support_images'
|
||||
| 'file_type_not_supported';
|
||||
|
||||
export interface AttachmentWarning {
|
||||
code: AttachmentWarningCode;
|
||||
message: string;
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
export interface AgentAttachmentErrorJson {
|
||||
code: AttachmentDeliveryFailureCode;
|
||||
message: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
attachmentId?: string;
|
||||
retryable: boolean;
|
||||
safeDetails?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export interface AgentImageMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
animated?: boolean;
|
||||
optimizedWidth?: number;
|
||||
optimizedHeight?: number;
|
||||
optimization: 'none' | 'lossless' | 'resized' | 'jpeg-reencoded' | 'unsupported';
|
||||
}
|
||||
|
||||
export interface AgentAttachmentStorageReference {
|
||||
originalArtifactId?: string;
|
||||
optimizedArtifactId?: string;
|
||||
thumbnailArtifactId?: string;
|
||||
}
|
||||
|
||||
export interface AgentAttachmentPayload {
|
||||
schemaVersion: typeof AGENT_ATTACHMENT_SCHEMA_VERSION;
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
kind: AgentAttachmentKind;
|
||||
source: 'composer' | 'clipboard' | 'drag-drop' | 'task' | 'inbox';
|
||||
order: number;
|
||||
storage: AgentAttachmentStorageReference;
|
||||
image?: AgentImageMetadata;
|
||||
warnings: AttachmentWarning[];
|
||||
}
|
||||
|
||||
export interface ImageOptimizationBudget {
|
||||
maxInputBytes: number;
|
||||
maxInputPixels: number;
|
||||
maxOutputBytesPerImage: number;
|
||||
maxOutputBytesTotal: number;
|
||||
maxOutputEdge: number;
|
||||
jpegQualityAttempts: readonly number[];
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ImageBudgetAllocation {
|
||||
imageId: string;
|
||||
targetBytes: number;
|
||||
}
|
||||
|
||||
export type AgentAttachmentProviderId = 'anthropic' | 'codex' | 'opencode' | 'unknown';
|
||||
|
||||
export interface AgentAttachmentCapabilityTarget {
|
||||
providerId: AgentAttachmentProviderId | string;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentAttachmentCapability {
|
||||
supportsImages: boolean;
|
||||
supportsFiles: boolean;
|
||||
supportedImageMimeTypes: ProviderImageMimeType[];
|
||||
supportedFileMimeTypes: ProviderFileMimeType[];
|
||||
maxImages: number;
|
||||
maxFiles: number;
|
||||
maxBytesPerImage: number;
|
||||
maxBytesPerFile: number;
|
||||
maxBytesTotal: number;
|
||||
reason:
|
||||
| 'known_provider_support'
|
||||
| 'known_vision_model'
|
||||
| 'known_non_vision_model'
|
||||
| 'unknown_model'
|
||||
| 'unsupported_provider';
|
||||
displayText: string;
|
||||
filesDisplayText: string;
|
||||
}
|
||||
|
||||
export type AttachmentValidationResult =
|
||||
| { ok: true; warnings: AttachmentWarning[] }
|
||||
| {
|
||||
ok: false;
|
||||
code: AttachmentDeliveryFailureCode;
|
||||
message: string;
|
||||
warnings: AttachmentWarning[];
|
||||
};
|
||||
138
src/features/agent-attachments/core/domain/validation.test.ts
Normal file
138
src/features/agent-attachments/core/domain/validation.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { resolveAgentAttachmentCapability } from './capabilities';
|
||||
import { validateAttachmentForCapability, validateImageOptimizationInput } from './validation';
|
||||
|
||||
import type { AgentAttachmentPayload } from './types';
|
||||
|
||||
function fakeImageAttachment(
|
||||
overrides: Partial<AgentAttachmentPayload> = {}
|
||||
): AgentAttachmentPayload {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: 'att_1',
|
||||
originalName: 'red-square.png',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
kind: 'image',
|
||||
source: 'composer',
|
||||
order: 1,
|
||||
storage: { originalArtifactId: 'art_original_1', optimizedArtifactId: 'art_optimized_1' },
|
||||
image: { width: 64, height: 64, optimizedWidth: 64, optimizedHeight: 64, optimization: 'none' },
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent attachment validation', () => {
|
||||
it('accepts a small png optimization input', () => {
|
||||
expect(
|
||||
validateImageOptimizationInput({
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1000,
|
||||
width: 64,
|
||||
height: 64,
|
||||
})
|
||||
).toEqual({ ok: true, warnings: [] });
|
||||
});
|
||||
|
||||
it('rejects unsupported image optimization input', () => {
|
||||
const result = validateImageOptimizationInput({
|
||||
mimeType: 'image/gif',
|
||||
sizeBytes: 1000,
|
||||
width: 64,
|
||||
height: 64,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.code).toBe('attachment_type_unsupported');
|
||||
});
|
||||
|
||||
it('blocks known non-vision OpenCode models', () => {
|
||||
const capability = resolveAgentAttachmentCapability({
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/z-ai/glm-5.1',
|
||||
});
|
||||
const result = validateAttachmentForCapability({
|
||||
attachment: fakeImageAttachment(),
|
||||
capability,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.code).toBe('attachment_model_unsupported');
|
||||
});
|
||||
|
||||
it('allows known vision OpenCode models', () => {
|
||||
const capability = resolveAgentAttachmentCapability({
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
});
|
||||
expect(
|
||||
validateAttachmentForCapability({ attachment: fakeImageAttachment(), capability })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('allows Claude text file delivery through document blocks', () => {
|
||||
const capability = resolveAgentAttachmentCapability({
|
||||
providerId: 'anthropic',
|
||||
model: 'claude-haiku-4-5',
|
||||
});
|
||||
const result = validateAttachmentForCapability({
|
||||
attachment: fakeImageAttachment({
|
||||
id: 'att_text',
|
||||
originalName: 'notes.md',
|
||||
mimeType: 'text/markdown',
|
||||
sizeBytes: 128,
|
||||
kind: 'file',
|
||||
}),
|
||||
capability,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, warnings: [] });
|
||||
});
|
||||
|
||||
it('blocks non-image files for Codex native delivery', () => {
|
||||
const capability = resolveAgentAttachmentCapability({
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
});
|
||||
const result = validateAttachmentForCapability({
|
||||
attachment: fakeImageAttachment({
|
||||
id: 'att_pdf',
|
||||
originalName: 'spec.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: 1024,
|
||||
kind: 'file',
|
||||
}),
|
||||
capability,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('attachment_type_unsupported');
|
||||
expect(result.message).toContain('image attachments only');
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks non-image files for OpenCode even when the model supports images', () => {
|
||||
const capability = resolveAgentAttachmentCapability({
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
});
|
||||
const result = validateAttachmentForCapability({
|
||||
attachment: fakeImageAttachment({
|
||||
id: 'att_text',
|
||||
originalName: 'trace.txt',
|
||||
mimeType: 'text/plain',
|
||||
sizeBytes: 1024,
|
||||
kind: 'file',
|
||||
}),
|
||||
capability,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('attachment_type_unsupported');
|
||||
expect(result.message).toContain('image attachments only');
|
||||
}
|
||||
});
|
||||
});
|
||||
161
src/features/agent-attachments/core/domain/validation.ts
Normal file
161
src/features/agent-attachments/core/domain/validation.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from './budgets';
|
||||
|
||||
import type {
|
||||
AgentAttachmentCapability,
|
||||
AgentAttachmentKind,
|
||||
AgentAttachmentPayload,
|
||||
AgentImageMimeType,
|
||||
AttachmentValidationResult,
|
||||
ImageOptimizationBudget,
|
||||
ProviderImageMimeType,
|
||||
} from './types';
|
||||
|
||||
const AGENT_IMAGE_MIME_TYPES = new Set<AgentImageMimeType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const PROVIDER_IMAGE_MIME_TYPES = new Set<ProviderImageMimeType>(['image/png', 'image/jpeg']);
|
||||
|
||||
export function isAgentImageMimeType(mimeType: string): mimeType is AgentImageMimeType {
|
||||
return AGENT_IMAGE_MIME_TYPES.has(mimeType as AgentImageMimeType);
|
||||
}
|
||||
|
||||
export function isProviderImageMimeType(mimeType: string): mimeType is ProviderImageMimeType {
|
||||
return PROVIDER_IMAGE_MIME_TYPES.has(mimeType as ProviderImageMimeType);
|
||||
}
|
||||
|
||||
function isProviderFileMimeType(mimeType: string, supported: readonly string[]): boolean {
|
||||
return supported.some((candidate) =>
|
||||
candidate.endsWith('/*') ? mimeType.startsWith(candidate.slice(0, -1)) : candidate === mimeType
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind {
|
||||
if (isAgentImageMimeType(mimeType)) return 'image';
|
||||
if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) {
|
||||
return 'file';
|
||||
}
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
export function validateImageOptimizationInput(input: {
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
budget?: ImageOptimizationBudget;
|
||||
}): AttachmentValidationResult {
|
||||
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
||||
if (!isAgentImageMimeType(input.mimeType)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'This file type is not supported for agent image delivery.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
if (input.sizeBytes <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'Image file is empty.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
if (input.sizeBytes > budget.maxInputBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_too_large',
|
||||
message: 'Image is too large to prepare for sending.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
if (input.width * input.height > budget.maxInputPixels) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_too_large',
|
||||
message: 'Image dimensions are too large to prepare for sending.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
return { ok: true, warnings: [] };
|
||||
}
|
||||
|
||||
export function validateAttachmentForCapability(input: {
|
||||
attachment: AgentAttachmentPayload;
|
||||
capability: AgentAttachmentCapability;
|
||||
}): AttachmentValidationResult {
|
||||
const { attachment, capability } = input;
|
||||
const warnings = [...attachment.warnings];
|
||||
|
||||
if (attachment.kind !== 'image') {
|
||||
if (attachment.kind !== 'file') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'This attachment type is not supported by the selected provider.',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (!capability.supportsFiles) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: capability.filesDisplayText,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isProviderFileMimeType(attachment.mimeType, capability.supportedFileMimeTypes)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'This file type is not supported by the selected provider.',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (attachment.sizeBytes > capability.maxBytesPerFile) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_too_large',
|
||||
message: 'File is too large for the selected provider path.',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, warnings };
|
||||
}
|
||||
|
||||
if (!capability.supportsImages) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_model_unsupported',
|
||||
message: capability.displayText,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isProviderImageMimeType(attachment.mimeType)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'This image type is not supported by the selected provider.',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (attachment.sizeBytes > capability.maxBytesPerImage) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_too_large',
|
||||
message: 'Image is too large after optimization. Remove it or use a smaller image.',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, warnings };
|
||||
}
|
||||
1
src/features/agent-attachments/index.ts
Normal file
1
src/features/agent-attachments/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './contracts';
|
||||
5
src/features/agent-attachments/main/index.ts
Normal file
5
src/features/agent-attachments/main/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { AgentAttachmentError } from '../core/domain';
|
||||
export * from './infrastructure/attachmentArtifactStore';
|
||||
export * from './providers/claudeAttachmentAdapter';
|
||||
export * from './providers/codexNativeAttachmentAdapter';
|
||||
export * from './providers/opencodeAttachmentAdapter';
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { resolveAgentAttachmentArtifactPath, writeFileAtomic } from './attachmentArtifactStore';
|
||||
|
||||
describe('agent attachment artifact store helpers', () => {
|
||||
it('resolves paths under the managed attachment directory', () => {
|
||||
const root = path.join(os.tmpdir(), 'agent-attachments-test');
|
||||
const resolved = resolveAgentAttachmentArtifactPath({
|
||||
appDataPath: root,
|
||||
teamName: 'team_1',
|
||||
messageId: 'msg_1',
|
||||
attachmentId: 'att_1',
|
||||
fileName: 'optimized.png',
|
||||
});
|
||||
expect(resolved).toBe(
|
||||
path.join(root, 'attachments', 'team_1', 'msg_1', 'att_1', 'optimized.png')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe ids before path construction', () => {
|
||||
expect(() =>
|
||||
resolveAgentAttachmentArtifactPath({
|
||||
// eslint-disable-next-line sonarjs/publicly-writable-directories -- Unit test uses a fixed synthetic root and never writes to it.
|
||||
appDataPath: '/tmp/root',
|
||||
teamName: 'team_1',
|
||||
messageId: '../msg',
|
||||
attachmentId: 'att_1',
|
||||
fileName: 'optimized.png',
|
||||
})
|
||||
).toThrow(/Invalid messageId/);
|
||||
});
|
||||
|
||||
it('writes files atomically', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-attachments-'));
|
||||
const filePath = path.join(dir, 'file.txt');
|
||||
await writeFileAtomic(filePath, 'hello');
|
||||
await expect(fs.readFile(filePath, 'utf8')).resolves.toBe('hello');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { assertSafeAttachmentStorageId } from '@features/agent-attachments/core/domain';
|
||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export type AgentAttachmentArtifactFileName =
|
||||
| 'original.png'
|
||||
| 'original.jpg'
|
||||
| 'original.webp'
|
||||
| 'optimized.png'
|
||||
| 'optimized.jpg'
|
||||
| 'optimized.webp'
|
||||
| 'thumb.jpg'
|
||||
| 'meta.json';
|
||||
|
||||
export interface ResolveAgentAttachmentArtifactPathInput {
|
||||
teamName: string;
|
||||
messageId: string;
|
||||
attachmentId: string;
|
||||
fileName: AgentAttachmentArtifactFileName;
|
||||
appDataPath?: string;
|
||||
}
|
||||
|
||||
export function resolveAgentAttachmentArtifactPath(
|
||||
input: ResolveAgentAttachmentArtifactPathInput
|
||||
): string {
|
||||
assertSafeAttachmentStorageId('teamName', input.teamName);
|
||||
assertSafeAttachmentStorageId('messageId', input.messageId);
|
||||
assertSafeAttachmentStorageId('attachmentId', input.attachmentId);
|
||||
|
||||
const root = input.appDataPath ?? getAppDataPath();
|
||||
const base = path.resolve(
|
||||
root,
|
||||
'attachments',
|
||||
input.teamName,
|
||||
input.messageId,
|
||||
input.attachmentId
|
||||
);
|
||||
const resolved = path.resolve(base, input.fileName);
|
||||
if (!resolved.startsWith(base + path.sep)) {
|
||||
throw new Error('Attachment artifact path escaped managed directory');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function writeFileAtomic(filePath: string, bytes: Buffer | string): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(tmpPath, bytes);
|
||||
await fs.rename(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
await fs.rm(tmpPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import {
|
||||
buildClaudeAttachmentDeliveryParts,
|
||||
redactClaudeBlocksForDiagnostics,
|
||||
} from './claudeAttachmentAdapter';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
function attachment(overrides: Partial<AttachmentPayload> = {}): AttachmentPayload {
|
||||
return {
|
||||
id: 'att_1',
|
||||
filename: 'red.png',
|
||||
mimeType: 'image/png',
|
||||
size: 3,
|
||||
data: Buffer.from([1, 2, 3]).toString('base64'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Claude attachment adapter', () => {
|
||||
it('keeps text-only messages on the legacy text path', () => {
|
||||
expect(buildClaudeAttachmentDeliveryParts({ text: 'hello' })).toEqual({
|
||||
kind: 'legacy_text',
|
||||
blocks: [{ type: 'text', text: 'hello' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes png images as structured image blocks', () => {
|
||||
const result = buildClaudeAttachmentDeliveryParts({
|
||||
text: 'What color?',
|
||||
attachments: [attachment()],
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('structured_blocks');
|
||||
expect(result.blocks[0]).toEqual({ type: 'text', text: 'What color?' });
|
||||
expect(result.blocks[1]).toMatchObject({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png' },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes UTF-8 text files as text document blocks', () => {
|
||||
const result = buildClaudeAttachmentDeliveryParts({
|
||||
text: 'Read this',
|
||||
attachments: [
|
||||
attachment({
|
||||
filename: 'note.txt',
|
||||
mimeType: 'text/plain',
|
||||
data: Buffer.from('hello', 'utf8').toString('base64'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.blocks[1]).toEqual({
|
||||
type: 'document',
|
||||
source: { type: 'text', media_type: 'text/plain', data: 'hello' },
|
||||
title: 'note.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes text subtypes as text document blocks', () => {
|
||||
const result = buildClaudeAttachmentDeliveryParts({
|
||||
text: 'Read this',
|
||||
attachments: [
|
||||
attachment({
|
||||
filename: 'notes.md',
|
||||
mimeType: 'text/markdown',
|
||||
data: Buffer.from('# hello', 'utf8').toString('base64'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.blocks[1]).toEqual({
|
||||
type: 'document',
|
||||
source: { type: 'text', media_type: 'text/plain', data: '# hello' },
|
||||
title: 'notes.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported non-image files before provider send', () => {
|
||||
expect(() =>
|
||||
buildClaudeAttachmentDeliveryParts({
|
||||
text: 'read sheet',
|
||||
attachments: [attachment({ filename: 'sheet.xlsx', mimeType: 'application/vnd.ms-excel' })],
|
||||
})
|
||||
).toThrow(/Claude attachment MIME unsupported/);
|
||||
});
|
||||
|
||||
it('rejects unsupported image mime types before provider send', () => {
|
||||
expect(() =>
|
||||
buildClaudeAttachmentDeliveryParts({
|
||||
text: 'see gif',
|
||||
attachments: [attachment({ mimeType: 'image/gif' })],
|
||||
})
|
||||
).toThrow(/Claude attachment MIME unsupported/);
|
||||
});
|
||||
|
||||
it('redacts image and document bytes in diagnostics', () => {
|
||||
const result = buildClaudeAttachmentDeliveryParts({
|
||||
text: 'What color?',
|
||||
attachments: [
|
||||
attachment(),
|
||||
attachment({ id: 'att_2', filename: 'a.pdf', mimeType: 'application/pdf' }),
|
||||
],
|
||||
});
|
||||
|
||||
const redacted = redactClaudeBlocksForDiagnostics(result.blocks);
|
||||
expect(JSON.stringify(redacted)).not.toContain(attachment().data);
|
||||
expect(JSON.stringify(redacted)).toContain('[redacted image bytes: image/png]');
|
||||
expect(JSON.stringify(redacted)).toContain('[redacted document bytes: application/pdf]');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { AgentAttachmentError } from '@features/agent-attachments/core/domain';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
export type ClaudeInputBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'image';
|
||||
source: { type: 'base64'; media_type: string; data: string };
|
||||
}
|
||||
| {
|
||||
type: 'document';
|
||||
source:
|
||||
| { type: 'base64'; media_type: string; data: string }
|
||||
| { type: 'text'; media_type: 'text/plain'; data: string };
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export interface ClaudeAttachmentDeliveryParts {
|
||||
kind: 'legacy_text' | 'structured_blocks';
|
||||
blocks: ClaudeInputBlock[];
|
||||
}
|
||||
|
||||
function decodeBase64Text(data: string): { ok: true; text: string } | { ok: false } {
|
||||
const decoded = Buffer.from(data, 'base64').toString('utf-8');
|
||||
if (decoded.includes('\uFFFD')) return { ok: false };
|
||||
return { ok: true, text: decoded };
|
||||
}
|
||||
|
||||
export function buildClaudeAttachmentDeliveryParts(input: {
|
||||
text: string;
|
||||
attachments?: AttachmentPayload[];
|
||||
}): ClaudeAttachmentDeliveryParts {
|
||||
const contentBlocks: ClaudeInputBlock[] = [{ type: 'text', text: input.text }];
|
||||
const attachments = input.attachments ?? [];
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return { kind: 'legacy_text', blocks: contentBlocks };
|
||||
}
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.mimeType === 'application/pdf') {
|
||||
contentBlocks.push({
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: attachment.data,
|
||||
},
|
||||
title: attachment.filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attachment.mimeType === 'text/plain' || attachment.mimeType.startsWith('text/')) {
|
||||
const decoded = decodeBase64Text(attachment.data);
|
||||
contentBlocks.push(
|
||||
decoded.ok
|
||||
? {
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'text',
|
||||
media_type: 'text/plain',
|
||||
data: decoded.text,
|
||||
},
|
||||
title: attachment.filename,
|
||||
}
|
||||
: {
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'text/plain',
|
||||
data: attachment.data,
|
||||
},
|
||||
title: attachment.filename,
|
||||
}
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attachment.mimeType === 'image/png' || attachment.mimeType === 'image/jpeg') {
|
||||
contentBlocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
// Claude expects image bytes inside the structured image block as base64.
|
||||
// This is provider-native payload data, not text appended to the user prompt.
|
||||
type: 'base64',
|
||||
media_type: attachment.mimeType,
|
||||
data: attachment.data,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new AgentAttachmentError(
|
||||
'attachment_type_unsupported',
|
||||
`Claude attachment MIME unsupported: ${attachment.mimeType}`,
|
||||
{ attachmentId: attachment.id, retryable: false }
|
||||
);
|
||||
}
|
||||
|
||||
return { kind: 'structured_blocks', blocks: contentBlocks };
|
||||
}
|
||||
|
||||
export function redactClaudeBlocksForDiagnostics(blocks: ClaudeInputBlock[]): ClaudeInputBlock[] {
|
||||
return blocks.map((block) => {
|
||||
if (block.type === 'image') {
|
||||
return {
|
||||
...block,
|
||||
source: {
|
||||
...block.source,
|
||||
data: `[redacted image bytes: ${block.source.media_type}]`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (block.type === 'document' && block.source.type === 'base64') {
|
||||
return {
|
||||
...block,
|
||||
source: {
|
||||
...block.source,
|
||||
data: `[redacted document bytes: ${block.source.media_type}]`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return block;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
buildCodexNativeAttachmentDeliveryParts,
|
||||
redactCodexNativeAttachmentPartsForDiagnostics,
|
||||
} from './codexNativeAttachmentAdapter';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
function attachment(overrides: Partial<AttachmentPayload> = {}): AttachmentPayload {
|
||||
return {
|
||||
id: 'att_1',
|
||||
filename: 'red.png',
|
||||
mimeType: 'image/png',
|
||||
size: 3,
|
||||
data: Buffer.from([1, 2, 3]).toString('base64'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Codex native attachment adapter', () => {
|
||||
it('keeps text-only messages on the legacy text path', async () => {
|
||||
await expect(
|
||||
buildCodexNativeAttachmentDeliveryParts({
|
||||
teamName: 'team_1',
|
||||
messageId: 'msg_1',
|
||||
text: 'hello',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
kind: 'legacy_text',
|
||||
promptText: 'hello',
|
||||
imageParts: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('materializes image attachments as managed files for --image args', async () => {
|
||||
const appDataPath = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-attachments-'));
|
||||
const result = await buildCodexNativeAttachmentDeliveryParts({
|
||||
appDataPath,
|
||||
teamName: 'team_1',
|
||||
messageId: 'msg_1',
|
||||
text: 'What color?',
|
||||
attachments: [attachment()],
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('text_with_images');
|
||||
expect(result.imageParts).toHaveLength(1);
|
||||
expect(result.imageParts[0]).toMatchObject({
|
||||
kind: 'codex-image-arg',
|
||||
attachmentId: 'att_1',
|
||||
filename: 'red.png',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 3,
|
||||
});
|
||||
await expect(fs.readFile(result.imageParts[0].path)).resolves.toEqual(Buffer.from([1, 2, 3]));
|
||||
expect(result.diagnostics.join('\n')).not.toContain(attachment().data);
|
||||
});
|
||||
|
||||
it('preserves image order for multiple attachments', async () => {
|
||||
const appDataPath = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-attachments-'));
|
||||
const result = await buildCodexNativeAttachmentDeliveryParts({
|
||||
appDataPath,
|
||||
teamName: 'team_1',
|
||||
messageId: 'msg_1',
|
||||
text: 'Compare',
|
||||
attachments: [
|
||||
attachment({ id: 'att_1', filename: 'a.jpg', mimeType: 'image/jpeg' }),
|
||||
attachment({ id: 'att_2', filename: 'b.webp', mimeType: 'image/webp' }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.imageParts.map((part) => part.filename)).toEqual(['a.jpg', 'b.webp']);
|
||||
expect(result.imageParts.map((part) => part.mimeType)).toEqual(['image/jpeg', 'image/webp']);
|
||||
});
|
||||
|
||||
it('rejects non-image attachments before provider send', async () => {
|
||||
await expect(
|
||||
buildCodexNativeAttachmentDeliveryParts({
|
||||
teamName: 'team_1',
|
||||
messageId: 'msg_1',
|
||||
text: 'Read PDF',
|
||||
attachments: [attachment({ filename: 'a.pdf', mimeType: 'application/pdf' })],
|
||||
})
|
||||
).rejects.toThrow(/Codex native supports image attachments only/);
|
||||
});
|
||||
|
||||
it('redacts managed artifact paths from diagnostics', () => {
|
||||
const redacted = redactCodexNativeAttachmentPartsForDiagnostics([
|
||||
{
|
||||
kind: 'codex-image-arg',
|
||||
attachmentId: 'att_1',
|
||||
filename: 'red.png',
|
||||
mimeType: 'image/png',
|
||||
path: '/Users/me/.claude/attachments/team/msg/att/optimized.png',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(redacted[0].path).toBe('[managed attachment artifact: red.png]');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { AgentAttachmentError } from '@features/agent-attachments/core/domain';
|
||||
import {
|
||||
type AgentAttachmentArtifactFileName,
|
||||
resolveAgentAttachmentArtifactPath,
|
||||
writeFileAtomic,
|
||||
} from '@features/agent-attachments/main/infrastructure/attachmentArtifactStore';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
export type CodexNativeImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
|
||||
export interface CodexNativeImageArgPart {
|
||||
kind: 'codex-image-arg';
|
||||
attachmentId: string;
|
||||
filename: string;
|
||||
mimeType: CodexNativeImageMimeType;
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface CodexNativeAttachmentDeliveryParts {
|
||||
kind: 'legacy_text' | 'text_with_images';
|
||||
promptText: string;
|
||||
imageParts: CodexNativeImageArgPart[];
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface BuildCodexNativeAttachmentDeliveryPartsInput {
|
||||
teamName: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
attachments?: AttachmentPayload[];
|
||||
appDataPath?: string;
|
||||
}
|
||||
|
||||
function assertCodexImageMimeType(mimeType: string): asserts mimeType is CodexNativeImageMimeType {
|
||||
if (mimeType === 'image/png' || mimeType === 'image/jpeg' || mimeType === 'image/webp') {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AgentAttachmentError(
|
||||
'attachment_type_unsupported',
|
||||
`Codex native supports image attachments only; unsupported MIME: ${mimeType}`,
|
||||
{ retryable: false }
|
||||
);
|
||||
}
|
||||
|
||||
function codexArtifactFileName(
|
||||
mimeType: CodexNativeImageMimeType
|
||||
): AgentAttachmentArtifactFileName {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return 'optimized.png';
|
||||
case 'image/jpeg':
|
||||
return 'optimized.jpg';
|
||||
case 'image/webp':
|
||||
return 'optimized.webp';
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kib = bytes / 1024;
|
||||
if (kib < 1024) return `${kib.toFixed(1)} KB`;
|
||||
return `${(kib / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export async function buildCodexNativeAttachmentDeliveryParts(
|
||||
input: BuildCodexNativeAttachmentDeliveryPartsInput
|
||||
): Promise<CodexNativeAttachmentDeliveryParts> {
|
||||
const attachments = input.attachments ?? [];
|
||||
if (attachments.length === 0) {
|
||||
return {
|
||||
kind: 'legacy_text',
|
||||
promptText: input.text,
|
||||
imageParts: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
const imageParts: CodexNativeImageArgPart[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
assertCodexImageMimeType(attachment.mimeType);
|
||||
|
||||
const filePath = resolveAgentAttachmentArtifactPath({
|
||||
teamName: input.teamName,
|
||||
messageId: input.messageId,
|
||||
attachmentId: attachment.id,
|
||||
fileName: codexArtifactFileName(attachment.mimeType),
|
||||
appDataPath: input.appDataPath,
|
||||
});
|
||||
const bytes = Buffer.from(attachment.data, 'base64');
|
||||
await writeFileAtomic(filePath, bytes);
|
||||
|
||||
imageParts.push({
|
||||
kind: 'codex-image-arg',
|
||||
attachmentId: attachment.id,
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
path: filePath,
|
||||
sizeBytes: bytes.byteLength,
|
||||
});
|
||||
diagnostics.push(
|
||||
`prepared Codex native image ${attachment.filename} (${attachment.mimeType}, ${formatBytes(
|
||||
bytes.byteLength
|
||||
)})`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'text_with_images',
|
||||
promptText: input.text,
|
||||
imageParts,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function redactCodexNativeAttachmentPartsForDiagnostics(
|
||||
parts: CodexNativeImageArgPart[]
|
||||
): (Omit<CodexNativeImageArgPart, 'path'> & { path: string })[] {
|
||||
return parts.map((part) => ({
|
||||
...part,
|
||||
path: `[managed attachment artifact: ${part.filename}]`,
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import {
|
||||
buildOpenCodeAttachmentDeliveryParts,
|
||||
redactOpenCodeFilePartsForDiagnostics,
|
||||
} from './opencodeAttachmentAdapter';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
function attachment(overrides: Partial<AttachmentPayload> = {}): AttachmentPayload {
|
||||
return {
|
||||
id: 'att_1',
|
||||
filename: 'red.png',
|
||||
mimeType: 'image/png',
|
||||
size: 3,
|
||||
data: Buffer.from([1, 2, 3]).toString('base64'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('OpenCode attachment adapter', () => {
|
||||
it('keeps text-only messages on the legacy text path', () => {
|
||||
expect(
|
||||
buildOpenCodeAttachmentDeliveryParts({
|
||||
text: 'hello',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'legacy_text',
|
||||
text: 'hello',
|
||||
fileParts: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes verified OpenCode vision models as file parts', () => {
|
||||
const result = buildOpenCodeAttachmentDeliveryParts({
|
||||
text: 'What color?',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
attachments: [attachment()],
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('text_with_file_parts');
|
||||
expect(result.fileParts).toEqual([
|
||||
{
|
||||
type: 'file',
|
||||
mime: 'image/png',
|
||||
url: `data:image/png;base64,${attachment().data}`,
|
||||
filename: 'red.png',
|
||||
},
|
||||
]);
|
||||
expect(result.diagnostics.join('\n')).not.toContain(attachment().data);
|
||||
});
|
||||
|
||||
it('allows verified GLM 4.5V image delivery', () => {
|
||||
expect(() =>
|
||||
buildOpenCodeAttachmentDeliveryParts({
|
||||
text: 'What color?',
|
||||
model: 'openrouter/z-ai/glm-4.5v',
|
||||
attachments: [attachment()],
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('blocks known non-vision OpenCode models before runtime send', () => {
|
||||
expect(() =>
|
||||
buildOpenCodeAttachmentDeliveryParts({
|
||||
text: 'What color?',
|
||||
model: 'openrouter/z-ai/glm-5.1',
|
||||
attachments: [attachment()],
|
||||
})
|
||||
).toThrow(/not verified for image attachments/);
|
||||
});
|
||||
|
||||
it('blocks unknown OpenCode model image delivery by default', () => {
|
||||
expect(() =>
|
||||
buildOpenCodeAttachmentDeliveryParts({
|
||||
text: 'What color?',
|
||||
model: 'openrouter/example/new-model',
|
||||
attachments: [attachment()],
|
||||
})
|
||||
).toThrow(/unknown image support/);
|
||||
});
|
||||
|
||||
it('rejects non-image attachments before provider send', () => {
|
||||
expect(() =>
|
||||
buildOpenCodeAttachmentDeliveryParts({
|
||||
text: 'Read PDF',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
attachments: [attachment({ filename: 'a.pdf', mimeType: 'application/pdf' })],
|
||||
})
|
||||
).toThrow(/OpenCode currently supports image attachments only/);
|
||||
});
|
||||
|
||||
it('redacts data URLs from diagnostics', () => {
|
||||
const redacted = redactOpenCodeFilePartsForDiagnostics([
|
||||
{
|
||||
type: 'file',
|
||||
mime: 'image/png',
|
||||
url: `data:image/png;base64,${attachment().data}`,
|
||||
filename: 'red.png',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(redacted[0].url).toBe('[redacted data URL: image/png]');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import {
|
||||
AgentAttachmentError,
|
||||
resolveAgentAttachmentCapability,
|
||||
} from '@features/agent-attachments/core/domain';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
export type OpenCodeFilePartMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
|
||||
export interface OpenCodeFilePart {
|
||||
type: 'file';
|
||||
mime: OpenCodeFilePartMimeType;
|
||||
url: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeAttachmentDeliveryParts {
|
||||
kind: 'legacy_text' | 'text_with_file_parts';
|
||||
text: string;
|
||||
fileParts: OpenCodeFilePart[];
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface BuildOpenCodeAttachmentDeliveryPartsInput {
|
||||
text: string;
|
||||
model: string;
|
||||
attachments?: AttachmentPayload[];
|
||||
}
|
||||
|
||||
function assertOpenCodeImageMimeType(
|
||||
mimeType: string
|
||||
): asserts mimeType is OpenCodeFilePartMimeType {
|
||||
if (mimeType === 'image/png' || mimeType === 'image/jpeg' || mimeType === 'image/webp') {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AgentAttachmentError(
|
||||
'attachment_type_unsupported',
|
||||
`OpenCode currently supports image attachments only; unsupported MIME: ${mimeType}`,
|
||||
{ providerId: 'opencode', retryable: false }
|
||||
);
|
||||
}
|
||||
|
||||
function assertOpenCodeVisionCapability(model: string): void {
|
||||
const capability = resolveAgentAttachmentCapability({
|
||||
providerId: 'opencode',
|
||||
model,
|
||||
});
|
||||
if (capability.supportsImages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const code =
|
||||
capability.reason === 'known_non_vision_model' || capability.reason === 'unknown_model'
|
||||
? 'attachment_model_unsupported'
|
||||
: 'attachment_type_unsupported';
|
||||
throw new AgentAttachmentError(code, capability.displayText, {
|
||||
providerId: 'opencode',
|
||||
model,
|
||||
retryable: false,
|
||||
safeDetails: {
|
||||
reason: capability.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kib = bytes / 1024;
|
||||
if (kib < 1024) return `${kib.toFixed(1)} KB`;
|
||||
return `${(kib / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function buildOpenCodeAttachmentDeliveryParts(
|
||||
input: BuildOpenCodeAttachmentDeliveryPartsInput
|
||||
): OpenCodeAttachmentDeliveryParts {
|
||||
const attachments = input.attachments ?? [];
|
||||
if (attachments.length === 0) {
|
||||
return {
|
||||
kind: 'legacy_text',
|
||||
text: input.text,
|
||||
fileParts: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
assertOpenCodeVisionCapability(input.model);
|
||||
|
||||
const fileParts: OpenCodeFilePart[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
for (const attachment of attachments) {
|
||||
assertOpenCodeImageMimeType(attachment.mimeType);
|
||||
fileParts.push({
|
||||
type: 'file',
|
||||
mime: attachment.mimeType,
|
||||
url: `data:${attachment.mimeType};base64,${attachment.data}`,
|
||||
filename: attachment.filename,
|
||||
});
|
||||
diagnostics.push(
|
||||
`prepared OpenCode image file part ${attachment.filename} (${attachment.mimeType}, ${formatBytes(
|
||||
attachment.size
|
||||
)}) for ${input.model}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'text_with_file_parts',
|
||||
text: input.text,
|
||||
fileParts,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function redactOpenCodeFilePartsForDiagnostics(
|
||||
parts: OpenCodeFilePart[]
|
||||
): OpenCodeFilePart[] {
|
||||
return parts.map((part) => ({
|
||||
...part,
|
||||
url: `[redacted data URL: ${part.mime}]`,
|
||||
}));
|
||||
}
|
||||
3
src/features/agent-attachments/renderer/index.ts
Normal file
3
src/features/agent-attachments/renderer/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from '../core/domain';
|
||||
export { type AgentAttachmentCapability, resolveAgentAttachmentCapability } from '../core/domain';
|
||||
export * from './optimizeImageForAgent';
|
||||
170
src/features/agent-attachments/renderer/optimizeImageForAgent.ts
Normal file
170
src/features/agent-attachments/renderer/optimizeImageForAgent.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
type AttachmentWarning,
|
||||
DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET,
|
||||
type ImageDimensions,
|
||||
type ImageOptimizationBudget,
|
||||
planResizeDimensions,
|
||||
validateImageOptimizationInput,
|
||||
} from '@features/agent-attachments/core/domain';
|
||||
import createPica from 'pica';
|
||||
|
||||
export interface OptimizeImageForAgentInput {
|
||||
file: File;
|
||||
budget?: ImageOptimizationBudget;
|
||||
}
|
||||
|
||||
export interface OptimizeImageForAgentResult {
|
||||
original: {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
optimized: {
|
||||
blob: Blob;
|
||||
mimeType: 'image/png' | 'image/jpeg';
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
warnings: AttachmentWarning[];
|
||||
}
|
||||
|
||||
function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
mimeType: string,
|
||||
quality?: number
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) reject(new Error('Could not encode image canvas'));
|
||||
else resolve(blob);
|
||||
},
|
||||
mimeType,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function drawBitmapToCanvas(
|
||||
bitmap: ImageBitmap,
|
||||
dimensions: ImageDimensions
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = dimensions.width;
|
||||
canvas.height = dimensions.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Could not create image canvas context');
|
||||
context.drawImage(bitmap, 0, 0, dimensions.width, dimensions.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function resizeCanvas(
|
||||
source: HTMLCanvasElement,
|
||||
dimensions: ImageDimensions
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const target = document.createElement('canvas');
|
||||
target.width = dimensions.width;
|
||||
target.height = dimensions.height;
|
||||
const pica = createPica();
|
||||
await pica.resize(source, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
async function encodeJpegWithinBudget(
|
||||
canvas: HTMLCanvasElement,
|
||||
budget: ImageOptimizationBudget,
|
||||
targetBytes: number,
|
||||
warnings: AttachmentWarning[]
|
||||
): Promise<Blob> {
|
||||
for (const quality of budget.jpegQualityAttempts) {
|
||||
const blob = await canvasToBlob(canvas, 'image/jpeg', quality);
|
||||
if (blob.size <= targetBytes) {
|
||||
if (quality < budget.jpegQualityAttempts[0]) {
|
||||
warnings.push({
|
||||
code: 'image_quality_reduced',
|
||||
message: 'Image quality was reduced to fit the provider budget.',
|
||||
});
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
throw new Error('Image is too large after optimization. Remove it or use a smaller image.');
|
||||
}
|
||||
|
||||
export async function optimizeImageForAgent(
|
||||
input: OptimizeImageForAgentInput
|
||||
): Promise<OptimizeImageForAgentResult> {
|
||||
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
||||
const bitmap = await createImageBitmap(input.file);
|
||||
const originalDimensions = { width: bitmap.width, height: bitmap.height };
|
||||
const validation = validateImageOptimizationInput({
|
||||
mimeType: input.file.type,
|
||||
sizeBytes: input.file.size,
|
||||
width: originalDimensions.width,
|
||||
height: originalDimensions.height,
|
||||
budget,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
throw new Error(validation.message);
|
||||
}
|
||||
|
||||
const targetDimensions = planResizeDimensions(originalDimensions, {
|
||||
maxEdge: budget.maxOutputEdge,
|
||||
});
|
||||
const warnings: AttachmentWarning[] = [...validation.warnings];
|
||||
if (
|
||||
targetDimensions.width !== originalDimensions.width ||
|
||||
targetDimensions.height !== originalDimensions.height
|
||||
) {
|
||||
warnings.push({ code: 'image_was_resized', message: 'Image was resized before sending.' });
|
||||
}
|
||||
|
||||
const sourceCanvas = await drawBitmapToCanvas(bitmap, originalDimensions);
|
||||
const outputCanvas =
|
||||
targetDimensions.width === originalDimensions.width &&
|
||||
targetDimensions.height === originalDimensions.height
|
||||
? sourceCanvas
|
||||
: await resizeCanvas(sourceCanvas, targetDimensions);
|
||||
|
||||
let optimizedBlob: Blob;
|
||||
let optimizedMimeType: 'image/png' | 'image/jpeg';
|
||||
if (input.file.type === 'image/png') {
|
||||
optimizedBlob = await canvasToBlob(outputCanvas, 'image/png');
|
||||
optimizedMimeType = 'image/png';
|
||||
if (optimizedBlob.size > budget.maxOutputBytesPerImage) {
|
||||
throw new Error(
|
||||
'PNG image is too large after optimization. Use a smaller screenshot or JPEG image.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
optimizedBlob = await encodeJpegWithinBudget(
|
||||
outputCanvas,
|
||||
budget,
|
||||
budget.maxOutputBytesPerImage,
|
||||
warnings
|
||||
);
|
||||
optimizedMimeType = 'image/jpeg';
|
||||
if (input.file.type !== 'image/jpeg') {
|
||||
warnings.push({ code: 'image_was_reencoded', message: 'Image was converted to JPEG.' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
original: {
|
||||
blob: input.file,
|
||||
mimeType: input.file.type,
|
||||
sizeBytes: input.file.size,
|
||||
...originalDimensions,
|
||||
},
|
||||
optimized: {
|
||||
blob: optimizedBlob,
|
||||
mimeType: optimizedMimeType,
|
||||
sizeBytes: optimizedBlob.size,
|
||||
...targetDimensions,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
|
@ -25,11 +25,11 @@ import {
|
|||
} from '@shared/utils/idleNotificationSemantics';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import {
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||
|
||||
import {
|
||||
buildInlineActivityEntries,
|
||||
|
|
@ -80,7 +80,16 @@ export interface TeamGraphData extends TeamViewSnapshot {
|
|||
function toGraphLaunchVisualState(
|
||||
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
|
||||
): GraphNode['launchVisualState'] {
|
||||
return visualState === 'bootstrap_stalled' ? 'runtime_pending' : (visualState ?? undefined);
|
||||
if (!visualState) {
|
||||
return undefined;
|
||||
}
|
||||
if (visualState === 'bootstrap_stalled') {
|
||||
return 'runtime_pending';
|
||||
}
|
||||
if (visualState === 'starting_stale') {
|
||||
return 'spawning';
|
||||
}
|
||||
return visualState;
|
||||
}
|
||||
|
||||
export class TeamGraphAdapter {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue