feat(team): expand opencode review and release support

This commit is contained in:
777genius 2026-04-24 12:05:54 +03:00
parent ba4de3775b
commit 1c07e0fdb6
220 changed files with 24409 additions and 1665 deletions

6
.github/CLA.md vendored
View file

@ -1,6 +1,6 @@
# Contributor License Agreement (CLA) # Contributor License Agreement (CLA)
**Project:** Claude Agent Teams UI (claude-agent-teams-ui) **Project:** Agent Teams (agent-teams-ai)
**License:** GNU Affero General Public License v3.0 (AGPL-3.0) **License:** GNU Affero General Public License v3.0 (AGPL-3.0)
--- ---
@ -16,9 +16,9 @@ You represent that you have the right to grant the above license and that your c
## Signed contributors ## Signed contributors
| Name | Email | Date | | Name | Email | Date |
|------|-------|------| | ---------------- | ---------------------- | ---------- |
| Илия (777genius) | quantjumppro@gmail.com | 2026-02-22 | | Илия (777genius) | quantjumppro@gmail.com | 2026-02-22 |
--- ---
*To add yourself: submit a pull request that adds your name, email, and the current date to the table above. By merging the PR you confirm your agreement with this CLA.* _To add yourself: submit a pull request that adds your name, email, and the current date to the table above. By merging the PR you confirm your agreement with this CLA._

View file

@ -1,6 +1,6 @@
# Contributing # Contributing
Thanks for contributing to Claude Agent Teams UI! Thanks for contributing to Agent Teams!
## Before You Start ## Before You Start

8
.github/SECURITY.md vendored
View file

@ -2,7 +2,7 @@
## Network Activity ## Network Activity
Claude Agent Teams UI makes **zero** outbound network calls to third-party servers. There is no telemetry, analytics, tracking, or data exfiltration of any kind. Agent Teams makes **zero** outbound network calls to third-party servers. There is no telemetry, analytics, tracking, or data exfiltration of any kind.
| Network activity | When | Mode | User-initiated | | Network activity | When | Mode | User-initiated |
|---|---|---|---| |---|---|---|---|
@ -18,7 +18,7 @@ In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-update
- All session data is read **locally** from `~/.claude/` — it never leaves your machine. - All session data is read **locally** from `~/.claude/` — it never leaves your machine.
- The app does not write to session files. Volume mounts in Docker use `:ro` (read-only) by default. - The app does not write to session files. Volume mounts in Docker use `:ro` (read-only) by default.
- Configuration is stored at `~/.claude/claude-devtools-config.json` on the local filesystem. - Configuration is stored at `~/.claude/agent-teams-config.json` on the local filesystem.
- No data is sent to Anthropic, GitHub (other than the auto-updater in Electron mode), or any other third party. - No data is sent to Anthropic, GitHub (other than the auto-updater in Electron mode), or any other third party.
## Docker Network Isolation ## Docker Network Isolation
@ -26,8 +26,8 @@ In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-update
For maximum trust, run the Docker container with `--network none`: For maximum trust, run the Docker container with `--network none`:
```bash ```bash
docker build -t claude-agent-teams-ui -f docker/Dockerfile . docker build -t agent-teams-ai -f docker/Dockerfile .
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro agent-teams-ai
``` ```
Or with Docker Compose, uncomment `network_mode: "none"` in `docker/docker-compose.yml`. Or with Docker Compose, uncomment `network_mode: "none"` in `docker/docker-compose.yml`.

View file

@ -33,6 +33,8 @@ jobs:
working-directory: landing working-directory: landing
env: env:
NUXT_APP_BASE_URL: /claude_agent_teams_ui/ 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
run: npx nuxt generate run: npx nuxt generate
- uses: actions/configure-pages@v5 - uses: actions/configure-pages@v5

View file

@ -254,7 +254,7 @@ jobs:
run: ${{ matrix.dist_command }} --publish never run: ${{ matrix.dist_command }} --publish never
- name: Validate packaged bundle (macOS ${{ matrix.arch }}) - name: Validate packaged bundle (macOS ${{ matrix.arch }})
run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Claude Agent Teams UI.app" darwin ${{ matrix.arch }} run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin ${{ matrix.arch }}
- name: Upload assets to release - name: Upload assets to release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
@ -498,7 +498,7 @@ jobs:
declare -A FILES=( declare -A FILES=(
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg" ["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}.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-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe"
["Claude-Agent-Teams-UI.AppImage"]="Claude.Agent.Teams.UI-${VERSION}.AppImage" ["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-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb"

View file

@ -1,4 +1,4 @@
# Claude Agent Teams UI # Agent Teams
A new approach to task management with AI agent teams. Assemble agent teams with different roles that work autonomously in parallel, communicate with each other, create and manage their own tasks, review code, and collaborate across teams. You manage everything through a kanban board — like a CTO with an AI engineering team. A new approach to task management with AI agent teams. Assemble agent teams with different roles that work autonomously in parallel, communicate with each other, create and manage their own tasks, review code, and collaborate across teams. You manage everything through a kanban board — like a CTO with an AI engineering team.

View file

@ -3,14 +3,14 @@
<a href="docs/screenshots/7.png"><img src="docs/screenshots/7.png" width="75" alt="Code Review" /></a>&nbsp; <a href="docs/screenshots/7.png"><img src="docs/screenshots/7.png" width="75" alt="Code Review" /></a>&nbsp;
<a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="75" alt="Team View" /></a>&nbsp; <a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="75" alt="Team View" /></a>&nbsp;
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>&nbsp; <a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>&nbsp;
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="80" />&nbsp; <img src="resources/icons/png/1024x1024.png" alt="Agent Teams" width="80" />&nbsp;
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>&nbsp; <a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>&nbsp;
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.png" width="75" alt="Agent Comments" /></a>&nbsp; <a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.png" width="75" alt="Agent Comments" /></a>&nbsp;
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>&nbsp; <a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>&nbsp;
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a> <a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
</p> </p>
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams UI</a></h1> <h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams</a></h1>
<p align="center"> <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> <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>
@ -169,7 +169,7 @@ For feature architecture and implementation guidance:
## Comparison ## Comparison
| Feature | Agent Teams UI | Vibe Kanban | Aperant | Cursor | Claude Code CLI | | Feature | Agent Teams | Vibe Kanban | Aperant | Cursor | Claude Code CLI |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ | | **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ |
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) | | **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) |

View file

@ -1,11 +1,19 @@
const kanbanStore = require('./kanbanStore.js'); const kanbanStore = require('./kanbanStore.js');
const taskStore = require('./taskStore.js'); const taskStore = require('./taskStore.js');
const runtimeHelpers = require('./runtimeHelpers.js'); const runtimeHelpers = require('./runtimeHelpers.js');
const reviewStateHelpers = require('./reviewState.js');
const { withTeamBoardLock } = require('./boardLock.js'); const { withTeamBoardLock } = require('./boardLock.js');
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
const REVIEW_COLUMNS = new Set(['review', 'approved']);
const INVENTORY_KANBAN_COLUMNS = new Set(['review', 'approved']); const INVENTORY_KANBAN_COLUMNS = new Set(['review', 'approved']);
const MAX_MEMBER_ACTIONABLE_ITEMS = 50;
const MAX_MEMBER_AWARENESS_ITEMS = 30;
const MAX_LEAD_SECTION_ITEMS = 50;
const MAX_EXPANDED_CONTEXT_ITEMS = 8;
const MAX_DESCRIPTION_CHARS = 1200;
const MAX_COMMENT_CHARS = 500;
const MAX_SUBJECT_CHARS = 240;
const MAX_ANOMALY_ITEMS = 25;
const MAX_ANOMALY_DETAIL_CHARS = 500;
function normalizeName(value) { function normalizeName(value) {
return typeof value === 'string' && value.trim() ? value.trim() : ''; return typeof value === 'string' && value.trim() ? value.trim() : '';
@ -15,28 +23,17 @@ function normalizeKey(value) {
return normalizeName(value).toLowerCase(); return normalizeName(value).toLowerCase();
} }
function normalizeReviewState(value) {
const normalized = normalizeName(value);
return REVIEW_STATES.has(normalized) ? normalized : 'none';
}
function formatTaskLabel(task) { function formatTaskLabel(task) {
return `#${task.displayId || task.id}`; return `#${task.displayId || task.id}`;
} }
function isLeadCandidate(member) { function isLeadCandidate(member) {
if (!member || typeof member !== 'object') return false; return runtimeHelpers.isCanonicalLeadMember(member);
if (typeof member.agentType === 'string' && member.agentType.trim() === 'team-lead') {
return true;
}
if (typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) {
return true;
}
return normalizeKey(member.name) === 'team-lead';
} }
function buildQueueRoster(paths) { function buildQueueRoster(paths) {
const resolved = runtimeHelpers.resolveTeamMembers(paths); const resolved = runtimeHelpers.resolveTeamMembers(paths);
const explicit = runtimeHelpers.collectExplicitTeamMembers(paths);
const membersByKey = new Map(); const membersByKey = new Map();
for (const member of resolved.members || []) { for (const member of resolved.members || []) {
@ -61,6 +58,7 @@ function buildQueueRoster(paths) {
return { return {
membersByKey, membersByKey,
explicitMemberKeys: new Set(explicit.membersByKey.keys()),
removedNames: resolved.removedNames || new Set(), removedNames: resolved.removedNames || new Set(),
leadAliases, leadAliases,
leadCandidates: leadCandidates.map((member) => normalizeName(member.name)).filter(Boolean), leadCandidates: leadCandidates.map((member) => normalizeName(member.name)).filter(Boolean),
@ -69,6 +67,52 @@ function buildQueueRoster(paths) {
}; };
} }
function collectExplicitMemberKeys(paths) {
return new Set(runtimeHelpers.collectExplicitTeamMembers(paths).membersByKey.keys());
}
function isCurrentRuntimeMember(teamName, memberName) {
const requestedKey = normalizeKey(memberName);
if (!requestedKey) return false;
const runtimeIdentity = runtimeHelpers.getCurrentRuntimeMemberIdentity();
if (!runtimeIdentity) return false;
const runtimeAgentName = normalizeKey(runtimeIdentity.agentName);
const runtimeAgentId = normalizeKey(runtimeIdentity.agentId);
const runtimeTeamName = normalizeKey(runtimeIdentity.teamName);
const requestedAgentId = `${requestedKey}@${normalizeKey(teamName)}`;
return (
(runtimeAgentName === requestedKey || runtimeAgentId === requestedAgentId) &&
(!runtimeTeamName || runtimeTeamName === normalizeKey(teamName))
);
}
function validateBriefingMember(paths, teamName, memberName) {
const normalized = normalizeName(memberName);
const key = normalizeKey(normalized);
if (!key) {
throw new Error('Missing member name');
}
const roster = buildQueueRoster(paths);
if (roster.removedNames.has(key)) {
throw new Error(`Member is removed from the team: ${normalized}`);
}
const explicitMemberKeys = collectExplicitMemberKeys(paths);
if (explicitMemberKeys.has(key) || isCurrentRuntimeMember(teamName, normalized)) {
return { warnings: [] };
}
if (roster.membersByKey.has(key)) {
return {
warnings: [
`Member identity warning: ${normalized} is known only from inbox state, not team config/member metadata. Verify the member name before acting.`,
],
};
}
throw new Error(`Member not found in team metadata or inboxes: ${normalized}`);
}
function resolveQueueActor(value, roster) { function resolveQueueActor(value, roster) {
const normalized = normalizeName(value); const normalized = normalizeName(value);
if (!normalized) return null; if (!normalized) return null;
@ -84,6 +128,7 @@ function resolveQueueActor(value, roster) {
const member = roster.membersByKey.get(key); const member = roster.membersByKey.get(key);
if (!member) return null; if (!member) return null;
if (!roster.explicitMemberKeys || !roster.explicitMemberKeys.has(key)) return null;
if (roster.canonicalLeadName && normalizeKey(member.name) === normalizeKey(roster.canonicalLeadName)) { if (roster.canonicalLeadName && normalizeKey(member.name) === normalizeKey(roster.canonicalLeadName)) {
return { kind: 'lead', memberName: roster.canonicalLeadName }; return { kind: 'lead', memberName: roster.canonicalLeadName };
@ -98,56 +143,8 @@ function areSameActors(left, right) {
return normalizeKey(left.memberName) === normalizeKey(right.memberName); return normalizeKey(left.memberName) === normalizeKey(right.memberName);
} }
function resolveReviewStateFromHistory(task) {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i];
if (
event.type === 'review_requested' ||
event.type === 'review_changes_requested' ||
event.type === 'review_approved' ||
event.type === 'review_started'
) {
return {
state: normalizeReviewState(event.to),
source: `history_${event.type}`,
};
}
if (event.type === 'status_changed' && event.to === 'in_progress') {
return {
state: 'none',
source: 'history_status_reset',
};
}
}
return null;
}
function resolveEffectiveReviewState(task, kanbanEntry) { function resolveEffectiveReviewState(task, kanbanEntry) {
const historyState = resolveReviewStateFromHistory(task); return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry);
if (historyState) {
return historyState;
}
const persisted = normalizeReviewState(task.reviewState);
if (persisted !== 'none') {
return {
state: persisted,
source: 'task_review_state',
};
}
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
return {
state: normalizeReviewState(kanbanEntry.column),
source: 'kanban_column',
};
}
return {
state: 'none',
source: 'none',
};
} }
function resolveLegacyKanbanReviewer(task, roster, options = {}) { function resolveLegacyKanbanReviewer(task, roster, options = {}) {
@ -208,7 +205,10 @@ function resolveCurrentCycleReviewer(task, roster, options = {}) {
break; break;
} }
if (event.type === 'status_changed' && event.to === 'in_progress') { if (
event.type === 'status_changed' &&
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')
) {
break; break;
} }
@ -247,17 +247,51 @@ function buildBoardState(paths, teamName) {
const kanbanState = kanbanStore.readKanbanState(paths, teamName); const kanbanState = kanbanStore.readKanbanState(paths, teamName);
const roster = buildQueueRoster(paths); const roster = buildQueueRoster(paths);
const tasksById = new Map(taskRows.tasks.map((task) => [task.id, task])); const tasksById = new Map(taskRows.tasks.map((task) => [task.id, task]));
const anomalies = [];
if (kanbanState.tasks && typeof kanbanState.tasks === 'object') {
for (const [taskId, entry] of Object.entries(kanbanState.tasks)) {
if (!tasksById.has(taskId)) {
anomalies.push({
code: 'stale_kanban_task',
taskId,
detail: `Kanban ${entry && entry.column ? entry.column : 'entry'} references a missing or deleted task row.`,
});
}
}
}
if (kanbanState.columnOrder && typeof kanbanState.columnOrder === 'object') {
for (const [columnId, orderedTaskIds] of Object.entries(kanbanState.columnOrder)) {
if (!Array.isArray(orderedTaskIds)) continue;
for (const taskId of orderedTaskIds) {
const id = String(taskId);
const entry = kanbanState.tasks ? kanbanState.tasks[id] : undefined;
if (!tasksById.has(id) || !entry || entry.column !== columnId) {
anomalies.push({
code: 'stale_kanban_order',
taskId: id,
detail: `Kanban columnOrder.${columnId} references a task that is not in that column overlay.`,
});
}
}
}
}
for (const anomaly of taskRows.anomalies) {
anomalies.push({
code: anomaly.code,
detail: anomaly.detail,
...(anomaly.taskId ? { taskId: anomaly.taskId } : {}),
});
}
return { return {
tasks: [...taskRows.tasks].sort(compareTasksByFreshness), tasks: [...taskRows.tasks].sort(compareTasksByFreshness),
tasksById, tasksById,
kanbanState, kanbanState,
roster, roster,
anomalies: taskRows.anomalies.map((anomaly) => ({ anomalies,
code: anomaly.code,
detail: anomaly.detail,
...(anomaly.taskId ? { taskId: anomaly.taskId } : {}),
})),
}; };
} }
@ -298,6 +332,14 @@ function getLastMeaningfulEventAt(task) {
return timestamps[0] || undefined; return timestamps[0] || undefined;
} }
function truncateText(value, maxChars) {
const text = normalizeName(value);
if (!text || text.length <= maxChars) {
return text;
}
return `${text.slice(0, maxChars)}... [truncated]`;
}
function buildAgendaItem(task, boardState) { function buildAgendaItem(task, boardState) {
const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined; const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined;
const reviewStateResult = resolveEffectiveReviewState(task, kanbanEntry); const reviewStateResult = resolveEffectiveReviewState(task, kanbanEntry);
@ -427,6 +469,8 @@ function buildAgendaItem(task, boardState) {
const watchers = buildWatchers(ownerActor, reviewActor, actionOwner); const watchers = buildWatchers(ownerActor, reviewActor, actionOwner);
const lastMeaningfulEventAt = getLastMeaningfulEventAt(task);
return { return {
taskId: task.id, taskId: task.id,
displayId: task.displayId, displayId: task.displayId,
@ -447,7 +491,7 @@ function buildAgendaItem(task, boardState) {
...(blockedByIds.length > 0 ? { blockedBy: blockedByIds } : {}), ...(blockedByIds.length > 0 ? { blockedBy: blockedByIds } : {}),
...(watchers.length > 0 ? { watchers } : {}), ...(watchers.length > 0 ? { watchers } : {}),
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}), ...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
...(getLastMeaningfulEventAt(task) ? { lastMeaningfulEventAt: getLastMeaningfulEventAt(task) } : {}), ...(lastMeaningfulEventAt ? { lastMeaningfulEventAt } : {}),
derivedFrom, derivedFrom,
_fullTask: task, _fullTask: task,
}; };
@ -539,14 +583,17 @@ function buildAgendaSnapshot(paths, teamName, actor) {
}); });
} }
function buildInventoryRow(task, reviewState) { function buildInventoryRow(task, reviewState, kanbanEntry) {
return { return {
id: task.id, id: task.id,
displayId: task.displayId, displayId: task.displayId,
subject: task.subject, subject: truncateText(task.subject, MAX_SUBJECT_CHARS),
status: task.status, status: task.status,
...(normalizeName(task.owner) ? { owner: task.owner } : {}), ...(normalizeName(task.owner) ? { owner: task.owner } : {}),
reviewState, reviewState,
...(kanbanEntry && INVENTORY_KANBAN_COLUMNS.has(kanbanEntry.column)
? { kanbanColumn: kanbanEntry.column }
: {}),
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}), ...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
...(Array.isArray(task.blockedBy) && task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}), ...(Array.isArray(task.blockedBy) && task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
...(Array.isArray(task.blocks) && task.blocks.length > 0 ? { blocks: task.blocks } : {}), ...(Array.isArray(task.blocks) && task.blocks.length > 0 ? { blocks: task.blocks } : {}),
@ -569,7 +616,7 @@ function matchesInventoryFilters(row, filters) {
} }
if (normalizeName(filters.kanbanColumn)) { if (normalizeName(filters.kanbanColumn)) {
const kanbanColumn = filters.kanbanColumn; const kanbanColumn = filters.kanbanColumn;
if (!INVENTORY_KANBAN_COLUMNS.has(kanbanColumn) || row.reviewState !== kanbanColumn) { if (!INVENTORY_KANBAN_COLUMNS.has(kanbanColumn) || row.kanbanColumn !== kanbanColumn) {
return false; return false;
} }
} }
@ -590,7 +637,8 @@ function matchesInventoryFilters(row, filters) {
function listTaskInventory(paths, teamName, filters = {}) { function listTaskInventory(paths, teamName, filters = {}) {
return withTeamBoardLock(paths, () => { return withTeamBoardLock(paths, () => {
const boardState = buildBoardState(paths, teamName); const taskRows = taskStore.listTaskRows(paths);
const kanbanState = kanbanStore.readKanbanState(paths, teamName);
const resolvedRelatedTo = normalizeName(filters.relatedTo) const resolvedRelatedTo = normalizeName(filters.relatedTo)
? taskStore.resolveTaskRef(paths, filters.relatedTo) ? taskStore.resolveTaskRef(paths, filters.relatedTo)
: ''; : '';
@ -602,21 +650,42 @@ function listTaskInventory(paths, teamName, filters = {}) {
? Math.max(1, Math.floor(filters.limit)) ? Math.max(1, Math.floor(filters.limit))
: null; : null;
const rows = boardState.tasks const resolvedFilters = {
.map((task) => {
const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined;
const reviewState = resolveEffectiveReviewState(task, kanbanEntry).state;
return buildInventoryRow(task, reviewState);
})
.filter((row) =>
matchesInventoryFilters(row, {
...filters, ...filters,
...(resolvedRelatedTo ? { relatedTo: resolvedRelatedTo } : {}), ...(resolvedRelatedTo ? { relatedTo: resolvedRelatedTo } : {}),
...(resolvedBlockedBy ? { blockedBy: resolvedBlockedBy } : {}), ...(resolvedBlockedBy ? { blockedBy: resolvedBlockedBy } : {}),
}) };
); const candidates = [];
return limit == null ? rows : rows.slice(0, limit); const addCandidate = (candidate) => {
if (limit == null || candidates.length < limit) {
candidates.push(candidate);
return;
}
let oldestIndex = 0;
for (let index = 1; index < candidates.length; index += 1) {
if (compareTasksByFreshness(candidates[index].task, candidates[oldestIndex].task) > 0) {
oldestIndex = index;
}
}
if (compareTasksByFreshness(candidate.task, candidates[oldestIndex].task) < 0) {
candidates[oldestIndex] = candidate;
}
};
for (const task of taskRows.tasks) {
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
const reviewState = resolveEffectiveReviewState(task, kanbanEntry).state;
const row = buildInventoryRow(task, reviewState, kanbanEntry);
if (!matchesInventoryFilters(row, resolvedFilters)) {
continue;
}
addCandidate({ task, row });
}
return candidates.sort((left, right) => compareTasksByFreshness(left.task, right.task)).map((entry) => entry.row);
}); });
} }
@ -641,7 +710,7 @@ function formatAgendaLine(item) {
if (item.needsClarification) { if (item.needsClarification) {
meta.push(`clarification=${item.needsClarification}`); meta.push(`clarification=${item.needsClarification}`);
} }
return `- ${formatTaskLabel(item)} [status=${item.status}${reviewSuffix}] ${item.subject} (${meta.join(', ')})`; return `- ${formatTaskLabel(item)} [status=${item.status}${reviewSuffix}] ${truncateText(item.subject, MAX_SUBJECT_CHARS)} (${meta.join(', ')})`;
} }
function appendExpandedTaskContext(lines, item) { function appendExpandedTaskContext(lines, item) {
@ -649,7 +718,7 @@ function appendExpandedTaskContext(lines, item) {
if (!task || typeof task !== 'object') return; if (!task || typeof task !== 'object') return;
if (normalizeName(task.description)) { if (normalizeName(task.description)) {
lines.push(` Description: ${task.description}`); lines.push(` Description: ${truncateText(task.description, MAX_DESCRIPTION_CHARS)}`);
} }
const comments = Array.isArray(task.comments) ? task.comments : []; const comments = Array.isArray(task.comments) ? task.comments : [];
@ -658,17 +727,37 @@ function appendExpandedTaskContext(lines, item) {
lines.push(' Comments:'); lines.push(' Comments:');
for (const comment of comments.slice(-5)) { for (const comment of comments.slice(-5)) {
const author = normalizeName(comment && comment.author) || 'unknown'; const author = normalizeName(comment && comment.author) || 'unknown';
const text = normalizeName(comment && comment.text) || '(empty comment)'; const text = truncateText(comment && comment.text, MAX_COMMENT_CHARS) || '(empty comment)';
lines.push(` - ${author}: ${text}`); lines.push(` - ${author}: ${text}`);
} }
} }
function appendOmittedLine(lines, sectionLabel, shownCount, totalCount) {
if (totalCount <= shownCount) return;
lines.push(
`... ${totalCount - shownCount} more ${sectionLabel} item(s) omitted. Use task_list filters and task_get for drill-down.`
);
}
function formatAnomalyLine(anomaly) { function formatAnomalyLine(anomaly) {
const ref = normalizeName(anomaly.taskId) ? ` (${anomaly.taskId})` : ''; const ref = normalizeName(anomaly.taskId) ? ` (${anomaly.taskId})` : '';
return `- ${anomaly.code}${ref}: ${anomaly.detail}`; return `- ${anomaly.code}${ref}: ${truncateText(anomaly.detail, MAX_ANOMALY_DETAIL_CHARS)}`;
}
function appendAnomalies(lines, anomalies) {
const shown = anomalies.slice(0, MAX_ANOMALY_ITEMS);
for (const anomaly of shown) {
lines.push(formatAnomalyLine(anomaly));
}
if (anomalies.length > shown.length) {
lines.push(
`... ${anomalies.length - shown.length} more board anomaly item(s) omitted. Run maintenance/reconcile or inspect board files for full details.`
);
}
} }
function formatTaskBriefing(paths, teamName, memberName) { function formatTaskBriefing(paths, teamName, memberName) {
const memberValidation = validateBriefingMember(paths, teamName, memberName);
const snapshot = buildAgendaSnapshot(paths, teamName, { const snapshot = buildAgendaSnapshot(paths, teamName, {
kind: 'member', kind: 'member',
memberName: normalizeName(memberName), memberName: normalizeName(memberName),
@ -679,11 +768,12 @@ function formatTaskBriefing(paths, teamName, memberName) {
`Use task_list only to search/browse inventory rows, not as your working queue.`, `Use task_list only to search/browse inventory rows, not as your working queue.`,
]; ];
if (snapshot.anomalies.length > 0) { if (memberValidation.warnings.length > 0 || snapshot.anomalies.length > 0) {
lines.push('', 'Board warnings:'); lines.push('', 'Board warnings:');
for (const anomaly of snapshot.anomalies) { for (const warning of memberValidation.warnings) {
lines.push(formatAnomalyLine(anomaly)); lines.push(`- ${warning}`);
} }
appendAnomalies(lines, snapshot.anomalies);
} }
if (snapshot.actionable.length === 0 && snapshot.awareness.length === 0) { if (snapshot.actionable.length === 0 && snapshot.awareness.length === 0) {
@ -693,19 +783,29 @@ function formatTaskBriefing(paths, teamName, memberName) {
if (snapshot.actionable.length > 0) { if (snapshot.actionable.length > 0) {
lines.push('', 'Actionable:'); lines.push('', 'Actionable:');
for (const item of snapshot.actionable) { let expandedCount = 0;
const actionableItems = snapshot.actionable.slice(0, MAX_MEMBER_ACTIONABLE_ITEMS);
for (const item of actionableItems) {
lines.push(formatAgendaLine(item)); lines.push(formatAgendaLine(item));
if (item.status === 'in_progress' || item.reasonCode === 'needs_fix') { if (item.status === 'in_progress' || item.reasonCode === 'needs_fix') {
if (expandedCount < MAX_EXPANDED_CONTEXT_ITEMS) {
appendExpandedTaskContext(lines, item); appendExpandedTaskContext(lines, item);
expandedCount += 1;
} else {
lines.push(' Context omitted: use task_get for full task details.');
} }
} }
} }
appendOmittedLine(lines, 'Actionable', actionableItems.length, snapshot.actionable.length);
}
if (snapshot.awareness.length > 0) { if (snapshot.awareness.length > 0) {
lines.push('', 'Awareness:'); lines.push('', 'Awareness:');
for (const item of snapshot.awareness) { const awarenessItems = snapshot.awareness.slice(0, MAX_MEMBER_AWARENESS_ITEMS);
for (const item of awarenessItems) {
lines.push(formatAgendaLine(item)); lines.push(formatAgendaLine(item));
} }
appendOmittedLine(lines, 'Awareness', awarenessItems.length, snapshot.awareness.length);
} }
lines.push( lines.push(
@ -761,9 +861,7 @@ function formatLeadBriefing(paths, teamName) {
if (snapshot.anomalies.length > 0) { if (snapshot.anomalies.length > 0) {
lines.push('', 'Board anomalies:'); lines.push('', 'Board anomalies:');
for (const anomaly of snapshot.anomalies) { appendAnomalies(lines, snapshot.anomalies);
lines.push(formatAnomalyLine(anomaly));
}
} }
const sections = [ const sections = [
@ -787,9 +885,11 @@ function formatLeadBriefing(paths, teamName) {
if (!items || items.length === 0) continue; if (!items || items.length === 0) continue;
renderedAnySection = true; renderedAnySection = true;
lines.push('', title); lines.push('', title);
for (const item of items) { const sectionItems = items.slice(0, MAX_LEAD_SECTION_ITEMS);
for (const item of sectionItems) {
lines.push(formatAgendaLine(item)); lines.push(formatAgendaLine(item));
} }
appendOmittedLine(lines, title.replace(/:$/, ''), sectionItems.length, items.length);
} }
if (!renderedAnySection && snapshot.anomalies.length === 0) { if (!renderedAnySection && snapshot.anomalies.length === 0) {

View file

@ -1,14 +1,62 @@
const kanbanStore = require('./kanbanStore.js'); const kanbanStore = require('./kanbanStore.js');
const tasks = require('./tasks.js'); const tasks = require('./tasks.js');
const reviewStateHelpers = require('./reviewState.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const { withTeamBoardLock } = require('./boardLock.js'); const { withTeamBoardLock } = require('./boardLock.js');
function getEffectiveReviewState(context, task) {
const state = getKanbanState(context);
const entry = state.tasks ? state.tasks[task.id] : undefined;
return reviewStateHelpers.getEffectiveReviewState(task, entry).state;
}
function assertKanbanColumnAllowed(context, task, column, options = {}) {
const transition = typeof options.transition === 'string' ? options.transition : 'direct';
const label = `#${task.displayId || task.id}`;
if (task.status === 'deleted') {
throw new Error(`Task ${label} is deleted`);
}
if (task.status !== 'completed') {
throw new Error(`Task ${label} must be completed before moving to ${String(column).toUpperCase()} column`);
}
const reviewState = getEffectiveReviewState(context, task);
if (column === 'review') {
if (transition === 'request_review') {
if (reviewState === 'approved') {
throw new Error(`Task ${label} is already approved; reopen work before requesting another review`);
}
return;
}
if (reviewState !== 'review') {
throw new Error(`Task ${label} must be in review before moving to REVIEW column; use review_request first`);
}
return;
}
if (column === 'approved') {
if (transition === 'approve_review') {
if (reviewState !== 'review' && reviewState !== 'approved') {
throw new Error(`Task ${label} must be in review before approval`);
}
return;
}
if (reviewState !== 'approved') {
throw new Error(`Task ${label} must already be approved before repairing APPROVED column; use review_approve first`);
}
}
}
function getKanbanState(context) { function getKanbanState(context) {
return kanbanStore.readKanbanState(context.paths, context.teamName); return kanbanStore.readKanbanState(context.paths, context.teamName);
} }
function setKanbanColumn(context, taskId, column) { function setKanbanColumn(context, taskId, column, options = {}) {
return withTeamBoardLock(context.paths, () => { return withTeamBoardLock(context.paths, () => {
const canonicalTaskId = tasks.resolveTaskId(context, taskId); const canonicalTaskId = tasks.resolveTaskId(context, taskId);
const task = tasks.getTask(context, canonicalTaskId);
assertKanbanColumnAllowed(context, task, String(column), options);
kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column)); kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
return getKanbanState(context); return getKanbanState(context);
}); });
@ -17,6 +65,31 @@ function setKanbanColumn(context, taskId, column) {
function clearKanban(context, taskId, options) { function clearKanban(context, taskId, options) {
return withTeamBoardLock(context.paths, () => { return withTeamBoardLock(context.paths, () => {
const canonicalTaskId = tasks.resolveTaskId(context, taskId); const canonicalTaskId = tasks.resolveTaskId(context, taskId);
const task = tasks.getTask(context, canonicalTaskId);
const state = getKanbanState(context);
const hasTaskEntry = Boolean(state.tasks && state.tasks[canonicalTaskId]);
const hasColumnOrderRef =
state.columnOrder &&
typeof state.columnOrder === 'object' &&
Object.values(state.columnOrder).some(
(orderedTaskIds) =>
Array.isArray(orderedTaskIds) &&
orderedTaskIds.some((entry) => String(entry) === String(canonicalTaskId))
);
if (!hasTaskEntry && !hasColumnOrderRef) {
return state;
}
const transition = options && typeof options.transition === 'string' ? options.transition : 'direct';
const allowedInternalTransitions = new Set(['request_changes', 'rollback', 'status_reset', 'delete', 'restore']);
const reviewState = getEffectiveReviewState(context, task);
if (transition === 'direct' && reviewState !== 'none') {
throw new Error(
`Task #${task.displayId || task.id} is in reviewState=${reviewState}; use review tools or task status transitions instead of kanban_clear`
);
}
if (transition !== 'direct' && !allowedInternalTransitions.has(transition)) {
throw new Error(`Invalid kanban clear transition: ${transition}`);
}
kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options); kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options);
return getKanbanState(context); return getKanbanState(context);
}); });
@ -28,6 +101,9 @@ function listReviewers(context) {
function addReviewer(context, reviewer) { function addReviewer(context, reviewer) {
return withTeamBoardLock(context.paths, () => { return withTeamBoardLock(context.paths, () => {
runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
allowLeadAliases: true,
});
const state = getKanbanState(context); const state = getKanbanState(context);
const next = new Set(state.reviewers); const next = new Set(state.reviewers);
next.add(String(reviewer)); next.add(String(reviewer));

View file

@ -70,16 +70,56 @@ function writeKanbanState(paths, teamName, state) {
writeJson(paths.kanbanPath, sanitizeState(teamName, state)); writeJson(paths.kanbanPath, sanitizeState(teamName, state));
} }
function removeTaskFromColumnOrder(state, taskId) {
if (!state.columnOrder || typeof state.columnOrder !== 'object') {
return 0;
}
const cleaned = {};
let removed = 0;
for (const [columnId, orderedTaskIds] of Object.entries(state.columnOrder)) {
if (!Array.isArray(orderedTaskIds)) continue;
const nextIds = orderedTaskIds.filter((entry) => String(entry) !== String(taskId));
removed += orderedTaskIds.length - nextIds.length;
if (nextIds.length > 0) {
cleaned[columnId] = nextIds.map((entry) => String(entry));
}
}
state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined;
return removed;
}
function appendTaskToColumnOrder(state, column, taskId) {
if (!state.columnOrder || typeof state.columnOrder !== 'object') {
return;
}
const nextColumnOrder = { ...state.columnOrder };
const existing = Array.isArray(nextColumnOrder[column]) ? nextColumnOrder[column] : [];
nextColumnOrder[column] = existing
.map((entry) => String(entry))
.filter((entry) => entry !== String(taskId))
.concat([String(taskId)]);
state.columnOrder = nextColumnOrder;
}
function setKanbanColumn(paths, teamName, taskId, column) { function setKanbanColumn(paths, teamName, taskId, column) {
if (column !== 'review' && column !== 'approved') { if (column !== 'review' && column !== 'approved') {
throw new Error(`Invalid kanban column: ${String(column)}`); throw new Error(`Invalid kanban column: ${String(column)}`);
} }
const state = readKanbanState(paths, teamName); const state = readKanbanState(paths, teamName);
const hadColumnOrder = Boolean(state.columnOrder && Object.keys(state.columnOrder).length > 0);
removeTaskFromColumnOrder(state, taskId);
if (hadColumnOrder && !state.columnOrder) {
state.columnOrder = {};
}
state.tasks[String(taskId)] = state.tasks[String(taskId)] =
column === 'review' column === 'review'
? { column: 'review', reviewer: null, movedAt: nowIso() } ? { column: 'review', reviewer: null, movedAt: nowIso() }
: { column: 'approved', movedAt: nowIso() }; : { column: 'approved', movedAt: nowIso() };
appendTaskToColumnOrder(state, column, taskId);
writeKanbanState(paths, teamName, state); writeKanbanState(paths, teamName, state);
taskStore.updateTask(paths, String(taskId), (task) => ({ taskStore.updateTask(paths, String(taskId), (task) => ({
...task, ...task,
@ -91,6 +131,7 @@ function setKanbanColumn(paths, teamName, taskId, column) {
function clearKanban(paths, teamName, taskId, options = {}) { function clearKanban(paths, teamName, taskId, options = {}) {
const state = readKanbanState(paths, teamName); const state = readKanbanState(paths, teamName);
delete state.tasks[String(taskId)]; delete state.tasks[String(taskId)];
removeTaskFromColumnOrder(state, taskId);
writeKanbanState(paths, teamName, state); writeKanbanState(paths, teamName, state);
const nextReviewState = const nextReviewState =
typeof options.nextReviewState === 'string' ? options.nextReviewState : 'none'; typeof options.nextReviewState === 'string' ? options.nextReviewState : 'none';
@ -133,7 +174,10 @@ function garbageCollect(paths, teamName, validTaskIds) {
continue; continue;
} }
const validIds = orderedTaskIds.filter((taskId) => validTaskIds.has(String(taskId))); const validIds = orderedTaskIds.filter((taskId) => {
const id = String(taskId);
return validTaskIds.has(id) && state.tasks[id] && state.tasks[id].column === columnId;
});
staleColumnOrderRefsRemoved += orderedTaskIds.length - validIds.length; staleColumnOrderRefsRemoved += orderedTaskIds.length - validIds.length;
if (validIds.length > 0) { if (validIds.length > 0) {
cleaned[columnId] = validIds; cleaned[columnId] = validIds;

View file

@ -1,6 +1,7 @@
const kanban = require('./kanban.js'); const kanban = require('./kanban.js');
const messages = require('./messages.js'); const messages = require('./messages.js');
const runtimeHelpers = require('./runtimeHelpers.js'); const runtimeHelpers = require('./runtimeHelpers.js');
const reviewStateHelpers = require('./reviewState.js');
const tasks = require('./tasks.js'); const tasks = require('./tasks.js');
const { withTeamBoardLock } = require('./boardLock.js'); const { withTeamBoardLock } = require('./boardLock.js');
const { wrapAgentBlock } = require('./agentBlocks.js'); const { wrapAgentBlock } = require('./agentBlocks.js');
@ -26,18 +27,164 @@ function resolveLeadSessionId(context, flags) {
return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId); return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId);
} }
function getReviewStateFromHistory(task) {
const result = reviewStateHelpers.getReviewStateFromHistory(task);
return result ? result.state : null;
}
function getCurrentReviewState(task) { function getCurrentReviewState(task) {
return getReviewStateFromHistory(task) || 'none';
}
function getEffectiveReviewState(context, task) {
const state = kanban.getKanbanState(context);
const kanbanEntry = state.tasks ? state.tasks[task.id] : undefined;
return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry).state;
}
function getLatestReviewRequestedReviewer(task) {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let i = events.length - 1; i >= 0; i--) { for (let i = events.length - 1; i >= 0; i--) {
const e = events[i]; const e = events[i];
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved' || e.type === 'review_started') { if (e.type === 'review_requested') {
return e.to; return typeof e.reviewer === 'string' && e.reviewer.trim() ? e.reviewer.trim() : null;
} }
if (e.type === 'status_changed' && e.to === 'in_progress') { if (
return 'none'; e.type === 'review_changes_requested' ||
e.type === 'review_approved' ||
(e.type === 'status_changed' &&
(e.to === 'in_progress' || e.to === 'pending' || e.to === 'deleted')) ||
e.type === 'task_created'
) {
return null;
} }
} }
return 'none'; return null;
}
function normalizeActorKey(value) {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
}
function resolveKnownActorName(context, value, label) {
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
if (!actor) return null;
runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
allowLeadAliases: true,
});
return actor;
}
function tryResolveKnownActorName(context, value, label) {
try {
return resolveKnownActorName(context, value, label);
} catch {
return null;
}
}
function resolveActorIdentityKey(context, value) {
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
if (!actor) return '';
const resolved = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, actor, {
allowLeadAliases: true,
});
return normalizeActorKey(resolved || actor);
}
function isLeadActor(context, value) {
const key = normalizeActorKey(value);
const resolvedKey = resolveActorIdentityKey(context, value);
const leadKey = normalizeActorKey(runtimeHelpers.inferLeadName(context.paths));
return key === 'lead' || key === 'team-lead' || (leadKey && resolvedKey === leadKey);
}
function assertMatchesAssignedReviewer(context, task, actor, actionName) {
const assignedReviewer = getLatestReviewRequestedReviewer(task);
if (!assignedReviewer || isLeadActor(context, actor)) {
return;
}
const assignedKey = resolveActorIdentityKey(context, assignedReviewer);
const actorKey = resolveActorIdentityKey(context, actor);
if (assignedKey && actorKey && assignedKey !== actorKey) {
throw new Error(
`Task #${task.displayId || task.id} is assigned to reviewer ${assignedReviewer}; ${actor} cannot ${actionName}`
);
}
}
function getReviewStartActor(context, task, flags) {
if (typeof flags.from === 'string' && flags.from.trim()) {
const actor = resolveKnownActorName(context, flags.from, 'review actor');
assertMatchesAssignedReviewer(context, task, actor, 'start review');
return actor;
}
const requestedReviewer = getLatestReviewRequestedReviewer(task);
if (requestedReviewer) {
return resolveKnownActorName(context, requestedReviewer, 'reviewer');
}
const state = kanban.getKanbanState(context);
const kanbanEntry = state.tasks ? state.tasks[task.id] : undefined;
if (kanbanEntry && typeof kanbanEntry.reviewer === 'string' && kanbanEntry.reviewer.trim()) {
return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer');
}
throw new Error(`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`);
}
function getLatestReviewStartedActor(task) {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
if (e.type === 'review_started') {
return typeof e.actor === 'string' && e.actor.trim() ? e.actor.trim() : null;
}
if (
e.type === 'review_changes_requested' ||
e.type === 'review_approved' ||
(e.type === 'status_changed' &&
(e.to === 'in_progress' || e.to === 'pending' || e.to === 'deleted')) ||
e.type === 'task_created'
) {
return null;
}
}
return null;
}
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 inferredActor =
startedActor &&
(!assignedReviewer ||
resolveActorIdentityKey(context, startedActor) === resolveActorIdentityKey(context, assignedReviewer))
? startedActor
: assignedReviewer;
const actor =
explicit ||
inferredActor ||
resolveKnownActorName(context, 'team-lead', 'review actor');
assertMatchesAssignedReviewer(context, task, actor, actionName);
return actor;
}
function assertReviewTransitionAllowed(context, task, transitionName) {
if (task.status === 'deleted') {
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}`);
}
const reviewState = getEffectiveReviewState(context, task);
if (reviewState !== 'review') {
throw new Error(`Task #${task.displayId || task.id} must be in review before ${transitionName}`);
}
return reviewState;
} }
function getLatestReviewLifecycleEvent(task) { function getLatestReviewLifecycleEvent(task) {
@ -52,7 +199,10 @@ function getLatestReviewLifecycleEvent(task) {
) { ) {
return e; return e;
} }
if (e.type === 'status_changed' && e.to === 'in_progress') { if (
e.type === 'status_changed' &&
(e.to === 'in_progress' || e.to === 'pending' || e.to === 'deleted')
) {
return e; return e;
} }
if (e.type === 'task_created') { if (e.type === 'task_created') {
@ -69,17 +219,58 @@ function startReview(context, taskId, flags = {}) {
throw new Error(`Task #${task.displayId || task.id} is deleted`); throw new Error(`Task #${task.displayId || task.id} is deleted`);
} }
const from =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer';
const latestReviewEvent = getLatestReviewLifecycleEvent(task); const latestReviewEvent = getLatestReviewLifecycleEvent(task);
const prevReviewState = getCurrentReviewState(task); const prevReviewState = getEffectiveReviewState(context, task);
if (latestReviewEvent && latestReviewEvent.type === 'review_started') { if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
assertReviewTransitionAllowed(context, task, 'starting review');
const existingActor = typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
const existingActorValid = existingActor
? Boolean(runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { allowLeadAliases: true }))
: false;
const assignedReviewer = tryResolveKnownActorName(
context,
getLatestReviewRequestedReviewer(task),
'reviewer'
);
const existingMatchesAssigned =
!assignedReviewer ||
(existingActorValid &&
resolveActorIdentityKey(context, existingActor) === resolveActorIdentityKey(context, assignedReviewer));
const requestedActor =
typeof flags.from === 'string' && flags.from.trim()
? getReviewStartActor(context, task, flags)
: null;
if (
existingActorValid &&
existingMatchesAssigned &&
requestedActor &&
resolveActorIdentityKey(context, existingActor) !== resolveActorIdentityKey(context, requestedActor)
) {
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);
tasks.updateTask(context, task.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_started',
from: prevReviewState,
to: 'review',
actor: repairedActor,
});
t.reviewState = 'review';
return t;
});
}
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' }; return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
} }
assertReviewTransitionAllowed(context, task, 'starting review');
const from = getReviewStartActor(context, task, flags);
try { try {
kanban.setKanbanColumn(context, task.id, 'review'); kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
tasks.updateTask(context, task.id, (t) => { tasks.updateTask(context, task.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_started', type: 'review_started',
@ -93,7 +284,7 @@ function startReview(context, taskId, flags = {}) {
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' }; return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
} catch (error) { } catch (error) {
try { try {
kanban.clearKanban(context, task.id); kanban.clearKanban(context, task.id, { transition: 'rollback' });
} catch (rollbackError) { } catch (rollbackError) {
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError); warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError);
} }
@ -110,12 +301,16 @@ function requestReview(context, taskId, flags = {}) {
} }
const nextFrom = const nextFrom =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; resolveKnownActorName(context, flags.from, 'review requester') || 'team-lead';
const nextReviewer = getReviewer(context, flags); const rawReviewer = getReviewer(context, flags);
const prevReviewState = getCurrentReviewState(currentTask); const nextReviewer = rawReviewer ? (resolveKnownActorName(context, rawReviewer, 'reviewer'), rawReviewer) : 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`);
}
try { try {
kanban.setKanbanColumn(context, currentTask.id, 'review'); kanban.setKanbanColumn(context, currentTask.id, 'review', { transition: 'request_review' });
tasks.updateTask(context, currentTask.id, (t) => { tasks.updateTask(context, currentTask.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_requested', type: 'review_requested',
@ -129,7 +324,7 @@ function requestReview(context, taskId, flags = {}) {
}); });
} catch (error) { } catch (error) {
try { try {
kanban.clearKanban(context, currentTask.id); kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
} catch (rollbackError) { } catch (rollbackError) {
warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError); warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError);
} }
@ -158,9 +353,9 @@ function requestReview(context, taskId, flags = {}) {
`FIRST call review_start to signal you are beginning the review:\n` + `FIRST call review_start to signal you are beginning the review:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>" }\n\n` + `{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>" }\n\n` +
`When approved, use MCP tool review_approve:\n` + `When approved, use MCP tool review_approve:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", note?: "<optional note>", notifyOwner: true }\n\n` + `{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>", note?: "<optional note>", notifyOwner: true }\n\n` +
`If changes are needed, use MCP tool review_request_changes:\n` + `If changes are needed, use MCP tool review_request_changes:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }` `{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>", comment: "..." }`
), ),
summary: `Review request for #${task.displayId || task.id}`, summary: `Review request for #${task.displayId || task.id}`,
source: 'system_notification', source: 'system_notification',
@ -176,14 +371,21 @@ function requestReview(context, taskId, flags = {}) {
function approveReview(context, taskId, flags = {}) { function approveReview(context, taskId, flags = {}) {
const result = withTeamBoardLock(context.paths, () => { const result = withTeamBoardLock(context.paths, () => {
const currentTask = tasks.getTask(context, taskId); const currentTask = tasks.getTask(context, taskId);
const nextFrom = const nextFrom = getReviewDecisionActor(context, currentTask, flags, 'approve review');
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
const nextNote = const nextNote =
typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved'; typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
const suppressTaskComment = flags.suppressTaskComment === true; const suppressTaskComment = flags.suppressTaskComment === true;
const prevReviewState = getCurrentReviewState(currentTask); const prevReviewState = getEffectiveReviewState(context, currentTask);
if (currentTask.status === 'deleted') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} is deleted`);
}
if (prevReviewState === 'approved') { if (prevReviewState === 'approved') {
if (currentTask.status !== 'completed') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before approval`);
}
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
return { return {
alreadyApproved: true, alreadyApproved: true,
payload: { payload: {
@ -196,7 +398,9 @@ function approveReview(context, taskId, flags = {}) {
}; };
} }
kanban.setKanbanColumn(context, currentTask.id, 'approved'); assertReviewTransitionAllowed(context, currentTask, 'approval');
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
tasks.updateTask(context, currentTask.id, (t) => { tasks.updateTask(context, currentTask.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_approved', type: 'review_approved',
@ -263,13 +467,12 @@ function requestChanges(context, taskId, flags = {}) {
throw new Error(`No owner found for task ${String(taskId)}`); throw new Error(`No owner found for task ${String(taskId)}`);
} }
const nextFrom = const nextFrom = getReviewDecisionActor(context, currentTask, flags, 'request changes');
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
const nextComment = const nextComment =
typeof flags.comment === 'string' && flags.comment.trim() typeof flags.comment === 'string' && flags.comment.trim()
? flags.comment.trim() ? flags.comment.trim()
: 'Reviewer requested changes.'; : 'Reviewer requested changes.';
const prevReviewState = getCurrentReviewState(currentTask); const prevReviewState = assertReviewTransitionAllowed(context, currentTask, 'requesting changes');
tasks.updateTask(context, currentTask.id, (t) => { tasks.updateTask(context, currentTask.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
@ -283,7 +486,10 @@ function requestChanges(context, taskId, flags = {}) {
return t; return t;
}); });
kanban.clearKanban(context, currentTask.id, { nextReviewState: 'needsFix' }); kanban.clearKanban(context, currentTask.id, {
nextReviewState: 'needsFix',
transition: 'request_changes',
});
tasks.setTaskStatus(context, currentTask.id, 'pending', nextFrom); tasks.setTaskStatus(context, currentTask.id, 'pending', nextFrom);
tasks.addTaskComment(context, currentTask.id, { tasks.addTaskComment(context, currentTask.id, {
text: nextComment, text: nextComment,

View file

@ -0,0 +1,112 @@
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
const REVIEW_COLUMNS = new Set(['review', 'approved']);
const REVIEW_LIFECYCLE_EVENTS = new Set([
'review_requested',
'review_changes_requested',
'review_approved',
'review_started',
]);
const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']);
function normalizeReviewState(value) {
const normalized = typeof value === 'string' && value.trim() ? value.trim() : '';
return REVIEW_STATES.has(normalized) ? normalized : 'none';
}
function eventReviewState(event) {
if (!event || typeof event !== 'object' || !REVIEW_LIFECYCLE_EVENTS.has(event.type)) {
return null;
}
return normalizeReviewState(event.to);
}
function derivePendingReviewState(events, startIndex) {
for (let index = startIndex - 1; index >= 0; index -= 1) {
const event = events[index];
if (!event || typeof event !== 'object') continue;
const reviewState = eventReviewState(event);
if (reviewState) {
return reviewState === 'needsFix'
? { state: 'needsFix', source: 'history_pending_needs_fix' }
: { state: 'none', source: 'history_pending_reset' };
}
if (
event.type === 'task_created' ||
(event.type === 'status_changed' &&
(REVIEW_RESET_STATUSES.has(event.to) || event.to === 'pending'))
) {
return { state: 'none', source: 'history_pending_reset' };
}
}
return { state: 'none', source: 'history_pending_reset' };
}
function getReviewStateFromHistory(task) {
const events = Array.isArray(task && task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (!event || typeof event !== 'object') continue;
const reviewState = eventReviewState(event);
if (reviewState) {
return {
state: reviewState,
source: `history_${event.type}`,
};
}
if (event.type === 'status_changed') {
if (REVIEW_RESET_STATUSES.has(event.to)) {
return {
state: 'none',
source: 'history_status_reset',
};
}
if (event.to === 'pending') {
return derivePendingReviewState(events, index);
}
}
}
return null;
}
function getEffectiveReviewState(task, kanbanEntry) {
const historyState = getReviewStateFromHistory(task);
if (historyState) {
return historyState;
}
const persisted = normalizeReviewState(task && task.reviewState);
if (persisted !== 'none') {
return {
state: persisted,
source: 'task_review_state',
};
}
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
return {
state: normalizeReviewState(kanbanEntry.column),
source: 'kanban_column',
};
}
return {
state: 'none',
source: 'none',
};
}
module.exports = {
REVIEW_COLUMNS,
REVIEW_LIFECYCLE_EVENTS,
REVIEW_RESET_STATUSES,
REVIEW_STATES,
getEffectiveReviewState,
getReviewStateFromHistory,
normalizeReviewState,
};

View file

@ -124,14 +124,69 @@ function getPaths(flags, teamName) {
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath }; return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
} }
function isCanonicalLeadMember(member) {
if (!member || typeof member !== 'object') return false;
const agentType = typeof member.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
return (
agentType === 'team-lead' ||
name === 'team-lead' ||
role === 'team-lead' ||
role === 'team lead' ||
role === 'lead'
);
}
function normalizeMemberKey(value) {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
}
function collectExplicitTeamMembers(paths) {
const config = readTeamConfig(paths) || {};
const configMembers = Array.isArray(config.members) ? config.members : [];
const metaMembers = readMembersMeta(paths);
const membersByKey = new Map();
const removedNames = new Set();
for (const rawMember of configMembers) {
const normalized = normalizeMemberRecord(rawMember);
if (!normalized) continue;
membersByKey.set(normalizeMemberKey(normalized.name), normalized);
}
for (const rawMember of metaMembers) {
const normalized = normalizeMemberRecord(rawMember);
if (!normalized) continue;
const key = normalizeMemberKey(normalized.name);
if (normalized.removedAt != null) {
membersByKey.delete(key);
removedNames.add(key);
continue;
}
removedNames.delete(key);
membersByKey.set(key, mergeResolvedMember(membersByKey.get(key) || { name: normalized.name }, normalized));
}
return { membersByKey, removedNames };
}
function inferLeadName(paths) { function inferLeadName(paths) {
const resolved = resolveTeamMembers(paths); const resolved = resolveTeamMembers(paths);
const lead = resolved.members.find( const members = resolved.members || [];
const lead =
members.find(
(member) => (member) =>
member && member &&
((typeof member.agentType === 'string' && member.agentType === 'team-lead') || typeof member.agentType === 'string' &&
(typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) || member.agentType.trim().toLowerCase() === 'team-lead'
member.name === 'team-lead') ) ||
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
members.find(
(member) => {
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
return role === 'team-lead' || role === 'team lead' || role === 'lead';
}
); );
if (lead) { if (lead) {
return String(lead.name); return String(lead.name);
@ -143,6 +198,39 @@ function inferLeadName(paths) {
return 'team-lead'; return 'team-lead';
} }
function resolveExplicitTeamMemberName(paths, candidate, options = {}) {
const normalized = typeof candidate === 'string' && candidate.trim() ? candidate.trim() : '';
const key = normalizeMemberKey(normalized);
if (!key) return null;
const explicit = collectExplicitTeamMembers(paths);
if (explicit.removedNames.has(key)) return null;
const directMember = explicit.membersByKey.get(key);
if (directMember) {
return directMember.name;
}
if (options.allowLeadAliases !== false) {
const leadName = inferLeadName(paths);
const leadKey = normalizeMemberKey(leadName);
if (key === 'lead' || key === 'team-lead' || (leadKey && key === leadKey)) {
const leadMember = leadKey ? explicit.membersByKey.get(leadKey) : null;
return leadMember ? leadMember.name : null;
}
}
return null;
}
function assertExplicitTeamMemberName(paths, candidate, label = 'member', options = {}) {
const resolved = resolveExplicitTeamMemberName(paths, candidate, options);
if (!resolved) {
const value = typeof candidate === 'string' && candidate.trim() ? candidate.trim() : String(candidate || '');
throw new Error(`Unknown ${label}: ${value}. Use a configured team member name.`);
}
return resolved;
}
function readTeamConfig(paths) { function readTeamConfig(paths) {
return readJson(path.join(paths.teamDir, 'config.json'), null); return readJson(path.join(paths.teamDir, 'config.json'), null);
} }
@ -507,12 +595,16 @@ function saveTaskAttachmentFile(paths, taskId, flags) {
} }
module.exports = { module.exports = {
assertExplicitTeamMemberName,
collectExplicitTeamMembers,
getPaths, getPaths,
inferLeadName, inferLeadName,
isCanonicalLeadMember,
isProcessAlive, isProcessAlive,
listInboxMemberNames, listInboxMemberNames,
readMembersMeta, readMembersMeta,
readTeamConfig, readTeamConfig,
resolveExplicitTeamMemberName,
resolveTeamMembers, resolveTeamMembers,
getCurrentRuntimeMemberIdentity, getCurrentRuntimeMemberIdentity,
resolveCanonicalLeadSessionId, resolveCanonicalLeadSessionId,

View file

@ -1,9 +1,9 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const reviewStateHelpers = require('./reviewState.js');
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']); const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
const UUID_TASK_ID_PATTERN = const UUID_TASK_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@ -65,11 +65,16 @@ function normalizeTask(rawTask, filePath) {
reviewState: normalizeTaskReviewState(rawTask.reviewState), reviewState: normalizeTaskReviewState(rawTask.reviewState),
}; };
if (!TASK_STATUSES.has(String(task.status || '').trim())) {
throw new Error(`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`);
}
task.status = String(task.status).trim();
return task; return task;
} }
function normalizeTaskReviewState(value) { function normalizeTaskReviewState(value) {
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none'; return reviewStateHelpers.normalizeReviewState(value);
} }
function listTaskRows(paths, options = {}) { function listTaskRows(paths, options = {}) {
@ -82,7 +87,18 @@ function listTaskRows(paths, options = {}) {
for (const fileName of entries) { for (const fileName of entries) {
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
const filePath = path.join(paths.tasksDir, fileName); const filePath = path.join(paths.tasksDir, fileName);
const rawTask = readJson(filePath, null); let rawTask;
try {
rawTask = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
anomalies.push({
code: 'unreadable_task',
taskId: path.basename(fileName, '.json'),
filePath,
detail: error instanceof Error ? error.message : 'Unreadable task row',
});
continue;
}
if (!rawTask) continue; if (!rawTask) continue;
if (rawTask.metadata && rawTask.metadata._internal === true) continue; if (rawTask.metadata && rawTask.metadata._internal === true) continue;
try { try {
@ -410,7 +426,14 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
} }
return updateTask(paths, taskRef, (task) => { return updateTask(paths, taskRef, (task) => {
if (task.status === status) return task; if (task.status === status) {
if (status === 'deleted' || status === 'in_progress') {
task.reviewState = 'none';
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') {
task.reviewState = 'none';
}
return task;
}
const timestamp = nowIso(); const timestamp = nowIso();
const workIntervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : []; const workIntervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null; const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
@ -437,10 +460,17 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
if (status === 'deleted') { if (status === 'deleted') {
task.deletedAt = timestamp; task.deletedAt = timestamp;
task.reviewState = 'none';
} else if (task.deletedAt) { } else if (task.deletedAt) {
delete task.deletedAt; delete task.deletedAt;
} }
if (status === 'in_progress') {
task.reviewState = 'none';
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') {
task.reviewState = 'none';
}
return task; return task;
}); });
} }
@ -672,22 +702,7 @@ function compareTasksByFreshness(a, b) {
} }
function getEffectiveReviewState(kanbanEntry, task) { function getEffectiveReviewState(kanbanEntry, task) {
// Derive from historyEvents if available return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry).state;
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved' || e.type === 'review_started') {
return e.to;
}
if (e.type === 'status_changed' && e.to === 'in_progress') {
return 'none';
}
}
// Fallback to persisted reviewState or kanban
if (normalizeTaskReviewState(task.reviewState) !== 'none') {
return normalizeTaskReviewState(task.reviewState);
}
return kanbanEntry && kanbanEntry.column ? String(kanbanEntry.column) : 'none';
} }
function formatBriefTaskLine(task, reviewState) { function formatBriefTaskLine(task, reviewState) {

View file

@ -11,6 +11,22 @@ function normalizeActorName(value) {
return typeof value === 'string' && value.trim() ? value.trim() : ''; return typeof value === 'string' && value.trim() ? value.trim() : '';
} }
function isClearOwnerValue(value) {
return value == null || value === 'clear' || value === 'none';
}
function assertKnownTaskActor(context, value, label) {
return runtimeHelpers.assertExplicitTeamMemberName(context.paths, value, label, {
allowLeadAliases: true,
});
}
function assertTaskNotDeleted(task, action) {
if (task && task.status === 'deleted') {
throw new Error(`Task #${task.displayId || task.id} is deleted; use task_restore before ${action}`);
}
}
function isSameMember(left, right) { function isSameMember(left, right) {
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase(); return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
} }
@ -171,6 +187,9 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
} }
function createTask(context, input) { function createTask(context, input) {
if (input && typeof input.owner === 'string' && input.owner.trim()) {
assertKnownTaskActor(context, input.owner, 'task owner');
}
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input)); const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
if (input && input.notifyOwner !== false) { if (input && input.notifyOwner !== false) {
maybeNotifyAssignedOwner(context, task, { maybeNotifyAssignedOwner(context, task, {
@ -233,18 +252,46 @@ function resolveTaskId(context, taskRef) {
} }
function setTaskStatus(context, taskId, status, actor) { function setTaskStatus(context, taskId, status, actor) {
return withTeamBoardLock(context.paths, () => return withTeamBoardLock(context.paths, () => {
taskStore.setTaskStatus(context.paths, taskId, status, actor) const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
const normalizedStatus = String(status || '').trim();
if (before.status === 'deleted' && normalizedStatus !== 'deleted') {
throw new Error(`Task #${before.displayId || before.id} is deleted; use task_restore before changing status`);
}
let task = taskStore.setTaskStatus(context.paths, taskId, status, actor);
if (normalizedStatus === 'deleted' || normalizedStatus === 'in_progress' || normalizedStatus === 'pending') {
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
if (hasKanbanReference(state, task.id)) {
kanbanStore.clearKanban(context.paths, context.teamName, task.id, { nextReviewState: 'none' });
task = taskStore.readTask(context.paths, task.id, { includeDeleted: true });
}
}
return task;
});
}
function hasKanbanReference(state, taskId) {
if (state.tasks && state.tasks[taskId]) {
return true;
}
if (!state.columnOrder || typeof state.columnOrder !== 'object') {
return false;
}
return Object.values(state.columnOrder).some(
(orderedTaskIds) =>
Array.isArray(orderedTaskIds) && orderedTaskIds.some((entry) => String(entry) === String(taskId))
); );
} }
function startTask(context, taskId, actor) { function startTask(context, taskId, actor) {
return withTeamBoardLock(context.paths, () => { return withTeamBoardLock(context.paths, () => {
const task = taskStore.setTaskStatus(context.paths, taskId, 'in_progress', actor); const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
assertTaskNotDeleted(before, 'starting work');
let task = taskStore.setTaskStatus(context.paths, taskId, 'in_progress', actor);
const state = kanbanStore.readKanbanState(context.paths, context.teamName); const state = kanbanStore.readKanbanState(context.paths, context.teamName);
if (state.tasks[task.id]) { if (hasKanbanReference(state, task.id)) {
delete state.tasks[task.id]; kanbanStore.clearKanban(context.paths, context.teamName, task.id, { nextReviewState: 'none' });
kanbanStore.writeKanbanState(context.paths, context.teamName, state); task = taskStore.readTask(context.paths, task.id, { includeDeleted: true });
} }
return task; return task;
}); });
@ -322,17 +369,42 @@ function completeTask(context, taskId, actor) {
} }
function softDeleteTask(context, taskId, actor) { function softDeleteTask(context, taskId, actor) {
return setTaskStatus(context, taskId, 'deleted', actor); return withTeamBoardLock(context.paths, () => {
let task = taskStore.setTaskStatus(context.paths, taskId, 'deleted', actor);
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
if (hasKanbanReference(state, task.id)) {
kanbanStore.clearKanban(context.paths, context.teamName, task.id, { nextReviewState: 'none' });
task = taskStore.readTask(context.paths, task.id, { includeDeleted: true });
}
return task;
});
} }
function restoreTask(context, taskId, actor) { function restoreTask(context, taskId, actor) {
return setTaskStatus(context, taskId, 'pending', actor || 'user'); return withTeamBoardLock(context.paths, () => {
let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user');
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
if (hasKanbanReference(state, task.id)) {
kanbanStore.clearKanban(context.paths, context.teamName, task.id, { nextReviewState: 'none' });
task = taskStore.readTask(context.paths, task.id, { includeDeleted: true });
}
if (task.reviewState !== 'none') {
task = taskStore.updateTask(context.paths, task.id, (current) => {
current.reviewState = 'none';
return current;
});
}
return task;
});
} }
function setTaskOwner(context, taskId, owner) { function setTaskOwner(context, taskId, owner) {
const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => { const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true }); const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
const after = taskStore.setTaskOwner(context.paths, taskId, owner); const nextOwner = isClearOwnerValue(owner)
? owner
: (assertKnownTaskActor(context, owner, 'task owner'), owner);
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
return { return {
previousTask: before, previousTask: before,
updatedTask: after, updatedTask: after,
@ -553,10 +625,10 @@ function buildMemberTaskProtocol(teamName) {
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>" } { teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>" }
This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review. This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review.
4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: 4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true } { teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", note?: "<optional note>", notifyOwner: true }
CRITICAL: Text comments like "approved" or "LGTM" do NOT change the kanban board. You MUST call review_approve to move a task from REVIEW to APPROVED. Without the tool call the task stays stuck in the REVIEW column. CRITICAL: Text comments like "approved" or "LGTM" do NOT change the kanban board. You MUST call review_approve to move a task from REVIEW to APPROVED. Without the tool call the task stays stuck in the REVIEW column.
5. If review fails and changes are needed, use MCP tool review_request_changes: 5. If review fails and changes are needed, use MCP tool review_request_changes:
{ teamName: "${teamName}", taskId: "<taskId>", comment: "<what to fix>" } { teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", comment: "<what to fix>" }
6. NEVER skip status updates. A task is NOT done until completed status is written. 6. NEVER skip status updates. A task is NOT done until completed status is written.
- Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work. - Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work.
7. To reply to a comment on a task, use MCP tool task_add_comment: 7. To reply to a comment on a task, use MCP tool task_add_comment:

View file

@ -559,12 +559,14 @@ describe('agent-teams-controller API', () => {
expect(inbox[0].leadSessionId).toBe('lead-session-1'); expect(inbox[0].leadSessionId).toBe('lead-session-1');
}); });
it('starts review idempotently without requiring completed status', () => { it('starts review idempotently after review_request', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' }); const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
// startReview does not require completed status controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
const result = controller.review.startReview(task.id, { from: 'alice' }); const result = controller.review.startReview(task.id, { from: 'alice' });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.taskId).toBe(task.id); expect(result.taskId).toBe(task.id);
@ -582,7 +584,7 @@ describe('agent-teams-controller API', () => {
// Verify history event // Verify history event
const reviewEvent = updatedTask.historyEvents.find((e) => e.type === 'review_started'); const reviewEvent = updatedTask.historyEvents.find((e) => e.type === 'review_started');
expect(reviewEvent).toBeDefined(); expect(reviewEvent).toBeDefined();
expect(reviewEvent.from).toBe('none'); expect(reviewEvent.from).toBe('review');
expect(reviewEvent.to).toBe('review'); expect(reviewEvent.to).toBe('review');
expect(reviewEvent.actor).toBe('alice'); expect(reviewEvent.actor).toBe('alice');
@ -619,6 +621,148 @@ describe('agent-teams-controller API', () => {
expect(reviewerBriefing).toContain('reviewer=alice'); expect(reviewerBriefing).toContain('reviewer=alice');
}); });
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' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(task.id);
const reloaded = controller.tasks.getTask(task.id);
const startedEvent = reloaded.historyEvents.find((event) => event.type === 'review_started');
expect(startedEvent.actor).toBe('alice');
const reviewerBriefing = await controller.tasks.taskBriefing('alice');
expect(reviewerBriefing).toContain(`#${task.displayId}`);
expect(reviewerBriefing).toContain('reason=review_in_progress');
expect(reviewerBriefing).toContain('reviewer=alice');
});
it('rejects review terminal transitions outside active completed review tasks', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const pendingTask = controller.tasks.createTask({ subject: 'Pending task', owner: 'bob' });
expect(() => controller.review.approveReview(pendingTask.id, { from: 'alice' })).toThrow(
'must be completed before approval'
);
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' });
controller.tasks.softDeleteTask(deletedTask.id, 'bob');
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');
expect(controller.tasks.getTask(deletedTask.id).status).toBe('deleted');
});
it('rejects review_start outside active review and keeps owner routing intact', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
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' });
controller.tasks.completeTask(completedTask.id, 'bob');
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
'must be in review before starting review'
);
const bobBriefing = await controller.tasks.taskBriefing('bob');
expect(bobBriefing).toContain(`#${pendingTask.displayId}`);
expect(bobBriefing).toContain('actionOwner=@bob');
expect(bobBriefing).not.toContain('reason=review_in_progress');
});
it('rejects direct kanban lifecycle bypasses while allowing repair of matching review state', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
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' });
controller.tasks.completeTask(completedTask.id, 'bob');
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
'must be in review before moving to REVIEW column'
);
controller.review.requestReview(completedTask.id, { from: 'team-lead', reviewer: 'alice' });
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
const state = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
delete state.tasks[completedTask.id];
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
controller.kanban.setKanbanColumn(completedTask.id, 'review');
expect(controller.kanban.getKanbanState().tasks[completedTask.id].column).toBe('review');
});
it('rejects review_request for already approved tasks until work is reopened', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved terminal task', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
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.tasks.getTask(task.id).reviewState).toBe('approved');
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
});
it('repairs kanban on idempotent review transitions without duplicate history', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Repair review column', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(task.id, { from: 'alice' });
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
const reviewState = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
delete reviewState.tasks[task.id];
reviewState.columnOrder = { review: [] };
fs.writeFileSync(kanbanPath, JSON.stringify(reviewState, null, 2));
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')
).toHaveLength(1);
controller.review.approveReview(task.id, { from: 'alice' });
const approvedState = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
delete approvedState.tasks[task.id];
approvedState.columnOrder = { approved: [] };
fs.writeFileSync(kanbanPath, JSON.stringify(approvedState, null, 2));
const approvedAgain = controller.review.approveReview(task.id, { from: 'alice' });
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')
).toHaveLength(1);
});
it('throws when starting review on a deleted task', () => { it('throws when starting review on a deleted task', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
@ -628,6 +772,26 @@ describe('agent-teams-controller API', () => {
expect(() => controller.review.startReview(task.id, { from: 'alice' })).toThrow('is deleted'); expect(() => controller.review.startReview(task.id, { from: 'alice' })).toThrow('is deleted');
}); });
it('clears stale needsFix reviewState when owner restarts work', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Needs fix restart', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.requestChanges(task.id, { from: 'alice', comment: 'Please fix.' });
const started = controller.tasks.startTask(task.id, 'bob');
expect(started.status).toBe('in_progress');
expect(started.reviewState).toBe('none');
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
expect(controller.tasks.listTaskInventory({ owner: 'bob' })[0].reviewState).toBe('none');
const briefing = await controller.tasks.taskBriefing('bob');
expect(briefing).toContain('reason=owner_executing');
expect(briefing).not.toContain('reason=needs_fix');
});
it('persists full inbox metadata through controller messages.sendMessage', () => { it('persists full inbox metadata through controller messages.sendMessage', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
@ -919,6 +1083,357 @@ describe('agent-teams-controller API', () => {
expect(leadBriefing).not.toContain('review_reviewer_missing'); expect(leadBriefing).not.toContain('review_reviewer_missing');
}); });
it('does not treat role names containing lead as canonical team lead', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', role: 'tech lead' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
const aliceBriefing = await controller.tasks.taskBriefing('alice');
const leadBriefing = await controller.tasks.leadBriefing();
expect(aliceBriefing).toContain('Actionable:');
expect(aliceBriefing).toContain(`#${task.displayId}`);
expect(aliceBriefing).toContain('actionOwner=@alice');
expect(leadBriefing).not.toContain(`#${task.displayId}`);
});
it('rejects task_briefing for unknown members', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
await expect(controller.tasks.taskBriefing('bbo')).rejects.toThrow(
'Member not found in team metadata or inboxes: bbo'
);
});
it('warns when task_briefing member exists only because of inbox state', async () => {
const claudeDir = makeClaudeDir();
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(path.join(inboxDir, 'bbo.json'), '[]', 'utf8');
const controller = createController({ teamName: 'my-team', claudeDir });
const briefing = await controller.tasks.taskBriefing('bbo');
expect(briefing).toContain('Board warnings:');
expect(briefing).toContain(
'Member identity warning: bbo is known only from inbox state, not team config/member metadata. Verify the member name before acting.'
);
});
it('clears kanban tasks and column order when review tasks leave review', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Column cleanup', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.kanban.updateColumnOrder('review', [task.id]);
controller.review.requestChanges(task.id, { from: 'alice', comment: 'Needs work.' });
let kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.tasks[task.id]).toBeUndefined();
expect(kanbanState.columnOrder).toBeUndefined();
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.kanban.updateColumnOrder('review', [task.id]);
const deleted = controller.tasks.softDeleteTask(task.id, 'bob');
expect(deleted.status).toBe('deleted');
expect(deleted.reviewState).toBe('none');
kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.tasks[task.id]).toBeUndefined();
expect(kanbanState.columnOrder).toBeUndefined();
});
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' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.kanban.updateColumnOrder('review', [task.id]);
const deleted = controller.tasks.setTaskStatus(task.id, 'deleted', 'bob');
expect(deleted.status).toBe('deleted');
expect(deleted.reviewState).toBe('none');
const kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.tasks[task.id]).toBeUndefined();
expect(kanbanState.columnOrder).toBeUndefined();
});
it('surfaces unreadable task rows as board anomalies', async () => {
const claudeDir = makeClaudeDir();
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', 'broken.json'), '{ bad json', 'utf8');
const controller = createController({ teamName: 'my-team', claudeDir });
const leadBriefing = await controller.tasks.leadBriefing();
expect(leadBriefing).toContain('Board anomalies:');
expect(leadBriefing).toContain('unreadable_task (broken)');
expect(leadBriefing).toContain('anomalies=1');
});
it('caps large member briefings and points agents to drill-down tools', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
for (let i = 0; i < 60; i += 1) {
controller.tasks.createTask({
subject: `Large queue task ${i}`,
description: 'x'.repeat(3000),
owner: 'bob',
status: 'in_progress',
comments: Array.from({ length: 8 }, (_, index) => ({
id: `comment-${i}-${index}`,
author: 'bob',
text: 'y'.repeat(1000),
createdAt: new Date(Date.UTC(2026, 0, 1, 0, i, index)).toISOString(),
})),
notifyOwner: false,
});
}
const briefing = await controller.tasks.taskBriefing('bob');
const renderedTaskLines = briefing.split('\n').filter((line) => line.startsWith('- #'));
expect(renderedTaskLines.length).toBe(50);
expect(briefing).toContain('10 more Actionable item(s) omitted');
expect(briefing).toContain('Use task_list filters and task_get for drill-down.');
expect(briefing.length).toBeLessThan(100_000);
});
it('resets approved review state when work is reopened to pending', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved then reopened', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
const reopened = controller.tasks.setTaskStatus(task.id, 'pending', 'alice');
expect(reopened.status).toBe('pending');
expect(reopened.reviewState).toBe('none');
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' })).toHaveLength(0);
expect(controller.tasks.listTaskInventory({ owner: 'bob' })[0].reviewState).toBe('none');
const bobBriefing = await controller.tasks.taskBriefing('bob');
expect(bobBriefing).toContain(`#${task.displayId}`);
expect(bobBriefing).toContain('reason=owner_ready');
expect(bobBriefing).toContain('actionOwner=@bob');
});
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' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
expect(() => controller.kanban.clearKanban(task.id)).toThrow('reviewState=approved');
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
controller.tasks.setTaskStatus(task.id, 'pending', 'alice');
const noOpState = controller.kanban.clearKanban(task.id);
expect(noOpState.tasks[task.id]).toBeUndefined();
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
});
it('does not let inbox-only names become real owners or reviewers', async () => {
const claudeDir = makeClaudeDir();
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(path.join(inboxDir, 'boob.json'), '[]', 'utf8');
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'
);
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.owner = 'boob';
rawTask.status = 'pending';
rawTask.reviewState = 'none';
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
const leadBriefing = await controller.tasks.leadBriefing();
expect(leadBriefing).toContain(`#${task.displayId}`);
expect(leadBriefing).toContain('reason=owner_invalid');
expect(leadBriefing).toContain('Needs owner assignment:');
});
it('prevents deleted tasks from being resurrected by normal work tools', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Deleted work guard', owner: 'bob' });
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.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
'use task_restore before changing status'
);
const restored = controller.tasks.restoreTask(task.id, 'alice');
expect(restored.status).toBe('pending');
expect(restored.reviewState).toBe('none');
});
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved without overlay', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
const state = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
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({ kanbanColumn: 'approved' })).toHaveLength(0);
});
it('repairs an invalid review_started actor without losing the assigned reviewer', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Repair reviewer actor', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.historyEvents.push({
id: 'bad-review-start',
timestamp: '2026-01-01T00:00:00.000Z',
type: 'review_started',
from: 'review',
to: 'review',
actor: 'alicce',
});
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
controller.review.startReview(task.id, { from: 'alice' });
const startedEvents = controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_started');
expect(startedEvents.at(-1).actor).toBe('alice');
const reviewerBriefing = await controller.tasks.taskBriefing('alice');
expect(reviewerBriefing).toContain(`#${task.displayId}`);
expect(reviewerBriefing).toContain('reviewer=alice');
expect(reviewerBriefing).not.toContain('review_reviewer_missing');
});
it('repairs a valid but mismatched review_started actor back to the assigned reviewer', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
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' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.historyEvents.push({
id: 'wrong-review-start',
timestamp: '2026-01-01T00:00:00.000Z',
type: 'review_started',
from: 'review',
to: 'review',
actor: 'carol',
});
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');
expect(startedEvents.at(-1).actor).toBe('alice');
const aliceBriefing = await controller.tasks.taskBriefing('alice');
const carolBriefing = await controller.tasks.taskBriefing('carol');
expect(aliceBriefing).toContain(`#${task.displayId}`);
expect(aliceBriefing).toContain('reviewer=alice');
expect(carolBriefing).not.toContain('reason=review_in_progress');
});
it('bounds anomaly and subject rendering on primary queue surfaces', async () => {
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 kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
fs.writeFileSync(
kanbanPath,
JSON.stringify(
{
teamName: 'my-team',
reviewers: [],
tasks: {
missing: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z' },
},
columnOrder: { review: ['missing', task.id] },
},
null,
2
)
);
fs.writeFileSync(
path.join(claudeDir, 'tasks', 'my-team', 'bad-status.json'),
JSON.stringify({ id: 'bad-status', subject: 'Bad status', status: 'inprogress' }, null, 2),
'utf8'
);
for (let index = 0; index < 30; index += 1) {
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`), '{ bad json', 'utf8');
}
const briefing = await controller.tasks.leadBriefing();
expect(briefing).toContain('Board anomalies:');
expect(briefing).toContain('Invalid task status "inprogress"');
expect(briefing).toContain('stale_kanban_task (missing)');
expect(briefing).toContain('more board anomaly item(s) omitted');
expect(briefing).not.toContain('x'.repeat(1000));
const inventoryRow = controller.tasks.listTaskInventory({ owner: 'bob' })[0];
expect(inventoryRow.subject).toContain('[truncated]');
expect(inventoryRow.subject.length).toBeLessThan(300);
});
it('marks stale processes stopped during listing and supports unregister', () => { it('marks stale processes stopped during listing and supports unregister', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -156,7 +156,7 @@
"version": "1.0.0", "version": "1.0.0",
}, },
"landing": { "landing": {
"name": "claude-agent-teams-landing", "name": "agent-teams-landing",
"dependencies": { "dependencies": {
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@nuxtjs/i18n": "^9.5.6", "@nuxtjs/i18n": "^9.5.6",
@ -1780,7 +1780,7 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"claude-agent-teams-landing": ["claude-agent-teams-landing@workspace:landing"], "agent-teams-landing": ["agent-teams-landing@workspace:landing"],
"clean-regexp": ["clean-regexp@1.0.0", "", { "dependencies": { "escape-string-regexp": "1.0.5" } }, "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw=="], "clean-regexp": ["clean-regexp@1.0.0", "", { "dependencies": { "escape-string-regexp": "1.0.5" } }, "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw=="],

View file

@ -1,11 +1,11 @@
# ============================================================================= # =============================================================================
# Claude Agent Teams UI standalone Docker image # Agent Teams standalone Docker image
# #
# Runs the HTTP server without Electron, serving the full UI over HTTP. # Runs the HTTP server without Electron, serving the full UI over HTTP.
# Mount your ~/.claude directory to make session data available. # Mount your ~/.claude directory to make session data available.
# #
# Build: docker build -t claude-agent-teams-ui . # Build: docker build -t agent-teams-ai .
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui # Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro agent-teams-ai
# ============================================================================= # =============================================================================
FROM node:20-slim AS builder FROM node:20-slim AS builder

View file

@ -1,5 +1,5 @@
# ============================================================================= # =============================================================================
# Claude Agent Teams UI — Docker Compose # Agent Teams - Docker Compose
# #
# Quick start: # Quick start:
# docker compose -f docker/docker-compose.yml up # docker compose -f docker/docker-compose.yml up
@ -13,7 +13,7 @@
# ============================================================================= # =============================================================================
services: services:
claude-agent-teams-ui: agent-teams-ai:
build: build:
context: .. context: ..
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile

View file

@ -10,13 +10,13 @@ Minor release: React 19 + Electron 40 migration, start-task-by-user, auth troubl
## Published: v1.0.0 (2026-03-19) ## Published: v1.0.0 (2026-03-19)
Initial release: Claude Agent Teams UI with reliable CLI detection in packaged builds (shell PATH/HOME, `CLAUDE_CONFIG_DIR`, auth output parsing), IPC status cache handling, concurrent binary resolution, capped NDJSON diagnostics. Full list: [CHANGELOG.md](./CHANGELOG.md). Initial release: Agent Teams with reliable CLI detection in packaged builds (shell PATH/HOME, `CLAUDE_CONFIG_DIR`, auth output parsing), IPC status cache handling, concurrent binary resolution, capped NDJSON diagnostics. Full list: [CHANGELOG.md](./CHANGELOG.md).
After CI uploads artifacts, optional notes update: After CI uploads artifacts, optional notes update:
```bash ```bash
gh release edit v1.0.0 --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF' gh release edit v1.0.0 --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF'
## Claude Agent Teams UI v1.0.0 ## Agent Teams v1.0.0
First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging. First stable build: CLI/auth reliability in packaged apps, IPC hardening, and platform packaging.
@ -121,7 +121,7 @@ EOF
## Release Notes Template ## Release Notes Template
```markdown ```markdown
## Claude Agent Teams UI v<VERSION> ## Agent Teams v<VERSION>
<1-2 sentence summary of the release> <1-2 sentence summary of the release>
@ -144,7 +144,7 @@ EOF
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" /> <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> </a>
<br /> <br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<VERSION>.dmg"> <a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude.Agent.Teams.UI-<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" /> <img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
</a> </a>
</td> </td>
@ -197,9 +197,9 @@ electron-builder generates these artifacts per platform:
| Platform | Versioned Name | Stable Name (for /latest/download) | | 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 arm64 DMG | `Claude.Agent.Teams.UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
| macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>.dmg` | `Claude-Agent-Teams-UI-x64.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 arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | - |
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-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` | | 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 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 deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |

View file

@ -1,6 +1,6 @@
# Team Management Feature # Team Management Feature
Интерфейс для управления командами тиммейтов Claude Code внутри Claude Agent Teams UI (Electron). Интерфейс для управления командами тиммейтов Claude Code внутри Agent Teams (Electron).
## Что делает ## Что делает

File diff suppressed because it is too large Load diff

View file

@ -33,7 +33,7 @@ claude # Новая независимая сессия
--- ---
## ЧАСТЬ 2: Существующая инфраструктура в Claude Agent Teams UI ## ЧАСТЬ 2: Существующая инфраструктура в Agent Teams
### Уже реализовано (можно переиспользовать) ### Уже реализовано (можно переиспользовать)

View file

@ -51,7 +51,7 @@ const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
org: process.env.SENTRY_ORG ?? 'quant-jump-pro', org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
project: process.env.SENTRY_PROJECT ?? 'electron', project: process.env.SENTRY_PROJECT ?? 'electron',
authToken: process.env.SENTRY_AUTH_TOKEN, authToken: process.env.SENTRY_AUTH_TOKEN,
release: { name: `claude-agent-teams-ui@${pkg.version}` }, release: { name: `agent-teams-ai@${pkg.version}` },
sourcemaps: { sourcemaps: {
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'], filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'],
}, },

View file

@ -1,4 +1,4 @@
# Claude Agent Teams Landing # Agent Teams Landing
## Quick start ## Quick start

View file

@ -6,12 +6,12 @@ const { baseURL } = useRuntimeConfig().app;
<NuxtLink to="/" class="app-logo"> <NuxtLink to="/" class="app-logo">
<img <img
:src="`${baseURL}logo-192.png`" :src="`${baseURL}logo-192.png`"
alt="Claude Agent Teams" alt="Agent Teams"
class="app-logo__img" class="app-logo__img"
width="36" width="36"
height="36" height="36"
/> />
<span class="app-logo__text">Claude Agent Teams</span> <span class="app-logo__text">Agent Teams</span>
</NuxtLink> </NuxtLink>
</template> </template>

View file

@ -1,16 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
const { t } = useI18n(); const { t } = useI18n();
const { repoUrl } = useGithubRepo();
const year = new Date().getFullYear(); const year = new Date().getFullYear();
</script> </script>
<template> <template>
<footer class="app-footer"> <footer class="app-footer">
<v-container class="app-footer__inner"> <v-container class="app-footer__inner">
<span class="app-footer__copy">{{ t("footer.copyright", { year }) }} · {{ t("footer.tagline") }}</span> <span class="app-footer__copy"
>{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span
>
<div class="app-footer__links"> <div class="app-footer__links">
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a> <a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
<span class="app-footer__divider" /> <span class="app-footer__divider" />
<a class="app-footer__link" href="https://github.com/777genius/claude_agent_teams_ui" target="_blank">GitHub</a> <a class="app-footer__link" :href="repoUrl" target="_blank">GitHub</a>
</div> </div>
</v-container> </v-container>
</footer> </footer>
@ -31,7 +34,7 @@ const year = new Date().getFullYear();
.app-footer__copy { .app-footer__copy {
font-size: 13px; font-size: 13px;
opacity: 0.5; opacity: 0.5;
font-family: "JetBrains Mono", monospace; font-family: 'JetBrains Mono', monospace;
} }
.app-footer__links { .app-footer__links {
@ -46,7 +49,7 @@ const year = new Date().getFullYear();
font-size: 13px; font-size: 13px;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
font-family: "JetBrains Mono", monospace; font-family: 'JetBrains Mono', monospace;
} }
.app-footer__link:hover { .app-footer__link:hover {

View file

@ -2,6 +2,7 @@
import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js'; import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js';
const { t } = useI18n(); const { t } = useI18n();
const { repoUrl } = useGithubRepo();
const menuOpen = ref(false); const menuOpen = ref(false);
const navItems = computed(() => [ const navItems = computed(() => [
@ -28,7 +29,7 @@ const navItems = computed(() => [
<v-btn <v-btn
variant="outlined" variant="outlined"
size="small" size="small"
href="https://github.com/777genius/claude_agent_teams_ui" :href="repoUrl"
target="_blank" target="_blank"
class="app-header__github-btn" class="app-header__github-btn"
:prepend-icon="mdiGithub" :prepend-icon="mdiGithub"
@ -60,7 +61,7 @@ const navItems = computed(() => [
{{ item.label }} {{ item.label }}
</a> </a>
<a <a
href="https://github.com/777genius/claude_agent_teams_ui" :href="repoUrl"
target="_blank" target="_blank"
class="mobile-menu__link" class="mobile-menu__link"
@click="menuOpen = false" @click="menuOpen = false"

View file

@ -172,7 +172,7 @@ const rows = computed<ComparisonRow[]>(() => [
]) ])
const competitors = [ const competitors = [
{ key: 'us', name: 'Claude Agent Teams', highlight: true }, { key: 'us', name: 'Agent Teams', highlight: true },
{ key: 'vibeKanban', name: 'Vibe Kanban' }, { key: 'vibeKanban', name: 'Vibe Kanban' },
{ key: 'aperant', name: 'Aperant' }, { key: 'aperant', name: 'Aperant' },
{ key: 'cursor', name: 'Cursor' }, { key: 'cursor', name: 'Cursor' },

View file

@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js'; import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
import { downloadAssets } from "~/data/downloads"; import { downloadAssets } from '~/data/downloads';
import type { DownloadOs, DownloadArch } from "~/data/downloads"; import type { DownloadOs, DownloadArch } from '~/data/downloads';
const { content } = useLandingContent(); const { content } = useLandingContent();
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const { data: releaseData, resolve } = useReleaseDownloads(); const { data: releaseData, resolve } = useReleaseDownloads();
const { trackDownloadClick } = useAnalytics(); const { trackDownloadClick } = useAnalytics();
const { releaseDownloadUrl } = useGithubRepo();
onMounted(() => downloadStore.init()); onMounted(() => downloadStore.init());
@ -18,18 +19,18 @@ const platformIcons: Record<string, string> = {
}; };
const platformColors: Record<string, string> = { const platformColors: Record<string, string> = {
macos: "#00f0ff", macos: '#00f0ff',
windows: "#39ff14", windows: '#39ff14',
linux: "#ffd700", linux: '#ffd700',
}; };
const visibleAssets = computed(() => { const visibleAssets = computed(() => {
const enriched = downloadAssets.map((asset) => { const enriched = downloadAssets.map((asset) => {
if (asset.os !== "macos") return { ...asset }; if (asset.os !== 'macos') return { ...asset };
if (!downloadStore.isMacOs) return { ...asset }; if (!downloadStore.isMacOs) return { ...asset };
return { return {
...asset, ...asset,
archLabel: downloadStore.macArch === "arm64" ? "Apple Silicon" : "Intel", archLabel: downloadStore.macArch === 'arm64' ? 'Apple Silicon' : 'Intel',
}; };
}); });
@ -43,13 +44,13 @@ const visibleAssets = computed(() => {
return [first, detected, ...rest]; return [first, detected, ...rest];
}); });
const getDownloadUrl = (asset: { os: string; arch: string; url: string }) => { const getDownloadUrl = (asset: { os: string; arch: string; fileName: string }) => {
const arch = (asset.os === "macos" ? downloadStore.macArch : asset.arch) as DownloadArch; const arch = (asset.os === 'macos' ? downloadStore.macArch : asset.arch) as DownloadArch;
return resolve(asset.os as DownloadOs, arch)?.url || asset.url; return resolve(asset.os as DownloadOs, arch)?.url || releaseDownloadUrl(asset.fileName);
}; };
const getDownloadArch = (asset: { os: string; arch: string }) => { const getDownloadArch = (asset: { os: string; arch: string }) => {
return asset.os === "macos" ? downloadStore.macArch : asset.arch; return asset.os === 'macos' ? downloadStore.macArch : asset.arch;
}; };
const releaseVersion = computed(() => releaseData.value?.version || null); const releaseVersion = computed(() => releaseData.value?.version || null);
@ -90,7 +91,11 @@ const releaseDate = computed(() => {
<!-- Platform icon --> <!-- Platform icon -->
<div class="download-section__card-icon-wrap"> <div class="download-section__card-icon-wrap">
<v-icon size="28" class="download-section__card-icon" :icon="platformIcons[asset.os] || mdiDownload" /> <v-icon
size="28"
class="download-section__card-icon"
:icon="platformIcons[asset.os] || mdiDownload"
/>
</div> </div>
<!-- Platform info --> <!-- Platform info -->
@ -103,10 +108,18 @@ const releaseDate = computed(() => {
<a <a
class="download-section__btn" class="download-section__btn"
:href="getDownloadUrl(asset)" :href="getDownloadUrl(asset)"
@click.stop="trackDownloadClick({ os: asset.os, arch: getDownloadArch(asset), version: releaseVersion, source: 'download_section' }); downloadStore.setSelected(asset.id)" @click.stop="
trackDownloadClick({
os: asset.os,
arch: getDownloadArch(asset),
version: releaseVersion,
source: 'download_section',
});
downloadStore.setSelected(asset.id);
"
> >
<v-icon size="18" class="download-section__btn-icon" :icon="mdiDownload" /> <v-icon size="18" class="download-section__btn-icon" :icon="mdiDownload" />
<span>{{ t("download.title") }}</span> <span>{{ t('download.title') }}</span>
</a> </a>
<!-- Active indicator --> <!-- Active indicator -->
@ -115,7 +128,7 @@ const releaseDate = computed(() => {
class="download-section__card-indicator" class="download-section__card-indicator"
> >
<v-icon size="16" :icon="mdiCheckCircle" /> <v-icon size="16" :icon="mdiCheckCircle" />
<span>{{ t("download.detected") }}</span> <span>{{ t('download.detected') }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -245,11 +258,7 @@ const releaseDate = computed(() => {
.download-section__card--active .download-section__card-glow { .download-section__card--active .download-section__card-glow {
opacity: 0.7; opacity: 0.7;
background: radial-gradient( background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(57, 255, 20, 0.1), transparent 70%);
ellipse 80% 60% at 50% 0%,
rgba(57, 255, 20, 0.1),
transparent 70%
);
} }
/* Icon wrap */ /* Icon wrap */
@ -267,7 +276,9 @@ const releaseDate = computed(() => {
); );
border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent);
margin-bottom: 14px; margin-bottom: 14px;
transition: transform 0.35s ease, box-shadow 0.35s ease; transition:
transform 0.35s ease,
box-shadow 0.35s ease;
} }
.download-section__card:hover .download-section__card-icon-wrap { .download-section__card:hover .download-section__card-icon-wrap {
@ -290,7 +301,7 @@ const releaseDate = computed(() => {
margin-bottom: 3px; margin-bottom: 3px;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: #e0e6ff; color: #e0e6ff;
font-family: "JetBrains Mono", monospace; font-family: 'JetBrains Mono', monospace;
} }
.download-section__card-arch { .download-section__card-arch {
@ -319,7 +330,7 @@ const releaseDate = computed(() => {
box-shadow 0.25s ease, box-shadow 0.25s ease,
filter 0.25s ease; filter 0.25s ease;
box-shadow: 0 4px 16px rgba(0, 240, 255, 0.3); box-shadow: 0 4px 16px rgba(0, 240, 255, 0.3);
font-family: "JetBrains Mono", monospace; font-family: 'JetBrains Mono', monospace;
} }
.download-section__btn:hover { .download-section__btn:hover {
@ -346,7 +357,7 @@ const releaseDate = computed(() => {
font-weight: 600; font-weight: 600;
color: #39ff14; color: #39ff14;
opacity: 0.9; opacity: 0.9;
font-family: "JetBrains Mono", monospace; font-family: 'JetBrains Mono', monospace;
} }
/* Release info */ /* Release info */
@ -360,7 +371,7 @@ const releaseDate = computed(() => {
letter-spacing: 0.01em; letter-spacing: 0.01em;
position: relative; position: relative;
z-index: 1; z-index: 1;
font-family: "JetBrains Mono", monospace; font-family: 'JetBrains Mono', monospace;
} }
@keyframes downloadFadeUp { @keyframes downloadFadeUp {

View file

@ -7,21 +7,26 @@ const { baseURL } = useRuntimeConfig().app;
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const { resolve, data: releaseData } = useReleaseDownloads(); const { resolve, data: releaseData } = useReleaseDownloads();
const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
const releaseVersion = computed(() => releaseData.value?.version || null); const releaseVersion = computed(() => releaseData.value?.version || null);
const releaseDate = computed(() => { const releaseDate = computed(() => {
const raw = releaseData.value?.pubDate; const raw = releaseData.value?.pubDate;
if (!raw) return null; if (!raw) return null;
return new Date(raw).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); return new Date(raw).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}); });
onMounted(() => downloadStore.init()); onMounted(() => downloadStore.init());
const heroDownloadUrl = computed(() => { const heroDownloadUrl = computed(() => {
const asset = downloadStore.selectedAsset; const asset = downloadStore.selectedAsset;
if (!asset) return 'https://github.com/777genius/claude_agent_teams_ui/releases/latest'; if (!asset) return latestReleaseUrl.value;
const arch = asset.os === 'macos' ? downloadStore.macArch : asset.arch; const arch = asset.os === 'macos' ? downloadStore.macArch : asset.arch;
return resolve(asset.os, arch)?.url || asset.url; return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
}); });
</script> </script>
@ -75,17 +80,17 @@ const heroDownloadUrl = computed(() => {
<div class="hero-section__trust"> <div class="hero-section__trust">
<div class="hero-section__trust-item"> <div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiRobotOutline" /> <v-icon size="16" class="hero-section__trust-icon" :icon="mdiRobotOutline" />
<span>{{ t("hero.trust.agentTeams") }}</span> <span>{{ t('hero.trust.agentTeams') }}</span>
</div> </div>
<div class="hero-section__trust-divider" /> <div class="hero-section__trust-divider" />
<div class="hero-section__trust-item"> <div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiViewDashboardOutline" /> <v-icon size="16" class="hero-section__trust-icon" :icon="mdiViewDashboardOutline" />
<span>{{ t("hero.trust.kanban") }}</span> <span>{{ t('hero.trust.kanban') }}</span>
</div> </div>
<div class="hero-section__trust-divider" /> <div class="hero-section__trust-divider" />
<div class="hero-section__trust-item"> <div class="hero-section__trust-item">
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiOpenSourceInitiative" /> <v-icon size="16" class="hero-section__trust-icon" :icon="mdiOpenSourceInitiative" />
<span>{{ t("hero.trust.openSource") }}</span> <span>{{ t('hero.trust.openSource') }}</span>
</div> </div>
</div> </div>
</v-col> </v-col>
@ -108,7 +113,6 @@ const heroDownloadUrl = computed(() => {
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
</section> </section>
</template> </template>
@ -262,7 +266,12 @@ const heroDownloadUrl = computed(() => {
position: absolute; position: absolute;
inset: -2px; inset: -2px;
border-radius: 22px; border-radius: 22px;
background: linear-gradient(135deg, rgba(0, 240, 255, 0.2), rgba(255, 0, 255, 0.2), rgba(57, 255, 20, 0.1)); background: linear-gradient(
135deg,
rgba(0, 240, 255, 0.2),
rgba(255, 0, 255, 0.2),
rgba(57, 255, 20, 0.1)
);
filter: blur(20px); filter: blur(20px);
opacity: 0.4; opacity: 0.4;
z-index: 0; z-index: 0;
@ -270,8 +279,15 @@ const heroDownloadUrl = computed(() => {
} }
@keyframes glowPulse { @keyframes glowPulse {
0%, 100% { opacity: 0.3; transform: scale(1); } 0%,
50% { opacity: 0.5; transform: scale(1.02); } 100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.02);
}
} }
/* ─── SSR Fallback ─── */ /* ─── SSR Fallback ─── */

View file

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify';
import { testimonials } from '~/data/testimonials' import { testimonials } from '~/data/testimonials';
import { useLandingContent } from '~/composables/useLandingContent' import { useLandingContent } from '~/composables/useLandingContent';
const { content } = useLandingContent(); const { content } = useLandingContent();
const { t } = useI18n(); const { t } = useI18n();
const { issuesUrl } = useGithubRepo();
const { smAndUp } = useDisplay(); const { smAndUp } = useDisplay();
const expanded = ref(false); const expanded = ref(false);
@ -16,7 +17,7 @@ const items = computed(() =>
if (!contentItem) return null; if (!contentItem) return null;
return { ...contentItem, avatar: entry.avatar }; return { ...contentItem, avatar: entry.avatar };
}) })
.filter((item): item is NonNullable<typeof item> => item !== null) .filter((item): item is NonNullable<typeof item> => item !== null),
); );
const visibleItems = computed(() => { const visibleItems = computed(() => {
@ -24,9 +25,7 @@ const visibleItems = computed(() => {
return items.value.slice(0, smAndUp.value ? 4 : 2); return items.value.slice(0, smAndUp.value ? 4 : 2);
}); });
const hasMore = computed(() => const hasMore = computed(() => !expanded.value && items.value.length > (smAndUp.value ? 4 : 2));
!expanded.value && items.value.length > (smAndUp.value ? 4 : 2)
);
const getInitial = (name: string) => name.charAt(0).toUpperCase(); const getInitial = (name: string) => name.charAt(0).toUpperCase();
</script> </script>
@ -36,32 +35,21 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
<v-container> <v-container>
<div class="testimonials-section__header"> <div class="testimonials-section__header">
<h2 class="testimonials-section__title"> <h2 class="testimonials-section__title">
{{ t("testimonials.sectionTitle") }} {{ t('testimonials.sectionTitle') }}
</h2> </h2>
<p class="testimonials-section__subtitle"> <p class="testimonials-section__subtitle">
{{ t("testimonials.sectionSubtitle") }} {{ t('testimonials.sectionSubtitle') }}
</p> </p>
</div> </div>
<v-row justify="center"> <v-row justify="center">
<v-col <v-col v-for="(item, index) in visibleItems" :key="item.id" cols="12" sm="6">
v-for="(item, index) in visibleItems" <div class="testimonials-section__card-wrap" :style="{ '--delay': `${index * 0.08}s` }">
:key="item.id"
cols="12"
sm="6"
>
<div
class="testimonials-section__card-wrap"
:style="{ '--delay': `${index * 0.08}s` }"
>
<div class="testimonial-card"> <div class="testimonial-card">
<div class="testimonial-card__quote">"</div> <div class="testimonial-card__quote">"</div>
<p class="testimonial-card__text">{{ item.text }}</p> <p class="testimonial-card__text">{{ item.text }}</p>
<div class="testimonial-card__author"> <div class="testimonial-card__author">
<div <div class="testimonial-card__avatar" :style="{ background: item.avatar }">
class="testimonial-card__avatar"
:style="{ background: item.avatar }"
>
{{ getInitial(item.name) }} {{ getInitial(item.name) }}
</div> </div>
<div class="testimonial-card__info"> <div class="testimonial-card__info">
@ -75,17 +63,14 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
</v-row> </v-row>
<div v-if="hasMore || expanded" class="testimonials-section__toggle"> <div v-if="hasMore || expanded" class="testimonials-section__toggle">
<button <button class="testimonials-section__toggle-btn" @click="expanded = !expanded">
class="testimonials-section__toggle-btn"
@click="expanded = !expanded"
>
{{ expanded ? t('testimonials.showLess') : t('testimonials.showMore') }} {{ expanded ? t('testimonials.showLess') : t('testimonials.showMore') }}
</button> </button>
</div> </div>
<p class="testimonials-section__feedback-cta"> <p class="testimonials-section__feedback-cta">
{{ t('testimonials.feedbackCta') }} {{ t('testimonials.feedbackCta') }}
<a href="https://github.com/777genius/claude_agent_teams_ui/issues" target="_blank" class="testimonials-section__email">GitHub</a> <a :href="issuesUrl" target="_blank" class="testimonials-section__email">GitHub</a>
</p> </p>
</v-container> </v-container>
</section> </section>
@ -139,7 +124,9 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: transform 0.25s ease, box-shadow 0.25s ease; transition:
transform 0.25s ease,
box-shadow 0.25s ease;
} }
.testimonial-card:hover { .testimonial-card:hover {

View file

@ -0,0 +1,23 @@
export const useGithubRepo = () => {
const config = useRuntimeConfig();
const githubRepo = computed(
() => (config.public.githubRepo as string) || '777genius/claude_agent_teams_ui',
);
const repoUrl = computed(() => `https://github.com/${githubRepo.value}`);
const releasesUrl = computed(
() => (config.public.githubReleasesUrl as string) || `${repoUrl.value}/releases`,
);
const latestReleaseUrl = computed(() => `${releasesUrl.value}/latest`);
const issuesUrl = computed(() => `${repoUrl.value}/issues`);
const releaseDownloadUrl = (assetName: string) =>
`${latestReleaseUrl.value}/download/${assetName}`;
return {
githubRepo,
repoUrl,
releasesUrl,
latestReleaseUrl,
issuesUrl,
releaseDownloadUrl,
};
};

View file

@ -22,7 +22,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
const route = useRoute(); const route = useRoute();
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const siteUrl = config.public.siteUrl || "https://example.com"; const siteUrl = config.public.siteUrl || "https://example.com";
const siteName = (config as any)?.site?.name || "Claude Agent Teams"; const siteName = (config as any)?.site?.name || "Agent Teams";
const switchLocale = useSwitchLocalePath(); const switchLocale = useSwitchLocalePath();
const title = computed(() => t(titleKey)); const title = computed(() => t(titleKey));
@ -149,7 +149,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
htmlAttrs: { lang: locale.value || "en" }, htmlAttrs: { lang: locale.value || "en" },
link: links, link: links,
meta: [ meta: [
{ name: "author", content: "Claude Agent Teams" }, { name: "author", content: "Agent Teams" },
{ name: "application-name", content: siteName }, { name: "application-name", content: siteName },
{ name: "apple-mobile-web-app-title", content: siteName }, { name: "apple-mobile-web-app-title", content: siteName },
{ name: "format-detection", content: "telephone=no" }, { name: "format-detection", content: "telephone=no" },

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "أنت المدير التقني، والوكلاء هم فريقك. يتولون المهام بأنفسهم، يتواصلون مع بعضهم، يراجعون كود بعضهم البعض. وأنت فقط تنظر للوحة كانبان وتشرب قهوتك." "subtitle": "أنت المدير التقني، والوكلاء هم فريقك. يتولون المهام بأنفسهم، يتواصلون مع بعضهم، يراجعون كود بعضهم البعض. وأنت فقط تنظر للوحة كانبان وتشرب قهوتك."
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "ما هو Claude Agent Teams؟", "question": "ما هو Agent Teams؟",
"answer": "تطبيق سطح مكتب لتنظيم فرق وكلاء الذكاء الاصطناعي عبر طبقة تنسيق محلية خاصة بنا. لكل وكيل دور، يعمل بشكل مستقل، ويتعاون عبر لوحة كانبان، ويمكن تشغيله مع Anthropic أو Codex." "answer": "تطبيق سطح مكتب لتنظيم فرق وكلاء الذكاء الاصطناعي عبر طبقة تنسيق محلية خاصة بنا. لكل وكيل دور، يعمل بشكل مستقل، ويتعاون عبر لوحة كانبان، ويمكن تشغيله مع Anthropic أو Codex."
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "Sie sind der CTO, Agenten sind Ihr Team. Sie erledigen Aufgaben, kommunizieren untereinander, reviewen Code. Sie schauen aufs Kanban-Board und trinken Kaffee." "subtitle": "Sie sind der CTO, Agenten sind Ihr Team. Sie erledigen Aufgaben, kommunizieren untereinander, reviewen Code. Sie schauen aufs Kanban-Board und trinken Kaffee."
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "Was ist Claude Agent Teams?", "question": "Was ist Agent Teams?",
"answer": "Eine Desktop-App zur Orchestrierung von KI-Agententeams mit unserer eigenen lokalen Koordinationsschicht. Agenten haben Rollen, arbeiten autonom, kollaborieren über ein Kanban-Board und können mit Anthropic oder Codex laufen." "answer": "Eine Desktop-App zur Orchestrierung von KI-Agententeams mit unserer eigenen lokalen Koordinationsschicht. Agenten haben Rollen, arbeiten autonom, kollaborieren über ein Kanban-Board und können mit Anthropic oder Codex laufen."
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee." "subtitle": "You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee."
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "What is Claude Agent Teams?", "question": "What is Agent Teams?",
"answer": "A desktop app for orchestrating AI agent teams with our own local coordination layer. Agents have roles, work autonomously, collaborate through a kanban board, and can run on Anthropic or Codex." "answer": "A desktop app for orchestrating AI agent teams with our own local coordination layer. Agents have roles, work autonomously, collaborate through a kanban board, and can run on Anthropic or Codex."
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "Tú eres el CTO, los agentes son tu equipo. Ellos manejan las tareas solos, se comunican entre sí, revisan el código del otro. Tú solo miras el tablero kanban y tomas café." "subtitle": "Tú eres el CTO, los agentes son tu equipo. Ellos manejan las tareas solos, se comunican entre sí, revisan el código del otro. Tú solo miras el tablero kanban y tomas café."
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "¿Qué es Claude Agent Teams?", "question": "¿Qué es Agent Teams?",
"answer": "Una app de escritorio para orquestar equipos de agentes IA con nuestra propia capa local de coordinación. Cada agente tiene un rol, trabaja de forma autónoma, colabora a través de un tablero kanban y puede ejecutarse con Anthropic o Codex." "answer": "Una app de escritorio para orquestar equipos de agentes IA con nuestra propia capa local de coordinación. Cada agente tiene un rol, trabaja de forma autónoma, colabora a través de un tablero kanban y puede ejecutarse con Anthropic o Codex."
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "Vous êtes le CTO, les agents sont votre équipe. Ils gèrent les tâches, communiquent entre eux, révisent le code. Vous regardez le kanban et buvez votre café." "subtitle": "Vous êtes le CTO, les agents sont votre équipe. Ils gèrent les tâches, communiquent entre eux, révisent le code. Vous regardez le kanban et buvez votre café."
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "Qu'est-ce que Claude Agent Teams ?", "question": "Qu'est-ce que Agent Teams ?",
"answer": "Une application de bureau pour orchestrer des équipes d'agents IA avec notre propre couche de coordination locale. Les agents ont des rôles, travaillent de façon autonome, collaborent sur un tableau kanban et peuvent s'exécuter avec Anthropic ou Codex." "answer": "Une application de bureau pour orchestrer des équipes d'agents IA avec notre propre couche de coordination locale. Les agents ont des rôles, travaillent de façon autonome, collaborent sur un tableau kanban et peuvent s'exécuter avec Anthropic ou Codex."
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "आप CTO हैं, एजेंट आपकी टीम हैं। वे खुद टास्क संभालते हैं, आपस में बात करते हैं, एक-दूसरे का कोड रिव्यू करते हैं। आप बस कानबन बोर्ड देखें और कॉफी पिएँ।" "subtitle": "आप CTO हैं, एजेंट आपकी टीम हैं। वे खुद टास्क संभालते हैं, आपस में बात करते हैं, एक-दूसरे का कोड रिव्यू करते हैं। आप बस कानबन बोर्ड देखें और कॉफी पिएँ।"
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "Claude Agent Teams क्या है?", "question": "Agent Teams क्या है?",
"answer": "यह एक डेस्कटॉप ऐप है जो हमारी अपनी लोकल coordination layer के साथ AI agent teams को orchestrate करता है। हर agent की एक भूमिका होती है, वह स्वायत्त रूप से काम करता है, kanban board पर सहयोग करता है, और Anthropic या Codex पर चल सकता है।" "answer": "यह एक डेस्कटॉप ऐप है जो हमारी अपनी लोकल coordination layer के साथ AI agent teams को orchestrate करता है। हर agent की एक भूमिका होती है, वह स्वायत्त रूप से काम करता है, kanban board पर सहयोग करता है, और Anthropic या Codex पर चल सकता है।"
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "あなたはCTO、エージェントはあなたのチーム。タスクを自分で処理し、互いにメッセージを送り、コードをレビューする。あなたはカンバンボードを見ながらコーヒーを飲むだけ。" "subtitle": "あなたはCTO、エージェントはあなたのチーム。タスクを自分で処理し、互いにメッセージを送り、コードをレビューする。あなたはカンバンボードを見ながらコーヒーを飲むだけ。"
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "Claude Agent Teamsとは?", "question": "Agent Teamsとは?",
"answer": "独自のローカル協調レイヤーでAIエージェントチームをオーケストレーションできるデスクトップアプリです。各エージェントは役割を持ち、自律的に動き、カンバン上で連携し、Anthropic または Codex で実行できます。" "answer": "独自のローカル協調レイヤーでAIエージェントチームをオーケストレーションできるデスクトップアプリです。各エージェントは役割を持ち、自律的に動き、カンバン上で連携し、Anthropic または Codex で実行できます。"
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "Você é o CTO, os agentes são sua equipe. Eles cuidam das tarefas sozinhos, se comunicam entre si, revisam o código uns dos outros. Você só olha o quadro kanban e toma café." "subtitle": "Você é o CTO, os agentes são sua equipe. Eles cuidam das tarefas sozinhos, se comunicam entre si, revisam o código uns dos outros. Você só olha o quadro kanban e toma café."
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "O que é Claude Agent Teams?", "question": "O que é Agent Teams?",
"answer": "Um app desktop para orquestrar equipes de agentes IA com nossa própria camada local de coordenação. Os agentes têm papéis, trabalham de forma autônoma, colaboram em um quadro kanban e podem rodar com Anthropic ou Codex." "answer": "Um app desktop para orquestrar equipes de agentes IA com nossa própria camada local de coordenação. Os agentes têm papéis, trabalham de forma autônoma, colaboram em um quadro kanban e podem rodar com Anthropic ou Codex."
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "Вы — CTO, агенты — ваша команда. Они сами берут задачи, переписываются друг с другом, ревьюят код друг друга. А вы просто смотрите на канбан-доску и пьёте кофе." "subtitle": "Вы — CTO, агенты — ваша команда. Они сами берут задачи, переписываются друг с другом, ревьюят код друг друга. А вы просто смотрите на канбан-доску и пьёте кофе."
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "Что такое Claude Agent Teams?", "question": "Что такое Agent Teams?",
"answer": "Десктопное приложение для оркестрации команд ИИ-агентов с нашей собственной локальной координацией. У агентов есть роли, они работают автономно, взаимодействуют через канбан-доску и могут запускаться на Anthropic или Codex." "answer": "Десктопное приложение для оркестрации команд ИИ-агентов с нашей собственной локальной координацией. У агентов есть роли, они работают автономно, взаимодействуют через канбан-доску и могут запускаться на Anthropic или Codex."
}, },
{ {

View file

@ -1,6 +1,6 @@
{ {
"hero": { "hero": {
"title": "Claude Agent Teams", "title": "Agent Teams",
"subtitle": "你是 CTO,智能体是你的团队。它们自己处理任务、互相沟通、审查彼此的代码。你只需看着看板喝咖啡。" "subtitle": "你是 CTO,智能体是你的团队。它们自己处理任务、互相沟通、审查彼此的代码。你只需看着看板喝咖啡。"
}, },
"features": [ "features": [
@ -38,7 +38,7 @@
"faq": [ "faq": [
{ {
"id": "whatIsIt", "id": "whatIsIt",
"question": "什么是 Claude Agent Teams?", "question": "什么是 Agent Teams?",
"answer": "一个桌面应用,通过我们自己的本地协调层来编排 AI 智能体团队。每个智能体都有角色,可自主工作,在看板上协作,并且可以运行在 Anthropic 或 Codex 上。" "answer": "一个桌面应用,通过我们自己的本地协调层来编排 AI 智能体团队。每个智能体都有角色,可自主工作,在看板上协作,并且可以运行在 Anthropic 或 Codex 上。"
}, },
{ {

View file

@ -1,29 +1,29 @@
export type DownloadOs = "macos" | "windows" | "linux"; export type DownloadOs = 'macos' | 'windows' | 'linux';
export type DownloadArch = "arm64" | "x64" | "universal"; export type DownloadArch = 'arm64' | 'x64' | 'universal';
export const downloadAssets = [ export const downloadAssets = [
{ {
id: "macos", id: 'macos',
os: "macos", os: 'macos',
arch: "universal", arch: 'universal',
label: "macOS", label: 'macOS',
archLabel: "Apple Silicon / Intel", archLabel: 'Apple Silicon / Intel',
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg" fileName: 'Claude-Agent-Teams-UI-arm64.dmg',
}, },
{ {
id: "windows-x64", id: 'windows-x64',
os: "windows", os: 'windows',
arch: "x64", arch: 'x64',
label: "Windows", label: 'Windows',
archLabel: "64-bit", archLabel: '64-bit',
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe" fileName: 'Claude-Agent-Teams-UI-Setup.exe',
}, },
{ {
id: "linux-appimage", id: 'linux-appimage',
os: "linux", os: 'linux',
arch: "x64", arch: 'x64',
label: "Linux", label: 'Linux',
archLabel: "64-bit", archLabel: '64-bit',
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage" fileName: 'Claude-Agent-Teams-UI.AppImage',
} },
] as const; ] as const;

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "حمّل الآن", "downloadNow": "حمّل الآن",
"ctaPrimary": "تحميل لـ {platform}", "ctaPrimary": "تحميل لـ {platform}",
"ctaSecondary": "مقارنة", "ctaSecondary": "مقارنة",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "لديك أسئلة؟ لدينا إجابات", "sectionTitle": "لديك أسئلة؟ لدينا إجابات",
"subtitle": "كل ما تحتاج معرفته عن Claude Agent Teams" "subtitle": "كل ما تحتاج معرفته عن Agent Teams"
}, },
"comparison": { "comparison": {
"sectionTitle": "كيف نقارن", "sectionTitle": "كيف نقارن",
@ -92,7 +92,7 @@
"learnMore": "اعرف المزيد" "learnMore": "اعرف المزيد"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "تنسيق وكلاء الذكاء الاصطناعي للمطورين", "tagline": "تنسيق وكلاء الذكاء الاصطناعي للمطورين",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — تنسيق وكلاء الذكاء الاصطناعي للمطورين", "homeTitle": "Agent Teams - تنسيق وكلاء الذكاء الاصطناعي للمطورين",
"homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل." "homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل."
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "Herunterladen", "downloadNow": "Herunterladen",
"ctaPrimary": "Für {platform} herunterladen", "ctaPrimary": "Für {platform} herunterladen",
"ctaSecondary": "Vergleichen", "ctaSecondary": "Vergleichen",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "Fragen? Wir haben Antworten", "sectionTitle": "Fragen? Wir haben Antworten",
"subtitle": "Alles, was Sie über Claude Agent Teams wissen müssen" "subtitle": "Alles, was Sie über Agent Teams wissen müssen"
}, },
"comparison": { "comparison": {
"sectionTitle": "Wie wir im Vergleich abschneiden", "sectionTitle": "Wie wir im Vergleich abschneiden",
@ -92,7 +92,7 @@
"learnMore": "Mehr erfahren" "learnMore": "Mehr erfahren"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "KI-Agenten-Orchestrierung für Entwickler", "tagline": "KI-Agenten-Orchestrierung für Entwickler",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — KI-Agenten-Orchestrierung für Entwickler", "homeTitle": "Agent Teams - KI-Agenten-Orchestrierung für Entwickler",
"homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Kanban-Board, Code-Review, teamübergreifende Kommunikation. Läuft vollständig lokal." "homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Kanban-Board, Code-Review, teamübergreifende Kommunikation. Läuft vollständig lokal."
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "Download Now", "downloadNow": "Download Now",
"ctaPrimary": "Download for {platform}", "ctaPrimary": "Download for {platform}",
"ctaSecondary": "Compare", "ctaSecondary": "Compare",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "Got questions? We've got answers", "sectionTitle": "Got questions? We've got answers",
"subtitle": "Everything you need to know about Claude Agent Teams" "subtitle": "Everything you need to know about Agent Teams"
}, },
"comparison": { "comparison": {
"sectionTitle": "How we compare", "sectionTitle": "How we compare",
@ -92,7 +92,7 @@
"learnMore": "Learn more" "learnMore": "Learn more"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "AI agent orchestration for developers", "tagline": "AI agent orchestration for developers",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — AI Agent Orchestration for Developers", "homeTitle": "Agent Teams - AI Agent Orchestration for Developers",
"homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally." "homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally."
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "Descargar ahora", "downloadNow": "Descargar ahora",
"ctaPrimary": "Descargar para {platform}", "ctaPrimary": "Descargar para {platform}",
"ctaSecondary": "Comparar", "ctaSecondary": "Comparar",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "¿Tienes preguntas? Tenemos respuestas", "sectionTitle": "¿Tienes preguntas? Tenemos respuestas",
"subtitle": "Todo lo que necesitas saber sobre Claude Agent Teams" "subtitle": "Todo lo que necesitas saber sobre Agent Teams"
}, },
"comparison": { "comparison": {
"sectionTitle": "Cómo nos comparamos", "sectionTitle": "Cómo nos comparamos",
@ -92,7 +92,7 @@
"learnMore": "Más información" "learnMore": "Más información"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "Orquestación de agentes IA para desarrolladores", "tagline": "Orquestación de agentes IA para desarrolladores",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — Orquestación de agentes IA para desarrolladores", "homeTitle": "Agent Teams - Orquestación de agentes IA para desarrolladores",
"homeDescription": "App de escritorio gratuita y de código abierto para montar equipos de agentes IA. Tablero kanban, revisión de código, comunicación entre equipos. Funciona completamente en local." "homeDescription": "App de escritorio gratuita y de código abierto para montar equipos de agentes IA. Tablero kanban, revisión de código, comunicación entre equipos. Funciona completamente en local."
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "Télécharger", "downloadNow": "Télécharger",
"ctaPrimary": "Télécharger pour {platform}", "ctaPrimary": "Télécharger pour {platform}",
"ctaSecondary": "Comparer", "ctaSecondary": "Comparer",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "Des questions ? Nous avons les réponses", "sectionTitle": "Des questions ? Nous avons les réponses",
"subtitle": "Tout ce qu'il faut savoir sur Claude Agent Teams" "subtitle": "Tout ce qu'il faut savoir sur Agent Teams"
}, },
"comparison": { "comparison": {
"sectionTitle": "Comment nous nous comparons", "sectionTitle": "Comment nous nous comparons",
@ -92,7 +92,7 @@
"learnMore": "En savoir plus" "learnMore": "En savoir plus"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "Orchestration d'agents IA pour développeurs", "tagline": "Orchestration d'agents IA pour développeurs",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — Orchestration d'agents IA pour développeurs", "homeTitle": "Agent Teams - Orchestration d'agents IA pour développeurs",
"homeDescription": "Application desktop gratuite et open source pour gérer des équipes d'agents IA. Tableau kanban, revue de code, communication inter-équipes. Fonctionne entièrement en local." "homeDescription": "Application desktop gratuite et open source pour gérer des équipes d'agents IA. Tableau kanban, revue de code, communication inter-équipes. Fonctionne entièrement en local."
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "अभी डाउनलोड करें", "downloadNow": "अभी डाउनलोड करें",
"ctaPrimary": "{platform} के लिए डाउनलोड करें", "ctaPrimary": "{platform} के लिए डाउनलोड करें",
"ctaSecondary": "तुलना करें", "ctaSecondary": "तुलना करें",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "सवाल हैं? हमारे पास जवाब हैं", "sectionTitle": "सवाल हैं? हमारे पास जवाब हैं",
"subtitle": "Claude Agent Teams के बारे में सब कुछ" "subtitle": "Agent Teams के बारे में सब कुछ"
}, },
"comparison": { "comparison": {
"sectionTitle": "तुलना करें", "sectionTitle": "तुलना करें",
@ -92,7 +92,7 @@
"learnMore": "और जानें" "learnMore": "और जानें"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन", "tagline": "डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन", "homeTitle": "Agent Teams - डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
"homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।" "homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।"
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "ダウンロード", "downloadNow": "ダウンロード",
"ctaPrimary": "{platform}版をダウンロード", "ctaPrimary": "{platform}版をダウンロード",
"ctaSecondary": "比較する", "ctaSecondary": "比較する",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "よくある質問", "sectionTitle": "よくある質問",
"subtitle": "Claude Agent Teamsについて知っておくべきこと" "subtitle": "Agent Teamsについて知っておくべきこと"
}, },
"comparison": { "comparison": {
"sectionTitle": "他ツールとの比較", "sectionTitle": "他ツールとの比較",
@ -92,7 +92,7 @@
"learnMore": "詳細" "learnMore": "詳細"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "開発者向けAIエージェントオーケストレーション", "tagline": "開発者向けAIエージェントオーケストレーション",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — 開発者向けAIエージェントオーケストレーション", "homeTitle": "Agent Teams - 開発者向けAIエージェントオーケストレーション",
"homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。" "homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。"
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "Baixar agora", "downloadNow": "Baixar agora",
"ctaPrimary": "Baixar para {platform}", "ctaPrimary": "Baixar para {platform}",
"ctaSecondary": "Comparar", "ctaSecondary": "Comparar",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "Tem perguntas? Temos respostas", "sectionTitle": "Tem perguntas? Temos respostas",
"subtitle": "Tudo sobre o Claude Agent Teams" "subtitle": "Tudo sobre o Agent Teams"
}, },
"comparison": { "comparison": {
"sectionTitle": "Como nos comparamos", "sectionTitle": "Como nos comparamos",
@ -92,7 +92,7 @@
"learnMore": "Saiba mais" "learnMore": "Saiba mais"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "Orquestração de agentes IA para desenvolvedores", "tagline": "Orquestração de agentes IA para desenvolvedores",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — Orquestração de agentes IA para desenvolvedores", "homeTitle": "Agent Teams - Orquestração de agentes IA para desenvolvedores",
"homeDescription": "App desktop gratuito e open source para montar equipes de agentes IA. Quadro kanban, revisão de código, comunicação entre equipes. Roda totalmente local." "homeDescription": "App desktop gratuito e open source para montar equipes de agentes IA. Quadro kanban, revisão de código, comunicação entre equipes. Roda totalmente local."
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "Скачать", "downloadNow": "Скачать",
"ctaPrimary": "Скачать для {platform}", "ctaPrimary": "Скачать для {platform}",
"ctaSecondary": "Сравнить", "ctaSecondary": "Сравнить",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "Есть вопросы? У нас есть ответы", "sectionTitle": "Есть вопросы? У нас есть ответы",
"subtitle": "Всё, что нужно знать о Claude Agent Teams" "subtitle": "Всё, что нужно знать о Agent Teams"
}, },
"comparison": { "comparison": {
"sectionTitle": "Сравнение с конкурентами", "sectionTitle": "Сравнение с конкурентами",
@ -92,7 +92,7 @@
"learnMore": "Подробнее" "learnMore": "Подробнее"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "Оркестрация ИИ-агентов для разработчиков", "tagline": "Оркестрация ИИ-агентов для разработчиков",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — Оркестрация ИИ-агентов для разработчиков", "homeTitle": "Agent Teams - Оркестрация ИИ-агентов для разработчиков",
"homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально." "homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально."
}, },
"error": { "error": {

View file

@ -9,7 +9,7 @@
"viewOnGithub": "View on GitHub" "viewOnGithub": "View on GitHub"
}, },
"hero": { "hero": {
"badge": "Claude Agent Teams", "badge": "Agent Teams",
"downloadNow": "立即下载", "downloadNow": "立即下载",
"ctaPrimary": "下载 {platform} 版", "ctaPrimary": "下载 {platform} 版",
"ctaSecondary": "对比", "ctaSecondary": "对比",
@ -56,7 +56,7 @@
}, },
"faq": { "faq": {
"sectionTitle": "有问题?我们有答案", "sectionTitle": "有问题?我们有答案",
"subtitle": "关于 Claude Agent Teams 的一切" "subtitle": "关于 Agent Teams 的一切"
}, },
"comparison": { "comparison": {
"sectionTitle": "功能对比", "sectionTitle": "功能对比",
@ -92,7 +92,7 @@
"learnMore": "了解更多" "learnMore": "了解更多"
}, },
"footer": { "footer": {
"copyright": "© {year} Claude Agent Teams", "copyright": "© {year} Agent Teams",
"tagline": "面向开发者的 AI 智能体编排", "tagline": "面向开发者的 AI 智能体编排",
"links": { "links": {
"github": "GitHub", "github": "GitHub",
@ -100,7 +100,7 @@
} }
}, },
"meta": { "meta": {
"homeTitle": "Claude Agent Teams — 面向开发者的 AI 智能体编排", "homeTitle": "Agent Teams - 面向开发者的 AI 智能体编排",
"homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。" "homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。"
}, },
"error": { "error": {

View file

@ -4,7 +4,7 @@ import { generateI18nRoutes, supportedLocales } from "./data/i18n";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const process: any; declare const process: any;
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://claude-agent-teams.dev"; 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 githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`; const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
const baseURL = process.env.NUXT_APP_BASE_URL || "/"; const baseURL = process.env.NUXT_APP_BASE_URL || "/";
@ -88,7 +88,7 @@ export default defineNuxtConfig({
// @ts-expect-error - field provided by nuxt modules // @ts-expect-error - field provided by nuxt modules
site: { site: {
url: siteUrl, url: siteUrl,
name: "Claude Agent Teams" name: "Agent Teams"
}, },
runtimeConfig: { runtimeConfig: {
github: { github: {

View file

@ -1,10 +1,10 @@
{ {
"name": "claude-agent-teams-landing", "name": "agent-teams-landing",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "claude-agent-teams-landing", "name": "agent-teams-landing",
"dependencies": { "dependencies": {
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@nuxtjs/i18n": "^9.5.6", "@nuxtjs/i18n": "^9.5.6",

View file

@ -1,5 +1,5 @@
{ {
"name": "claude-agent-teams-landing", "name": "agent-teams-landing",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const siteUrl = (config.public.siteUrl as string) || "https://claude-agent-teams.dev"; const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
setHeader(event, "content-type", "text/plain; charset=utf-8"); setHeader(event, "content-type", "text/plain; charset=utf-8");

View file

@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0];
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const siteUrl = (config.public.siteUrl as string) || "https://claude-agent-teams.dev"; const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/claude_agent_teams_ui";
setHeader(event, "content-type", "application/xml; charset=utf-8"); setHeader(event, "content-type", "application/xml; charset=utf-8");

View file

@ -1,7 +1,7 @@
{ {
"name": "agent-teams-mcp", "name": "agent-teams-mcp",
"version": "1.0.0", "version": "1.0.0",
"description": "MCP server for managing Claude Agent Teams through the agent-teams-controller API", "description": "MCP server for managing Agent Teams through the agent-teams-controller API",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {

View file

@ -22,7 +22,8 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'kanban_set_column', name: 'kanban_set_column',
description: 'Move task to review or approved column', description:
'Repair the kanban overlay for a task that is already in review or approved. Use review_request/review_approve for lifecycle transitions.',
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
taskId: z.string().min(1), taskId: z.string().min(1),
@ -36,7 +37,8 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'kanban_clear', name: 'kanban_clear',
description: 'Remove task from kanban board', description:
'Repair-clear a stale non-review kanban overlay. Use review_request_changes, review_approve, task_start, or task_set_status for lifecycle transitions.',
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
taskId: z.string().min(1), taskId: z.string().min(1),

View file

@ -70,7 +70,7 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
getController(teamName, claudeDir).review.approveReview(taskId, { getController(teamName, claudeDir).review.approveReview(taskId, {
...(from ? { from } : {}), ...(from ? { from } : {}),
...(note ? { note } : {}), ...(note ? { note } : {}),
...(notifyOwner !== false ? { 'notify-owner': true } : {}), ...(notifyOwner === true ? { 'notify-owner': true } : {}),
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),
}) as Record<string, unknown> }) as Record<string, unknown>
) )

View file

@ -996,7 +996,6 @@ describe('agent-teams-mcp stdio e2e', () => {
claudeDir, claudeDir,
teamName: 'review-roundtrip-team', teamName: 'review-roundtrip-team',
taskId: roundtripTask.id, taskId: roundtripTask.id,
from: 'bob',
}, },
39 39
); );
@ -1563,7 +1562,6 @@ describe('agent-teams-mcp stdio e2e', () => {
claudeDir, claudeDir,
teamName: 'terminal-routing-team', teamName: 'terminal-routing-team',
taskId: approvedTask.id, taskId: approvedTask.id,
from: 'bob',
}, },
87 87
); );
@ -1779,4 +1777,140 @@ describe('agent-teams-mcp stdio e2e', () => {
await client.close(); await client.close();
} }
}); });
it('guards review lifecycle bypasses and deleted resurrection over stdio', async () => {
await writeTeamConfig(claudeDir, 'stdio-hardening-team');
const client = new McpStdIoClient(serverPath, workspaceRoot);
try {
await client.initialize();
const createResult = await client.callTool(
'task_create',
{
claudeDir,
teamName: 'stdio-hardening-team',
subject: 'Lifecycle guard task',
owner: 'alice',
},
101
);
const task = parseJsonToolResult((createResult as { result: unknown }).result);
await client.callTool(
'task_complete',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
actor: 'alice',
},
102
);
await client.callTool(
'review_request',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
from: 'team-lead',
reviewer: 'bob',
},
103
);
await client.callTool(
'review_approve',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
from: 'bob',
},
104
);
const clearResult = await client.callTool(
'kanban_clear',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
},
105
);
const clearResponse = clearResult as {
error?: { message?: string };
result?: { content?: Array<{ text?: string }> };
};
const clearErrorText =
clearResponse.error?.message ?? (clearResponse.result?.content?.[0]?.text ?? '');
expect(clearErrorText).toContain('reviewState=approved');
const reopenedResult = await client.callTool(
'task_set_status',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
status: 'pending',
actor: 'team-lead',
},
106
);
const reopened = parseJsonToolResult((reopenedResult as { result: unknown }).result);
expect(reopened.status).toBe('pending');
expect(reopened.reviewState).toBe('none');
const inventoryResult = await client.callTool(
'task_list',
{
claudeDir,
teamName: 'stdio-hardening-team',
owner: 'alice',
},
107
);
const inventoryRows = parseJsonToolResult((inventoryResult as { result: unknown }).result);
expect(inventoryRows[0]).toMatchObject({
id: task.id,
status: 'pending',
reviewState: 'none',
});
const deleteResult = await client.callTool(
'task_set_status',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
status: 'deleted',
actor: 'team-lead',
},
108
);
const deleted = parseJsonToolResult((deleteResult as { result: unknown }).result);
expect(deleted.status).toBe('deleted');
expect(deleted.reviewState).toBe('none');
const startDeletedResult = await client.callTool(
'task_start',
{
claudeDir,
teamName: 'stdio-hardening-team',
taskId: task.id,
actor: 'alice',
},
109
);
const startDeletedResponse = startDeletedResult as {
error?: { message?: string };
result?: { content?: Array<{ text?: string }> };
};
const startDeletedErrorText =
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
expect(startDeletedErrorText).toContain('use task_restore before starting work');
} finally {
await client.close();
}
});
}); });

View file

@ -910,7 +910,7 @@ describe('agent-teams-mcp tools', () => {
); );
expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined(); expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined();
// review_start: moves task to review without requiring completed status // review_start: cannot move pending/non-review work into review by itself
const pendingTask = parseJsonToolResult( const pendingTask = parseJsonToolResult(
await getTool('task_create').execute({ await getTool('task_create').execute({
claudeDir, claudeDir,
@ -919,17 +919,14 @@ describe('agent-teams-mcp tools', () => {
owner: 'bob', owner: 'bob',
}) })
); );
const reviewStarted = parseJsonToolResult( await expect(
await getTool('review_start').execute({ getTool('review_start').execute({
claudeDir, claudeDir,
teamName, teamName,
taskId: pendingTask.id, taskId: pendingTask.id,
from: 'alice', from: 'alice',
}) })
); ).rejects.toThrow('must be completed before starting review');
expect(reviewStarted.ok).toBe(true);
expect(reviewStarted.column).toBe('review');
expect(reviewStarted.taskId).toBe(pendingTask.id);
const pid = process.pid; const pid = process.pid;
@ -979,6 +976,159 @@ describe('agent-teams-mcp tools', () => {
expect(unregistered).toEqual([]); expect(unregistered).toEqual([]);
}); });
it('rejects public lifecycle bypasses through kanban_set_column and task_set_status', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'public-bypass-guards';
writeTeamConfig(claudeDir, teamName, {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'reviewer' },
{ name: 'bob', role: 'developer' },
],
});
const pendingTask = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Cannot approve directly',
owner: 'bob',
})
);
await expect(
getTool('kanban_set_column').execute({
claudeDir,
teamName,
taskId: pendingTask.id,
column: 'approved',
})
).rejects.toThrow('must be completed before moving to APPROVED column');
const reviewTask = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Delete cleanup through generic status',
owner: 'bob',
})
);
await getTool('task_complete').execute({
claudeDir,
teamName,
taskId: reviewTask.id,
actor: 'bob',
});
await getTool('review_request').execute({
claudeDir,
teamName,
taskId: reviewTask.id,
from: 'lead',
reviewer: 'alice',
});
const deleted = parseJsonToolResult(
await getTool('task_set_status').execute({
claudeDir,
teamName,
taskId: reviewTask.id,
status: 'deleted',
actor: 'lead',
})
);
expect(deleted.status).toBe('deleted');
expect(deleted.reviewState).toBe('none');
const kanbanState = parseJsonToolResult(
await getTool('kanban_get').execute({
claudeDir,
teamName,
})
);
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id);
});
it('only notifies the owner on review_approve when notifyOwner is explicit', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'review-approval-notify';
writeTeamConfig(claudeDir, teamName, {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'reviewer' },
{ name: 'bob', role: 'developer' },
],
});
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'bob.json');
const readOwnerInbox = () =>
fs.existsSync(ownerInboxPath) ? JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8')) : [];
const quietTask = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Quiet approval',
owner: 'bob',
})
);
await getTool('task_complete').execute({
claudeDir,
teamName,
taskId: quietTask.id,
actor: 'bob',
});
await getTool('review_request').execute({
claudeDir,
teamName,
taskId: quietTask.id,
from: 'lead',
reviewer: 'alice',
});
const beforeQuietApprove = readOwnerInbox().length;
parseJsonToolResult(
await getTool('review_approve').execute({
claudeDir,
teamName,
taskId: quietTask.id,
from: 'alice',
})
);
expect(readOwnerInbox()).toHaveLength(beforeQuietApprove);
const notifyingTask = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Notifying approval',
owner: 'bob',
})
);
await getTool('task_complete').execute({
claudeDir,
teamName,
taskId: notifyingTask.id,
actor: 'bob',
});
await getTool('review_request').execute({
claudeDir,
teamName,
taskId: notifyingTask.id,
from: 'lead',
reviewer: 'alice',
});
const beforeNotifyingApprove = readOwnerInbox().length;
parseJsonToolResult(
await getTool('review_approve').execute({
claudeDir,
teamName,
taskId: notifyingTask.id,
from: 'alice',
notifyOwner: true,
})
);
const afterNotifyingApprove = readOwnerInbox();
expect(afterNotifyingApprove).toHaveLength(beforeNotifyingApprove + 1);
expect(afterNotifyingApprove.at(-1).text).toContain('approved');
});
it('persists full message metadata through message_send', async () => { it('persists full message metadata through message_send', async () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const teamName = 'gamma'; const teamName = 'gamma';

View file

@ -2,7 +2,7 @@
"name": "claude-agent-teams-ui", "name": "claude-agent-teams-ui",
"type": "module", "type": "module",
"version": "1.3.0", "version": "1.3.0",
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", "description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {
"name": "Илия (777genius)", "name": "Илия (777genius)",
@ -259,6 +259,7 @@
"main": "dist-electron/main/index.cjs" "main": "dist-electron/main/index.cjs"
}, },
"mac": { "mac": {
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}-mac.${ext}",
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"minimumSystemVersion": "12.0", "minimumSystemVersion": "12.0",
"target": [ "target": [
@ -273,7 +274,8 @@
"icon": "resources/icons/mac/icon.icns" "icon": "resources/icons/mac/icon.icns"
}, },
"dmg": { "dmg": {
"sign": false "sign": false,
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}"
}, },
"win": { "win": {
"target": [ "target": [
@ -291,10 +293,14 @@
"icon": "resources/icons/png", "icon": "resources/icons/png",
"category": "Development" "category": "Development"
}, },
"appImage": {
"artifactName": "Claude.Agent.Teams.UI-${version}.${ext}"
},
"deb": { "deb": {
"afterInstall": "resources/afterInstall.sh" "afterInstall": "resources/afterInstall.sh"
}, },
"nsis": { "nsis": {
"artifactName": "Claude.Agent.Teams.UI.Setup.${version}.${ext}",
"oneClick": false, "oneClick": false,
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true
@ -302,6 +308,8 @@
"publish": [ "publish": [
{ {
"provider": "github", "provider": "github",
"owner": "777genius",
"repo": "claude_agent_teams_ui",
"releaseType": "release" "releaseType": "release"
} }
] ]

View file

@ -312,7 +312,8 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"global.anthropic.claude-opus-4-6-v1": { "global.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -340,7 +341,8 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"us.anthropic.claude-opus-4-6-v1": { "us.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875, "cache_creation_input_token_cost": 0.000006875,
@ -368,7 +370,8 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"eu.anthropic.claude-opus-4-6-v1": { "eu.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875, "cache_creation_input_token_cost": 0.000006875,
@ -396,7 +399,8 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"au.anthropic.claude-opus-4-6-v1": { "au.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875, "cache_creation_input_token_cost": 0.000006875,
@ -424,7 +428,8 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"anthropic.claude-opus-4-7": { "anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -452,7 +457,9 @@
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"anthropic.claude-mythos-preview": { "anthropic.claude-mythos-preview": {
"input_cost_per_token": 0, "input_cost_per_token": 0,
@ -494,7 +501,9 @@
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"us.anthropic.claude-opus-4-7": { "us.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.000006875, "cache_creation_input_token_cost": 0.000006875,
@ -522,7 +531,9 @@
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"eu.anthropic.claude-opus-4-7": { "eu.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.000006875, "cache_creation_input_token_cost": 0.000006875,
@ -550,7 +561,9 @@
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"au.anthropic.claude-opus-4-7": { "au.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.000006875, "cache_creation_input_token_cost": 0.000006875,
@ -578,7 +591,9 @@
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"anthropic.claude-sonnet-4-6": { "anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost": 0.00000375,
@ -605,7 +620,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
}, },
"global.anthropic.claude-sonnet-4-6": { "global.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost": 0.00000375,
@ -632,7 +648,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
}, },
"us.anthropic.claude-sonnet-4-6": { "us.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125, "cache_creation_input_token_cost": 0.000004125,
@ -659,7 +676,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
}, },
"eu.anthropic.claude-sonnet-4-6": { "eu.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125, "cache_creation_input_token_cost": 0.000004125,
@ -686,7 +704,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
}, },
"au.anthropic.claude-sonnet-4-6": { "au.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125, "cache_creation_input_token_cost": 0.000004125,
@ -713,7 +732,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true "supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
}, },
"anthropic.claude-sonnet-4-20250514-v1:0": { "anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost": 0.00000375,
@ -1014,7 +1034,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 159, "tool_use_system_prompt_tokens": 159,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"azure_ai/claude-opus-4-7": { "azure_ai/claude-opus-4-7": {
"input_cost_per_token": 0.000005, "input_cost_per_token": 0.000005,
@ -1042,7 +1063,9 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 159 "tool_use_system_prompt_tokens": 159,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"azure_ai/claude-opus-4-1": { "azure_ai/claude-opus-4-1": {
"cache_creation_input_token_cost": 0.00001875, "cache_creation_input_token_cost": 0.00001875,
@ -1106,7 +1129,8 @@
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346 "tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
}, },
"bedrock/ap-northeast-1/anthropic.claude-instant-v1": { "bedrock/ap-northeast-1/anthropic.claude-instant-v1": {
"input_cost_per_token": 0.00000223, "input_cost_per_token": 0.00000223,
@ -1682,7 +1706,8 @@
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346 "tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
}, },
"claude-sonnet-4-5-20250929-v1:0": { "claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost": 0.00000375,
@ -1876,7 +1901,8 @@
"us": 1.1, "us": 1.1,
"fast": 6 "fast": 6
}, },
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"claude-opus-4-6-20260205": { "claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -1908,7 +1934,8 @@
"us": 1.1, "us": 1.1,
"fast": 6 "fast": 6
}, },
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"claude-opus-4-7": { "claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -1940,7 +1967,9 @@
"provider_specific_entry": { "provider_specific_entry": {
"us": 1.1, "us": 1.1,
"fast": 6 "fast": 6
} },
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"claude-opus-4-7-20260416": { "claude-opus-4-7-20260416": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -1972,7 +2001,9 @@
"provider_specific_entry": { "provider_specific_entry": {
"us": 1.1, "us": 1.1,
"fast": 6 "fast": 6
} },
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"claude-sonnet-4-20250514": { "claude-sonnet-4-20250514": {
"deprecation_date": "2026-05-14", "deprecation_date": "2026-05-14",
@ -3233,7 +3264,8 @@
"supports_reasoning": true, "supports_reasoning": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 159 "tool_use_system_prompt_tokens": 159,
"supports_minimal_reasoning_effort": true
}, },
"openrouter/anthropic/claude-opus-4.5": { "openrouter/anthropic/claude-opus-4.5": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -3271,7 +3303,8 @@
"supports_reasoning": true, "supports_reasoning": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346 "tool_use_system_prompt_tokens": 346,
"supports_minimal_reasoning_effort": true
}, },
"openrouter/anthropic/claude-sonnet-4.5": { "openrouter/anthropic/claude-sonnet-4.5": {
"input_cost_per_image": 0.0048, "input_cost_per_image": 0.0048,
@ -4051,7 +4084,8 @@
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true "supports_vision": true,
"supports_minimal_reasoning_effort": true
}, },
"vercel_ai_gateway/anthropic/claude-sonnet-4": { "vercel_ai_gateway/anthropic/claude-sonnet-4": {
"cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost": 0.00000375,
@ -4425,7 +4459,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"vertex_ai/claude-opus-4-6@default": { "vertex_ai/claude-opus-4-6@default": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -4452,7 +4487,8 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"vertex_ai/claude-opus-4-7": { "vertex_ai/claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -4479,7 +4515,9 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346 "tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"vertex_ai/claude-opus-4-7@default": { "vertex_ai/claude-opus-4-7@default": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -4506,7 +4544,9 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346 "tool_use_system_prompt_tokens": 346,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
}, },
"vertex_ai/claude-sonnet-4-5": { "vertex_ai/claude-sonnet-4-5": {
"cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost": 0.00000375,
@ -4558,7 +4598,8 @@
"search_context_size_high": 0.01, "search_context_size_high": 0.01,
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
} },
"supports_minimal_reasoning_effort": true
}, },
"vertex_ai/claude-sonnet-4-5@20250929": { "vertex_ai/claude-sonnet-4-5@20250929": {
"cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost": 0.00000375,
@ -4697,7 +4738,8 @@
"search_context_size_high": 0.01, "search_context_size_high": 0.01,
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
} },
"supports_minimal_reasoning_effort": true
}, },
"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.0000015, "cache_creation_input_token_cost": 0.0000015,

View file

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

View file

@ -10,7 +10,7 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, '..');
const defaultEvidencePath = path.join( const defaultEvidencePath = path.join(
resolveAppDataDir(), resolveAppDataDir(),
'claude-agent-teams-ui', 'Agent Teams UI',
'opencode-bridge', 'opencode-bridge',
'production-e2e-evidence.json' 'production-e2e-evidence.json'
); );

View file

@ -1,12 +1,11 @@
export {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';
export type { export type {
AnthropicFastModeResolution, AnthropicFastModeResolution,
AnthropicRuntimeProfileSource, AnthropicRuntimeProfileSource,
AnthropicRuntimeReconciliation, AnthropicRuntimeReconciliation,
AnthropicRuntimeSelection, AnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile'; } from '../core/domain/resolveAnthropicRuntimeProfile';
export {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';

View file

@ -1,12 +1,11 @@
export {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';
export type { export type {
AnthropicFastModeResolution, AnthropicFastModeResolution,
AnthropicRuntimeProfileSource, AnthropicRuntimeProfileSource,
AnthropicRuntimeReconciliation, AnthropicRuntimeReconciliation,
AnthropicRuntimeSelection, AnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile'; } from '../core/domain/resolveAnthropicRuntimeProfile';
export {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '../core/domain/resolveAnthropicRuntimeProfile';

View file

@ -1,7 +1,5 @@
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
import { CodexAccountEnvBuilder } from '@features/codex-account/main/infrastructure/CodexAccountEnvBuilder'; import { CodexAccountEnvBuilder } from '@features/codex-account/main/infrastructure/CodexAccountEnvBuilder';
import { createStaticCodexModelCatalogModels } from '@features/codex-model-catalog/core/domain/codexModelCatalogFallback'; import { createStaticCodexModelCatalogModels } from '@features/codex-model-catalog/core/domain/codexModelCatalogFallback';
import { normalizeCodexAppServerModels } from '@features/codex-model-catalog/core/domain/normalizeCodexAppServerModel'; import { normalizeCodexAppServerModels } from '@features/codex-model-catalog/core/domain/normalizeCodexAppServerModel';
@ -15,6 +13,8 @@ import {
import { CodexModelCatalogAppServerClient } from '../infrastructure/CodexModelCatalogAppServerClient'; import { CodexModelCatalogAppServerClient } from '../infrastructure/CodexModelCatalogAppServerClient';
import { InMemoryCodexModelCatalogCache } from '../infrastructure/InMemoryCodexModelCatalogCache'; import { InMemoryCodexModelCatalogCache } from '../infrastructure/InMemoryCodexModelCatalogCache';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts'; import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts';
import type { Logger } from '@shared/utils/logger'; import type { Logger } from '@shared/utils/logger';

View file

@ -9,7 +9,7 @@ import type {
describe('CodexModelCatalogAppServerClient', () => { describe('CodexModelCatalogAppServerClient', () => {
it('reads config and paginated model/list in one app-server session', async () => { it('reads config and paginated model/list in one app-server session', async () => {
const requests: Array<{ method: string; params: unknown }> = []; const requests: { method: string; params: unknown }[] = [];
let sessionCount = 0; let sessionCount = 0;
const session: CodexAppServerSession = { const session: CodexAppServerSession = {
initializeResponse: { initializeResponse: {

View file

@ -1,3 +1,10 @@
export type {
CodexFastCapabilitySource,
CodexFastModeResolution,
CodexRuntimeProfileSource,
CodexRuntimeReconciliation,
CodexRuntimeSelection,
} from '../core/domain/resolveCodexRuntimeProfile';
export { export {
buildCodexFastModeArgs, buildCodexFastModeArgs,
CODEX_FAST_CREDIT_COST_MULTIPLIER, CODEX_FAST_CREDIT_COST_MULTIPLIER,
@ -7,11 +14,3 @@ export {
resolveCodexFastMode, resolveCodexFastMode,
resolveCodexRuntimeSelection, resolveCodexRuntimeSelection,
} from '../core/domain/resolveCodexRuntimeProfile'; } from '../core/domain/resolveCodexRuntimeProfile';
export type {
CodexFastCapabilitySource,
CodexFastModeResolution,
CodexRuntimeProfileSource,
CodexRuntimeReconciliation,
CodexRuntimeSelection,
} from '../core/domain/resolveCodexRuntimeProfile';

View file

@ -1,3 +1,10 @@
export type {
CodexFastCapabilitySource,
CodexFastModeResolution,
CodexRuntimeProfileSource,
CodexRuntimeReconciliation,
CodexRuntimeSelection,
} from '../core/domain/resolveCodexRuntimeProfile';
export { export {
buildCodexFastModeArgs, buildCodexFastModeArgs,
CODEX_FAST_CREDIT_COST_MULTIPLIER, CODEX_FAST_CREDIT_COST_MULTIPLIER,
@ -7,11 +14,3 @@ export {
resolveCodexFastMode, resolveCodexFastMode,
resolveCodexRuntimeSelection, resolveCodexRuntimeSelection,
} from '../core/domain/resolveCodexRuntimeProfile'; } from '../core/domain/resolveCodexRuntimeProfile';
export type {
CodexFastCapabilitySource,
CodexFastModeResolution,
CodexRuntimeProfileSource,
CodexRuntimeReconciliation,
CodexRuntimeSelection,
} from '../core/domain/resolveCodexRuntimeProfile';

View file

@ -2,6 +2,10 @@ import {
type DashboardRecentProjectsPayload, type DashboardRecentProjectsPayload,
normalizeDashboardRecentProjectsPayload, normalizeDashboardRecentProjectsPayload,
} from '@features/recent-projects/contracts'; } from '@features/recent-projects/contracts';
import {
CodexBinaryResolver,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase'; import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase';
import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter'; import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter';
@ -13,10 +17,6 @@ import { RecentProjectIdentityResolver } from '../infrastructure/identity/Recent
import type { ClockPort } from '../../core/application/ports/ClockPort'; import type { ClockPort } from '../../core/application/ports/ClockPort';
import type { LoggerPort } from '../../core/application/ports/LoggerPort'; import type { LoggerPort } from '../../core/application/ports/LoggerPort';
import {
CodexBinaryResolver,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
import type { ServiceContext } from '@main/services'; import type { ServiceContext } from '@main/services';
export interface RecentProjectsFeatureFacade { export interface RecentProjectsFeatureFacade {

View file

@ -201,7 +201,7 @@ export class CodexAppServerClient {
'initialize', 'initialize',
{ {
clientInfo: { clientInfo: {
name: 'claude-agent-teams-ui', name: 'agent-teams-ai',
title: 'Agent Teams UI', title: 'Agent Teams UI',
version: '0.1.0', version: '0.1.0',
}, },

View file

@ -44,11 +44,11 @@ export type TeamRuntimeLanePlan =
mode: 'mixed_opencode_side_lanes'; mode: 'mixed_opencode_side_lanes';
primaryMembers: PlannedRuntimeMember[]; primaryMembers: PlannedRuntimeMember[];
allMembers: PlannedRuntimeMember[]; allMembers: PlannedRuntimeMember[];
sideLanes: Array<{ sideLanes: {
laneId: string; laneId: string;
providerId: 'opencode'; providerId: 'opencode';
member: PlannedRuntimeMember; member: PlannedRuntimeMember;
}>; }[];
}; };
export type TeamRuntimeLanePlanErrorReason = 'unsupported_opencode_led_mixed_team'; export type TeamRuntimeLanePlanErrorReason = 'unsupported_opencode_led_mixed_team';

View file

@ -0,0 +1,5 @@
import { app } from 'electron';
import { migrateElectronUserDataDirectory } from './utils/electronUserDataMigration';
export const earlyElectronUserDataMigrationResult = migrateElectronUserDataDirectory(app);

View file

@ -1,9 +1,9 @@
import { validateTeamName } from '@main/ipc/guards'; import { validateTeamName } from '@main/ipc/guards';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { import {
formatEffortLevelListForProvider, formatEffortLevelListForProvider,
isTeamEffortLevelForProvider, isTeamEffortLevelForProvider,
} from '@shared/utils/effortLevels'; } from '@shared/utils/effortLevels';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isTeamProviderId } from '@shared/utils/teamProvider'; import { isTeamProviderId } from '@shared/utils/teamProvider';
@ -224,7 +224,7 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
} }
const teamProvisioningService = getTeamProvisioningService(services); const teamProvisioningService = getTeamProvisioningService(services);
teamProvisioningService.stopTeam(validatedTeamName.value!); await teamProvisioningService.stopTeam(validatedTeamName.value!);
return reply.send(await teamProvisioningService.getRuntimeState(validatedTeamName.value!)); return reply.send(await teamProvisioningService.getRuntimeState(validatedTeamName.value!));
} catch (error) { } catch (error) {
if (shouldLogError(error)) { if (shouldLogError(error)) {

View file

@ -38,7 +38,7 @@ export function registerUpdaterRoutes(app: FastifyInstance, services: HttpServic
app.post('/api/updater/install', async () => { app.post('/api/updater/install', async () => {
try { try {
services.updaterService.quitAndInstall(); await services.updaterService.quitAndInstall();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
logger.error('Error in POST /api/updater/install:', getErrorMessage(error)); logger.error('Error in POST /api/updater/install:', getErrorMessage(error));

View file

@ -16,7 +16,10 @@
// On Windows this saturates all threads, blocking the event loop. // On Windows this saturates all threads, blocking the event loop.
process.env.UV_THREADPOOL_SIZE ??= '16'; process.env.UV_THREADPOOL_SIZE ??= '16';
// Sentry must be the first import to capture early errors. // Keep userData stable before any integration can initialize Electron storage.
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
// Sentry must stay near the top to capture early errors after storage migration.
import './sentry'; import './sentry';
import { import {
@ -53,6 +56,7 @@ import {
resolveAgentTeamsMcpLaunchSpec, resolveAgentTeamsMcpLaunchSpec,
TeamMcpConfigBuilder, TeamMcpConfigBuilder,
} from '@main/services/team/TeamMcpConfigBuilder'; } from '@main/services/team/TeamMcpConfigBuilder';
import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { import {
CONTEXT_CHANGED, CONTEXT_CHANGED,
@ -179,6 +183,20 @@ import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types'; import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('App'); const logger = createLogger('App');
if (
earlyElectronUserDataMigrationResult.migrated &&
earlyElectronUserDataMigrationResult.legacyPath &&
earlyElectronUserDataMigrationResult.currentPath
) {
logger.info(
`Migrated Electron userData from ${earlyElectronUserDataMigrationResult.legacyPath} to ${earlyElectronUserDataMigrationResult.currentPath}`
);
} else if (
earlyElectronUserDataMigrationResult.fallbackToLegacy &&
earlyElectronUserDataMigrationResult.legacyPath
) {
logger.warn(`Electron userData migration failed, using legacy path for this run`);
}
startEventLoopLagMonitor(); startEventLoopLagMonitor();
// Windows: set AppUserModelId early so native notifications show the correct // Windows: set AppUserModelId early so native notifications show the correct
@ -533,6 +551,70 @@ let rendererRecoveryAttempts = 0;
let fileChangeCleanup: (() => void) | null = null; let fileChangeCleanup: (() => void) | null = null;
let todoChangeCleanup: (() => void) | null = null; let todoChangeCleanup: (() => void) | null = null;
let teamChangeCleanup: (() => void) | null = null; let teamChangeCleanup: (() => void) | null = null;
let shutdownPromise: Promise<void> | null = null;
let shutdownComplete = false;
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
function isShutdownStarted(): boolean {
return shutdownComplete || shutdownPromise !== null;
}
function scheduleStartupTask(action: () => void, delayMs: number): void {
const timer = setTimeout(() => {
startupTimers.delete(timer);
if (isShutdownStarted()) {
return;
}
action();
}, delayMs);
timer.unref?.();
startupTimers.add(timer);
}
function clearStartupTimers(): void {
for (const timer of startupTimers) {
clearTimeout(timer);
}
startupTimers.clear();
}
function clearInboxNotifyTimers(): void {
for (const timer of inboxNotifyTimers.values()) {
clearTimeout(timer);
}
inboxNotifyTimers.clear();
}
async function runShutdownStep(
label: string,
action: () => void | Promise<void>,
timeoutMs: number = SHUTDOWN_STEP_TIMEOUT_MS
): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
Promise.resolve().then(action),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
logger.warn(`Shutdown step timed out after ${timeoutMs}ms: ${label}`);
resolve();
}, timeoutMs);
timeout.unref?.();
}),
]);
} catch (error) {
logger.warn(
`Shutdown step failed (${label}): ${error instanceof Error ? error.message : String(error)}`
);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
/** /**
* Resolve production renderer index path. * Resolve production renderer index path.
@ -714,13 +796,12 @@ function wireFileWatcherEvents(context: ServiceContext): void {
const timerKey = `${teamName}:${detail}`; const timerKey = `${teamName}:${detail}`;
const existing = inboxNotifyTimers.get(timerKey); const existing = inboxNotifyTimers.get(timerKey);
if (existing) clearTimeout(existing); if (existing) clearTimeout(existing);
inboxNotifyTimers.set( const timer = setTimeout(() => {
timerKey,
setTimeout(() => {
inboxNotifyTimers.delete(timerKey); inboxNotifyTimers.delete(timerKey);
void notifyNewInboxMessages(teamName, detail).catch(() => undefined); void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
}, INBOX_NOTIFY_DEBOUNCE_MS) }, INBOX_NOTIFY_DEBOUNCE_MS);
); timer.unref?.();
inboxNotifyTimers.set(timerKey, timer);
} }
// Show native OS notification for new lead → user messages (sentMessages.json). // Show native OS notification for new lead → user messages (sentMessages.json).
@ -728,13 +809,12 @@ function wireFileWatcherEvents(context: ServiceContext): void {
const timerKey = `${teamName}:sentMessages`; const timerKey = `${teamName}:sentMessages`;
const existing = inboxNotifyTimers.get(timerKey); const existing = inboxNotifyTimers.get(timerKey);
if (existing) clearTimeout(existing); if (existing) clearTimeout(existing);
inboxNotifyTimers.set( const timer = setTimeout(() => {
timerKey,
setTimeout(() => {
inboxNotifyTimers.delete(timerKey); inboxNotifyTimers.delete(timerKey);
void notifyNewSentMessages(teamName).catch(() => undefined); void notifyNewSentMessages(teamName).catch(() => undefined);
}, INBOX_NOTIFY_DEBOUNCE_MS) }, INBOX_NOTIFY_DEBOUNCE_MS);
); timer.unref?.();
inboxNotifyTimers.set(timerKey, timer);
} }
} }
@ -907,6 +987,19 @@ async function initializeServices(): Promise<void> {
// Initialize updater and CLI installer services // Initialize updater and CLI installer services
updaterService = new UpdaterService(); updaterService = new UpdaterService();
updaterService.setBeforeQuitAndInstall(async () => {
try {
await shutdownServices();
} catch (error) {
logger.error(
`Shutdown before update install failed: ${
error instanceof Error ? error.message : String(error)
}`
);
} finally {
shutdownComplete = true;
}
});
cliInstallerService = new CliInstallerService(); cliInstallerService = new CliInstallerService();
ptyTerminalService = new PtyTerminalService(); ptyTerminalService = new PtyTerminalService();
const teamMemberLogsFinder = new TeamMemberLogsFinder(); const teamMemberLogsFinder = new TeamMemberLogsFinder();
@ -1177,6 +1270,10 @@ async function initializeServices(): Promise<void> {
async function startHttpServer( async function startHttpServer(
modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void> modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void>
): Promise<void> { ): Promise<void> {
if (isShutdownStarted()) {
return;
}
try { try {
if (httpServer.isRunning()) { if (httpServer.isRunning()) {
await syncTeamControlApiState(); await syncTeamControlApiState();
@ -1200,6 +1297,11 @@ async function startHttpServer(
modeSwitchHandler, modeSwitchHandler,
config.httpServer?.port ?? 3456 config.httpServer?.port ?? 3456
); );
if (isShutdownStarted()) {
await httpServer.stop().catch(() => undefined);
await clearTeamControlApiState().catch(() => undefined);
return;
}
await syncTeamControlApiState(); await syncTeamControlApiState();
logger.info(`HTTP sidecar server running on port ${port}`); logger.info(`HTTP sidecar server running on port ${port}`);
} catch (error) { } catch (error) {
@ -1212,36 +1314,44 @@ async function startHttpServer(
/** /**
* Shuts down all services. * Shuts down all services.
*/ */
function shutdownServices(): void { async function shutdownServices(): Promise<void> {
if (shutdownPromise) {
return shutdownPromise;
}
shutdownPromise = (async () => {
logger.info('Shutting down services...'); logger.info('Shutting down services...');
// Clear pending auto-resume timers before anything else — otherwise the clearStartupTimers();
// dangling setTimeout handles keep the event loop alive past shutdown and clearInboxNotifyTimers();
// may fire against a torn-down provisioning service.
// Clear pending auto-resume timers before anything else. Dangling timers can
// keep the event loop alive and fire against a torn-down provisioning service.
clearAutoResumeService(); clearAutoResumeService();
// Kill all team CLI processes via SIGKILL BEFORE anything else. // Kill all team CLI processes via SIGKILL before anything else.
// This must happen before the OS closes stdin pipes (on app exit), // This must happen before the OS closes stdin pipes on app exit, because
// because stdin EOF triggers CLI's graceful shutdown which deletes team files. // stdin EOF triggers CLI cleanup that can delete team files.
if (teamProvisioningService) { if (teamProvisioningService) {
teamProvisioningService.stopAllTeams(); await runShutdownStep('stop all teams', () => teamProvisioningService.stopAllTeams(), 10_000);
} }
await runShutdownStep('tracked CLI subprocess cleanup', () =>
killTrackedCliProcesses('SIGKILL')
);
// Best-effort cleanup of MCP config files owned by this process await runShutdownStep('MCP config GC', () => new TeamMcpConfigBuilder().gcOwnConfigs());
void new TeamMcpConfigBuilder().gcOwnConfigs();
// Sync backup all team data (files are stable after SIGKILL). // Sync backup all team data. Files are stable after SIGKILL.
if (teamBackupService) { if (teamBackupService) {
teamBackupService.runShutdownBackupSync(); await runShutdownStep('team backup sync', () => teamBackupService?.runShutdownBackupSync());
} }
// Stop HTTP server
if (httpServer?.isRunning()) { if (httpServer?.isRunning()) {
void httpServer.stop(); await runShutdownStep('HTTP server stop', () => httpServer.stop());
} }
void clearTeamControlApiState(); await runShutdownStep('team control state cleanup', () => clearTeamControlApiState());
// Clean up file watcher event listeners await runShutdownStep('file watcher event cleanup', () => {
if (fileChangeCleanup) { if (fileChangeCleanup) {
fileChangeCleanup(); fileChangeCleanup();
fileChangeCleanup = null; fileChangeCleanup = null;
@ -1254,58 +1364,65 @@ function shutdownServices(): void {
teamChangeCleanup(); teamChangeCleanup();
teamChangeCleanup = null; teamChangeCleanup = null;
} }
});
// Clean up editor state (watcher, git service) await runShutdownStep('editor cleanup', () => cleanupEditorState());
cleanupEditorState();
// Dispose all contexts (including local)
if (contextRegistry) { if (contextRegistry) {
contextRegistry.dispose(); await runShutdownStep('context registry dispose', () => contextRegistry.dispose());
} }
// Dispose SSH connection manager
if (sshConnectionManager) { if (sshConnectionManager) {
sshConnectionManager.dispose(); await runShutdownStep('SSH connection manager dispose', () => sshConnectionManager.dispose());
} }
// Stop background polling timers (prevents hanging shutdown).
if (teamDataService) { if (teamDataService) {
teamDataService.stopProcessHealthPolling(); await runShutdownStep('team data polling stop', () =>
teamDataService.stopProcessHealthPolling()
);
}
if (updaterService) {
await runShutdownStep('updater periodic check stop', () =>
updaterService.stopPeriodicCheck()
);
} }
if (teamTaskStallMonitor) { if (teamTaskStallMonitor) {
void teamTaskStallMonitor.stop(); await runShutdownStep('team task stall monitor stop', () => teamTaskStallMonitor?.stop());
teamTaskStallMonitor = null; teamTaskStallMonitor = null;
} }
branchStatusService?.dispose(); await runShutdownStep('branch status dispose', () => branchStatusService?.dispose());
branchStatusService = null; branchStatusService = null;
// Stop scheduled task execution and croner jobs
if (schedulerService) { if (schedulerService) {
void schedulerService.stop(); await runShutdownStep('scheduler stop', () => schedulerService.stop());
} }
void skillsWatcherService?.stopAll(); await runShutdownStep('skills watcher stop', () => skillsWatcherService?.stopAll());
await runShutdownStep('provider connection feature detach', () => {
providerConnectionService.setCodexModelCatalogFeature(null); providerConnectionService.setCodexModelCatalogFeature(null);
providerConnectionService.setCodexAccountFeature(null); providerConnectionService.setCodexAccountFeature(null);
void codexModelCatalogFeature?.dispose(); });
await runShutdownStep('Codex model catalog dispose', () => codexModelCatalogFeature?.dispose());
codexModelCatalogFeature = null; codexModelCatalogFeature = null;
void codexAccountFeature?.dispose(); await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose());
codexAccountFeature = null; codexAccountFeature = null;
// Kill all PTY processes
if (ptyTerminalService) { if (ptyTerminalService) {
ptyTerminalService.killAll(); await runShutdownStep('PTY terminals kill', () => ptyTerminalService.killAll());
} }
// Remove IPC handlers await runShutdownStep('IPC handlers cleanup', () => {
removeIpcHandlers(); removeIpcHandlers();
removeCodexAccountIpc(ipcMain); removeCodexAccountIpc(ipcMain);
removeRecentProjectsIpc(ipcMain); removeRecentProjectsIpc(ipcMain);
});
// Dispose backup service timers await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());
teamBackupService?.dispose();
logger.info('Services shut down successfully'); logger.info('Services shut down successfully');
})();
return shutdownPromise;
} }
/** /**
@ -1322,6 +1439,9 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
} }
function scheduleRendererRecovery(win: BrowserWindow): void { function scheduleRendererRecovery(win: BrowserWindow): void {
if (isShutdownStarted()) {
return;
}
if (rendererRecoveryTimer) { if (rendererRecoveryTimer) {
return; return;
} }
@ -1336,6 +1456,9 @@ function scheduleRendererRecovery(win: BrowserWindow): void {
rendererRecoveryTimer = setTimeout(() => { rendererRecoveryTimer = setTimeout(() => {
rendererRecoveryTimer = null; rendererRecoveryTimer = null;
if (isShutdownStarted()) {
return;
}
if (!mainWindow || mainWindow !== win || win.isDestroyed()) { if (!mainWindow || mainWindow !== win || win.isDestroyed()) {
return; return;
} }
@ -1347,12 +1470,17 @@ function scheduleRendererRecovery(win: BrowserWindow): void {
logger.error(`Renderer recovery reload failed: ${String(error)}`); logger.error(`Renderer recovery reload failed: ${String(error)}`);
} }
}, delayMs); }, delayMs);
rendererRecoveryTimer.unref?.();
} }
/** /**
* Creates the main application window. * Creates the main application window.
*/ */
function createWindow(): void { function createWindow(): void {
if (isShutdownStarted()) {
return;
}
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
const iconPath = isMac ? undefined : getAppIconPath(); const iconPath = isMac ? undefined : getAppIconPath();
@ -1440,6 +1568,9 @@ function createWindow(): void {
}); });
mainWindow.webContents.on('did-start-loading', () => { mainWindow.webContents.on('did-start-loading', () => {
if (isShutdownStarted()) {
return;
}
markRendererUnavailable(mainWindow); markRendererUnavailable(mainWindow);
branchStatusService?.resetAllTracking(); branchStatusService?.resetAllTracking();
}); });
@ -1447,6 +1578,9 @@ function createWindow(): void {
// Set traffic light position + notify renderer on first load, and auto-check for updates // Set traffic light position + notify renderer on first load, and auto-check for updates
mainWindow.webContents.on('did-finish-load', () => { mainWindow.webContents.on('did-finish-load', () => {
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
if (isShutdownStarted()) {
return;
}
markRendererReady(mainWindow); markRendererReady(mainWindow);
rendererRecoveryAttempts = 0; rendererRecoveryAttempts = 0;
if (rendererRecoveryTimer) { if (rendererRecoveryTimer) {
@ -1455,9 +1589,12 @@ function createWindow(): void {
} }
logger.warn('[startup] renderer did-finish-load'); logger.warn('[startup] renderer did-finish-load');
syncTrafficLightPosition(mainWindow); syncTrafficLightPosition(mainWindow);
setTimeout(() => { const fullscreenSyncTimer = setTimeout(() => {
if (!isShutdownStarted()) {
safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, mainWindow?.isFullScreen()); safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, mainWindow?.isFullScreen());
}
}, 0); }, 0);
fullscreenSyncTimer.unref?.();
// Start file watchers now that the window is visible and responsive. // Start file watchers now that the window is visible and responsive.
// Deferred from initializeServices() to avoid blocking window creation // Deferred from initializeServices() to avoid blocking window creation
// with fs.watch() setup (especially slow on Windows with recursive watchers). // with fs.watch() setup (especially slow on Windows with recursive watchers).
@ -1466,17 +1603,21 @@ function createWindow(): void {
// On Windows, delay FileWatcher startup to let the renderer complete // On Windows, delay FileWatcher startup to let the renderer complete
// its initial IPC calls without UV thread pool contention. Recursive // its initial IPC calls without UV thread pool contention. Recursive
// fs.watch() on NTFS saturates all 4 default UV threads. // fs.watch() on NTFS saturates all 4 default UV threads.
setTimeout(() => activeContext.startFileWatcher(), 1500); scheduleStartupTask(() => activeContext.startFileWatcher(), 1500);
} else { } else {
if (!isShutdownStarted()) {
activeContext.startFileWatcher(); activeContext.startFileWatcher();
} }
}
setTimeout(() => updaterService.checkForUpdates(), 3000); if (!isShutdownStarted()) {
scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000);
updaterService.startPeriodicCheck(60 * 60 * 1000); updaterService.startPeriodicCheck(60 * 60 * 1000);
}
// Defer non-critical startup work to avoid thread pool contention. // Defer non-critical startup work to avoid thread pool contention.
// The window is now visible and responsive; these run in the background. // The window is now visible and responsive; these run in the background.
setTimeout(() => { scheduleStartupTask(() => {
void teamProvisioningService.warmup(); void teamProvisioningService.warmup();
teamDataService.startProcessHealthPolling(); teamDataService.startProcessHealthPolling();
void schedulerService?.start(); void schedulerService?.start();
@ -1546,11 +1687,12 @@ function createWindow(): void {
// For zoom keys (including Cmd+0 reset), defer sync until zoom is applied // For zoom keys (including Cmd+0 reset), defer sync until zoom is applied
if (ZOOM_IN_KEYS.has(input.key) || ZOOM_OUT_KEYS.has(input.key) || input.key === '0') { if (ZOOM_IN_KEYS.has(input.key) || ZOOM_OUT_KEYS.has(input.key) || input.key === '0') {
setTimeout(() => { const zoomSyncTimer = setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) { if (!isShutdownStarted() && mainWindow && !mainWindow.isDestroyed()) {
syncTrafficLightPosition(mainWindow); syncTrafficLightPosition(mainWindow);
} }
}, 100); }, 100);
zoomSyncTimer.unref?.();
} }
}); });
@ -1588,6 +1730,9 @@ function createWindow(): void {
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
mainWindow.webContents.on('render-process-gone', (_event, details) => { mainWindow.webContents.on('render-process-gone', (_event, details) => {
logger.error('Renderer process gone:', details.reason, details.exitCode); logger.error('Renderer process gone:', details.reason, details.exitCode);
if (isShutdownStarted()) {
return;
}
markRendererUnavailable(mainWindow); markRendererUnavailable(mainWindow);
branchStatusService?.resetAllTracking(); branchStatusService?.resetAllTracking();
const activeContext = contextRegistry.getActive(); const activeContext = contextRegistry.getActive();
@ -1666,6 +1811,9 @@ void app.whenReady().then(async () => {
// Listen for notification click events // Listen for notification click events
notificationManager.on('notification-clicked', (_error) => { notificationManager.on('notification-clicked', (_error) => {
if (isShutdownStarted()) {
return;
}
if (mainWindow) { if (mainWindow) {
mainWindow.show(); mainWindow.show();
mainWindow.focus(); mainWindow.focus();
@ -1679,6 +1827,9 @@ void app.whenReady().then(async () => {
} }
app.on('activate', () => { app.on('activate', () => {
if (isShutdownStarted()) {
return;
}
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
} }
@ -1689,7 +1840,10 @@ void app.whenReady().then(async () => {
* All windows closed handler. * All windows closed handler.
*/ */
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { const shouldQuitWhenAllWindowsClosed =
process.platform !== 'darwin' || !configManager.getConfig().general.showDockIcon;
if (shouldQuitWhenAllWindowsClosed) {
app.quit(); app.quit();
} }
}); });
@ -1697,6 +1851,25 @@ app.on('window-all-closed', () => {
/** /**
* Before quit handler - cleanup. * Before quit handler - cleanup.
*/ */
app.on('before-quit', () => { app.on('before-quit', (event) => {
shutdownServices(); if (shutdownComplete) {
return;
}
event.preventDefault();
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.hide();
}
}
void shutdownServices()
.catch((error) => {
logger.error(`Shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => {
shutdownComplete = true;
app.quit();
});
}); });

View file

@ -3,9 +3,8 @@
* Prevents invalid/unknown data from mutating persisted config. * Prevents invalid/unknown data from mutating persisted config.
*/ */
import * as path from 'path';
import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import * as path from 'path';
import type { import type {
AppConfig, AppConfig,

View file

@ -90,19 +90,19 @@ import {
extractUserFlags, extractUserFlags,
PROTECTED_CLI_FLAGS, PROTECTED_CLI_FLAGS,
} from '@shared/utils/cliArgsParser'; } from '@shared/utils/cliArgsParser';
import { createLogger } from '@shared/utils/logger';
import { import {
formatEffortLevelListForProvider, formatEffortLevelListForProvider,
isTeamEffortLevelForProvider, isTeamEffortLevelForProvider,
} from '@shared/utils/effortLevels'; } from '@shared/utils/effortLevels';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { import {
buildStandaloneSlashCommandMeta, buildStandaloneSlashCommandMeta,
parseStandaloneSlashCommand, parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands'; } from '@shared/utils/slashCommands';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import crypto from 'crypto'; import crypto from 'crypto';
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
@ -153,6 +153,7 @@ import type {
TeamProvisioningService, TeamProvisioningService,
} from '../services'; } from '../services';
import type { TeamBackupService } from '../services/team/TeamBackupService'; import type { TeamBackupService } from '../services/team/TeamBackupService';
import type { TeamMembersMetaFile } from '../services/team/TeamMembersMetaStore';
import type { import type {
AddTaskCommentRequest, AddTaskCommentRequest,
AgentActionMode, AgentActionMode,
@ -189,11 +190,11 @@ import type {
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamFastMode,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMemberActivityMeta, TeamMemberActivityMeta,
TeamMessageNotificationData, TeamMessageNotificationData,
TeamFastMode,
TeamProviderBackendId, TeamProviderBackendId,
TeamProviderId, TeamProviderId,
TeamProvisioningModelVerificationMode, TeamProvisioningModelVerificationMode,
@ -209,7 +210,6 @@ import type {
UpdateKanbanPatch, UpdateKanbanPatch,
} from '@shared/types'; } from '@shared/types';
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
import type { TeamMembersMetaFile } from '../services/team/TeamMembersMetaStore';
const logger = createLogger('IPC:teams'); const logger = createLogger('IPC:teams');
@ -1018,7 +1018,7 @@ async function handleDeleteTeam(
} }
return wrapTeamHandler('deleteTeam', async () => { return wrapTeamHandler('deleteTeam', async () => {
getAutoResumeService().cancelPendingAutoResume(validated.value!); getAutoResumeService().cancelPendingAutoResume(validated.value!);
getTeamProvisioningService().stopTeam(validated.value!); await getTeamProvisioningService().stopTeam(validated.value!);
await getTeamDataService().deleteTeam(validated.value!); await getTeamDataService().deleteTeam(validated.value!);
}); });
} }
@ -1217,7 +1217,7 @@ function parseOptionalTeamFastMode(
}; };
} }
type RuntimeRosterMutationMember = { interface RuntimeRosterMutationMember {
name: string; name: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
@ -1228,7 +1228,7 @@ type RuntimeRosterMutationMember = {
effort?: EffortLevel; effort?: EffortLevel;
fastMode?: TeamFastMode; fastMode?: TeamFastMode;
removedAt?: number | string | null; removedAt?: number | string | null;
}; }
const OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE = const OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE =
'Live roster mutation for a running OpenCode-led team is not supported in this phase. Stop the team, edit the roster, then relaunch.'; 'Live roster mutation for a running OpenCode-led team is not supported in this phase. Stop the team, edit the roster, then relaunch.';
@ -3402,7 +3402,7 @@ async function handleStopTeam(
return wrapTeamHandler('stop', async () => { return wrapTeamHandler('stop', async () => {
addMainBreadcrumb('team', 'stop', { teamName: validated.value! }); addMainBreadcrumb('team', 'stop', { teamName: validated.value! });
getAutoResumeService().cancelPendingAutoResume(validated.value!); getAutoResumeService().cancelPendingAutoResume(validated.value!);
getTeamProvisioningService().stopTeam(validated.value!); await getTeamProvisioningService().stopTeam(validated.value!);
}); });
} }
@ -3706,7 +3706,7 @@ async function handleReplaceMembers(
const previousByName = new Map( const previousByName = new Map(
previousMembers previousMembers
.filter((member) => !member.removedAt) .filter((member) => !member.removedAt)
.map((member) => [member.name.trim().toLowerCase(), member as RuntimeRosterMutationMember]) .map((member) => [member.name.trim().toLowerCase(), member])
); );
const nextByName = new Map( const nextByName = new Map(
members.map((member) => [ members.map((member) => [
@ -4556,7 +4556,7 @@ async function handleGetSavedRequest(
), ),
model: meta.model, model: meta.model,
effort: meta.effort as TeamCreateRequest['effort'], effort: meta.effort as TeamCreateRequest['effort'],
fastMode: meta.fastMode as TeamCreateRequest['fastMode'], fastMode: meta.fastMode,
skipPermissions: meta.skipPermissions, skipPermissions: meta.skipPermissions,
worktree: meta.worktree, worktree: meta.worktree,
extraCliArgs: meta.extraCliArgs, extraCliArgs: meta.extraCliArgs,

View file

@ -66,9 +66,9 @@ async function handleDownload(_event: IpcMainInvokeEvent): Promise<void> {
} }
} }
function handleInstall(_event: IpcMainInvokeEvent): void { async function handleInstall(_event: IpcMainInvokeEvent): Promise<void> {
try { try {
updaterService.quitAndInstall(); await updaterService.quitAndInstall();
} catch (error) { } catch (error) {
logger.error('Error in updater:install:', getErrorMessage(error)); logger.error('Error in updater:install:', getErrorMessage(error));
} }

View file

@ -5,7 +5,7 @@
*/ */
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow, type IpcMain } from 'electron'; import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent } from 'electron';
const WINDOW_IS_FULLSCREEN = 'window:isFullScreen'; const WINDOW_IS_FULLSCREEN = 'window:isFullScreen';
@ -18,14 +18,20 @@ function getMainWindow(): BrowserWindow | null {
return all.length > 0 ? all[0] : null; return all.length > 0 ? all[0] : null;
} }
function getWindowForEvent(event: IpcMainInvokeEvent): BrowserWindow | null {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && !win.isDestroyed()) return win;
return getMainWindow();
}
export function registerWindowHandlers(ipcMain: IpcMain): void { export function registerWindowHandlers(ipcMain: IpcMain): void {
ipcMain.handle('window:minimize', () => { ipcMain.handle('window:minimize', (event) => {
const win = getMainWindow(); const win = getWindowForEvent(event);
if (win && !win.isDestroyed()) win.minimize(); if (win && !win.isDestroyed()) win.minimize();
}); });
ipcMain.handle('window:maximize', () => { ipcMain.handle('window:maximize', (event) => {
const win = getMainWindow(); const win = getWindowForEvent(event);
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
if (win.isMaximized()) win.unmaximize(); if (win.isMaximized()) win.unmaximize();
else win.maximize(); else win.maximize();
@ -33,23 +39,22 @@ export function registerWindowHandlers(ipcMain: IpcMain): void {
}); });
ipcMain.handle('window:close', () => { ipcMain.handle('window:close', () => {
const win = getMainWindow(); app.quit();
if (win && !win.isDestroyed()) win.close();
}); });
ipcMain.handle('window:isMaximized', (): boolean => { ipcMain.handle('window:isMaximized', (event): boolean => {
const win = getMainWindow(); const win = getWindowForEvent(event);
return win != null && !win.isDestroyed() && win.isMaximized(); return win != null && !win.isDestroyed() && win.isMaximized();
}); });
ipcMain.handle(WINDOW_IS_FULLSCREEN, (): boolean => { ipcMain.handle(WINDOW_IS_FULLSCREEN, (event): boolean => {
const win = getMainWindow(); const win = getWindowForEvent(event);
return win != null && !win.isDestroyed() && win.isFullScreen(); return win != null && !win.isDestroyed() && win.isFullScreen();
}); });
ipcMain.handle('app:relaunch', () => { ipcMain.handle('app:relaunch', () => {
app.relaunch(); app.relaunch();
app.exit(0); app.quit();
}); });
logger.info('Window handlers registered'); logger.info('Window handlers registered');

View file

@ -155,7 +155,7 @@ function githubGet(url: string): Promise<{ statusCode: number; body: string }> {
{ {
headers: { headers: {
Accept: 'application/vnd.github.v3+json', Accept: 'application/vnd.github.v3+json',
'User-Agent': 'claude-devtools', 'User-Agent': 'agent-teams-ui',
}, },
}, },
(res) => { (res) => {

View file

@ -1,5 +1,5 @@
/** /**
* ConfigManager service - Manages app configuration stored at ~/.claude/claude-devtools-config.json. * ConfigManager service - Manages app configuration stored at ~/.claude/agent-teams-config.json.
* *
* Responsibilities: * Responsibilities:
* - Load configuration from disk on initialization * - Load configuration from disk on initialization
@ -25,10 +25,51 @@ import type { SshConnectionProfile } from '@shared/types/api';
const logger = createLogger('Service:ConfigManager'); const logger = createLogger('Service:ConfigManager');
const CONFIG_FILENAME = 'claude-devtools-config.json'; const CONFIG_FILENAME = 'agent-teams-config.json';
const LEGACY_CONFIG_FILENAMES = [
'claude-devtools-config.json',
'claude-code-context-config.json',
] as const;
function getDefaultConfigPath(): string { function getDefaultConfigPath(): string {
return path.join(getClaudeBasePath(), CONFIG_FILENAME); const basePath = getClaudeBasePath();
return migrateLegacyConfigPath(
path.join(basePath, CONFIG_FILENAME),
LEGACY_CONFIG_FILENAMES.map((filename) => path.join(basePath, filename))
);
}
function migrateLegacyConfigPath(currentPath: string, legacyPaths: string[]): string {
if (fs.existsSync(currentPath)) {
return currentPath;
}
const legacyPath = selectLegacyConfigPath(legacyPaths);
if (!legacyPath) {
return currentPath;
}
try {
fs.mkdirSync(path.dirname(currentPath), { recursive: true });
fs.copyFileSync(legacyPath, currentPath, fs.constants.COPYFILE_EXCL);
return currentPath;
} catch {
return fs.existsSync(currentPath) ? currentPath : legacyPath;
}
}
function selectLegacyConfigPath(legacyPaths: string[]): string | null {
const existingPaths = legacyPaths.filter((candidatePath) => fs.existsSync(candidatePath));
return existingPaths.find(isReadableJsonObjectFile) ?? existingPaths[0] ?? null;
}
function isReadableJsonObjectFile(filePath: string): boolean {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
} }
// =========================================================================== // ===========================================================================

View file

@ -2,7 +2,7 @@
* NotificationManager service - Manages native notifications and notification history. * NotificationManager service - Manages native notifications and notification history.
* *
* Responsibilities: * Responsibilities:
* - Store notification history at ~/.claude/claude-devtools-notifications.json (max 100 entries) * - Store notification history at ~/.claude/agent-teams-notifications.json (max 100 entries)
* - Show native notifications using Electron's Notification API (cross-platform) * - Show native notifications using Electron's Notification API (cross-platform)
* - Two adapters: addError() for error notifications, addTeamNotification() for team events * - Two adapters: addError() for error notifications, addTeamNotification() for team events
* - Shared internal pipeline: storeNotification() for unconditional storage + IPC emission * - Shared internal pipeline: storeNotification() for unconditional storage + IPC emission
@ -93,7 +93,19 @@ const MAX_NOTIFICATIONS = 100;
const THROTTLE_MS = 5000; const THROTTLE_MS = 5000;
/** Path to notifications storage file */ /** Path to notifications storage file */
const NOTIFICATIONS_PATH = path.join(getHomeDir(), '.claude', 'claude-devtools-notifications.json'); const NOTIFICATIONS_PATH = path.join(getHomeDir(), '.claude', 'agent-teams-notifications.json');
const LEGACY_NOTIFICATION_FILENAMES = [
'claude-devtools-notifications.json',
'claude-code-context-notifications.json',
] as const;
const LEGACY_NOTIFICATION_PATHS = LEGACY_NOTIFICATION_FILENAMES.map((filename) =>
path.join(getHomeDir(), '.claude', filename)
);
interface LegacyNotificationData {
path: string;
data: string;
}
type NotificationEventName = 'click' | 'close' | 'show' | 'failed'; type NotificationEventName = 'click' | 'close' | 'show' | 'failed';
@ -111,6 +123,64 @@ function getNotificationClass(): NotificationClass | null {
return (ElectronNotification as NotificationClass | undefined) ?? null; return (ElectronNotification as NotificationClass | undefined) ?? null;
} }
async function migrateLegacyNotificationPath(): Promise<string> {
try {
await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
return NOTIFICATIONS_PATH;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
return NOTIFICATIONS_PATH;
}
}
const legacyNotificationData = await selectLegacyNotificationData();
if (!legacyNotificationData) {
return NOTIFICATIONS_PATH;
}
try {
await fsp.mkdir(path.dirname(NOTIFICATIONS_PATH), { recursive: true });
await fsp.writeFile(NOTIFICATIONS_PATH, legacyNotificationData.data, {
encoding: 'utf8',
flag: 'wx',
});
return NOTIFICATIONS_PATH;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return NOTIFICATIONS_PATH;
}
return legacyNotificationData.path;
}
}
async function selectLegacyNotificationData(): Promise<LegacyNotificationData | null> {
const readableData: LegacyNotificationData[] = [];
for (const legacyPath of LEGACY_NOTIFICATION_PATHS) {
try {
const legacyData = await fsp.readFile(legacyPath, 'utf8');
const candidate = { path: legacyPath, data: legacyData };
if (isNotificationHistoryJson(legacyData)) {
return candidate;
}
readableData.push(candidate);
} catch {
// Continue to older legacy filenames.
}
}
return readableData[0] ?? null;
}
function isNotificationHistoryJson(data: string): boolean {
try {
return Array.isArray(JSON.parse(data));
} catch {
return false;
}
}
// ============================================================================= // =============================================================================
// NotificationManager Class // NotificationManager Class
// ============================================================================= // =============================================================================
@ -133,6 +203,7 @@ export class NotificationManager extends EventEmitter {
* Used by addError() to wait for notifications to be loaded from disk * Used by addError() to wait for notifications to be loaded from disk
* before writing, preventing a race where save overwrites unloaded data. */ * before writing, preventing a race where save overwrites unloaded data. */
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
private notificationsPath = NOTIFICATIONS_PATH;
constructor(configManager?: ConfigManager) { constructor(configManager?: ConfigManager) {
super(); super();
@ -183,6 +254,7 @@ export class NotificationManager extends EventEmitter {
return; return;
} }
this.notificationsPath = await migrateLegacyNotificationPath();
await this.loadNotifications(); await this.loadNotifications();
this.pruneNotifications(); this.pruneNotifications();
this.isInitialized = true; this.isInitialized = true;
@ -208,7 +280,7 @@ export class NotificationManager extends EventEmitter {
*/ */
private async loadNotifications(): Promise<void> { private async loadNotifications(): Promise<void> {
try { try {
const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8'); const data = await fsp.readFile(this.notificationsPath, 'utf8');
const parsed = JSON.parse(data) as unknown; const parsed = JSON.parse(data) as unknown;
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
@ -233,11 +305,11 @@ export class NotificationManager extends EventEmitter {
*/ */
private saveNotifications(): void { private saveNotifications(): void {
const data = JSON.stringify(this.notifications, null, 2); const data = JSON.stringify(this.notifications, null, 2);
const dir = path.dirname(NOTIFICATIONS_PATH); const dir = path.dirname(this.notificationsPath);
fsp fsp
.mkdir(dir, { recursive: true }) .mkdir(dir, { recursive: true })
.then(() => fsp.writeFile(NOTIFICATIONS_PATH, data, 'utf8')) .then(() => fsp.writeFile(this.notificationsPath, data, 'utf8'))
.catch((error) => { .catch((error) => {
logger.error('Error saving notifications:', error); logger.error('Error saving notifications:', error);
}); });

View file

@ -21,8 +21,8 @@ const { autoUpdater } = electronUpdater;
import { app, net } from 'electron'; import { app, net } from 'electron';
import { import {
getExpectedReleaseAssetUrl, getExpectedReleaseAssetUrls,
getLatestMacMetadataUrl, getLatestMacMetadataUrls,
isLatestMacMetadataCompatible, isLatestMacMetadataCompatible,
} from './updaterReleaseMetadata'; } from './updaterReleaseMetadata';
@ -44,6 +44,15 @@ async function assetExists(url: string): Promise<boolean> {
} }
} }
async function assetExistsInAnyRepo(urls: readonly string[]): Promise<boolean> {
for (const url of urls) {
if (await assetExists(url)) {
return true;
}
}
return false;
}
async function fetchText(url: string): Promise<string | null> { async function fetchText(url: string): Promise<string | null> {
try { try {
const response = await net.fetch(url, { method: 'GET' }); const response = await net.fetch(url, { method: 'GET' });
@ -60,6 +69,7 @@ export class UpdaterService {
private mainWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow | null = null;
private periodicTimer: ReturnType<typeof setInterval> | null = null; private periodicTimer: ReturnType<typeof setInterval> | null = null;
private downloadedVersion: string | null = null; private downloadedVersion: string | null = null;
private beforeQuitAndInstall: (() => Promise<void>) | null = null;
constructor() { constructor() {
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
@ -75,6 +85,10 @@ export class UpdaterService {
this.mainWindow = window; this.mainWindow = window;
} }
setBeforeQuitAndInstall(handler: (() => Promise<void>) | null): void {
this.beforeQuitAndInstall = handler;
}
/** /**
* Check for available updates. * Check for available updates.
*/ */
@ -104,7 +118,7 @@ export class UpdaterService {
* On Windows (NSIS): isSilent=true runs the installer with /S (no wizard); * On Windows (NSIS): isSilent=true runs the installer with /S (no wizard);
* isForceRunAfter=true launches the app after install. Other platforms ignore these. * isForceRunAfter=true launches the app after install. Other platforms ignore these.
*/ */
quitAndInstall(): void { async quitAndInstall(): Promise<void> {
if (!this.downloadedVersion || !this.isNewerThanCurrent(this.downloadedVersion)) { if (!this.downloadedVersion || !this.isNewerThanCurrent(this.downloadedVersion)) {
logger.warn( logger.warn(
`Refusing to install non-newer update. current=${app.getVersion()} downloaded=${this.downloadedVersion ?? 'unknown'}` `Refusing to install non-newer update. current=${app.getVersion()} downloaded=${this.downloadedVersion ?? 'unknown'}`
@ -116,6 +130,7 @@ export class UpdaterService {
return; return;
} }
await this.beforeQuitAndInstall?.();
autoUpdater.quitAndInstall(true, true); autoUpdater.quitAndInstall(true, true);
} }
@ -156,14 +171,16 @@ export class UpdaterService {
return false; return false;
} }
const metadataUrl = getLatestMacMetadataUrl(version); const metadataUrls = getLatestMacMetadataUrls(version);
for (const metadataUrl of metadataUrls) {
const metadataText = await fetchText(metadataUrl); const metadataText = await fetchText(metadataUrl);
if (!metadataText) { if (metadataText && isLatestMacMetadataCompatible(metadataText, version, process.arch)) {
logger.warn(`latest-mac.yml is not available for ${version} (${metadataUrl})`); return true;
return false; }
} }
return isLatestMacMetadataCompatible(metadataText, version, process.arch); logger.warn(`latest-mac.yml is not compatible or available for ${version}`);
return false;
} }
/** /**
@ -182,12 +199,12 @@ export class UpdaterService {
return; return;
} }
const url = getExpectedReleaseAssetUrl(info.version, process.platform, process.arch); const urls = getExpectedReleaseAssetUrls(info.version, process.platform, process.arch);
if (url) { if (urls.length > 0) {
const exists = await assetExists(url); const exists = await assetExistsInAnyRepo(urls);
if (!exists) { if (!exists) {
logger.warn( logger.warn(
`Asset not yet available for ${process.platform}/${process.arch}, suppressing update notification (${url})` `Asset not yet available for ${process.platform}/${process.arch}, suppressing update notification`
); );
return; return;
} }

View file

@ -113,7 +113,7 @@ export class CodexAppServerSessionFactory {
'initialize', 'initialize',
{ {
clientInfo: { clientInfo: {
name: 'claude-agent-teams-ui', name: 'agent-teams-ai',
title: 'Agent Teams UI', title: 'Agent Teams UI',
version: '0.1.0', version: '0.1.0',
}, },

View file

@ -1,8 +1,13 @@
const REPO_OWNER = '777genius'; const REPO_OWNER = '777genius';
const REPO_NAME = 'claude_agent_teams_ui'; const REPO_NAME = 'claude_agent_teams_ui';
const PLANNED_REPO_NAME = 'agent-teams-ai';
export function buildReleaseAssetBase(version: string): string { export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string {
return `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/v${version}`; return `https://github.com/${REPO_OWNER}/${repoName}/releases/download/v${version}`;
}
export function buildReleaseAssetBases(version: string): readonly string[] {
return [buildReleaseAssetBase(version), buildReleaseAssetBase(version, PLANNED_REPO_NAME)];
} }
export function getExpectedReleaseAssetUrl( export function getExpectedReleaseAssetUrl(
@ -16,7 +21,7 @@ export function getExpectedReleaseAssetUrl(
case 'darwin': case 'darwin':
return arch === 'arm64' return arch === 'arm64'
? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg` ? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg`
: `${base}/Claude.Agent.Teams.UI-${version}.dmg`; : `${base}/Claude.Agent.Teams.UI-${version}-x64.dmg`;
case 'win32': case 'win32':
return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`; return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`;
case 'linux': case 'linux':
@ -26,10 +31,28 @@ export function getExpectedReleaseAssetUrl(
} }
} }
export function getExpectedReleaseAssetUrls(
version: string,
platform: NodeJS.Platform,
arch: NodeJS.Architecture
): readonly string[] {
const assetUrl = getExpectedReleaseAssetUrl(version, platform, arch);
if (!assetUrl) {
return [];
}
const primaryBase = buildReleaseAssetBase(version);
return buildReleaseAssetBases(version).map((base) => assetUrl.replace(primaryBase, base));
}
export function getLatestMacMetadataUrl(version: string): string { export function getLatestMacMetadataUrl(version: string): string {
return `${buildReleaseAssetBase(version)}/latest-mac.yml`; return `${buildReleaseAssetBase(version)}/latest-mac.yml`;
} }
export function getLatestMacMetadataUrls(version: string): readonly string[] {
return buildReleaseAssetBases(version).map((base) => `${base}/latest-mac.yml`);
}
export function getExpectedLatestMacArtifacts( export function getExpectedLatestMacArtifacts(
version: string, version: string,
arch: Extract<NodeJS.Architecture, 'arm64' | 'x64'> arch: Extract<NodeJS.Architecture, 'arm64' | 'x64'>
@ -39,7 +62,7 @@ export function getExpectedLatestMacArtifacts(
`Claude.Agent.Teams.UI-${version}-arm64-mac.zip`, `Claude.Agent.Teams.UI-${version}-arm64-mac.zip`,
`Claude.Agent.Teams.UI-${version}-arm64.dmg`, `Claude.Agent.Teams.UI-${version}-arm64.dmg`,
] ]
: [`Claude.Agent.Teams.UI-${version}-mac.zip`, `Claude.Agent.Teams.UI-${version}.dmg`]; : [`Claude.Agent.Teams.UI-${version}-x64-mac.zip`, `Claude.Agent.Teams.UI-${version}-x64.dmg`];
} }
function stripYamlScalar(rawValue: string): string { function stripYamlScalar(rawValue: string): string {

View file

@ -1,11 +1,11 @@
import { execCli } from '@main/utils/childProcess'; import { execCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
import { import {
createDefaultCliExtensionCapabilities, createDefaultCliExtensionCapabilities,
createLegacyRuntimeFallbackCliExtensionCapabilities, createLegacyRuntimeFallbackCliExtensionCapabilities,
} from '@shared/utils/providerExtensionCapabilities'; } from '@shared/utils/providerExtensionCapabilities';
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv';

View file

@ -11,7 +11,11 @@ import type {
CodexAccountSnapshotDto, CodexAccountSnapshotDto,
} from '@features/codex-account/contracts'; } from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main'; import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
import type { CodexModelCatalogFeatureFacade } from '@features/codex-model-catalog/main'; import type { CodexModelCatalogDto } from '@features/codex-model-catalog';
import type {
CodexModelCatalogFeatureFacade,
CodexModelCatalogRequest,
} from '@features/codex-model-catalog/main';
import type { import type {
CliProviderAuthMode, CliProviderAuthMode,
CliProviderConnectionInfo, CliProviderConnectionInfo,
@ -107,6 +111,20 @@ export class ProviderConnectionService {
this.codexModelCatalogFeature = feature; this.codexModelCatalogFeature = feature;
} }
async getCodexModelCatalog(
request: CodexModelCatalogRequest = {}
): Promise<CodexModelCatalogDto | null> {
if (!this.codexModelCatalogFeature) {
return null;
}
try {
return await this.codexModelCatalogFeature.getCatalog(request);
} catch {
return null;
}
}
setApiKeyService(apiKeyService: ApiKeyService): void { setApiKeyService(apiKeyService: ApiKeyService): void {
this.apiKeyService = apiKeyService; this.apiKeyService = apiKeyService;
} }

View file

@ -1,5 +1,6 @@
import { getTasksBasePath } from '@main/utils/pathDecoder'; import { getTasksBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
import { import {
getTaskChangeStateBucket, getTaskChangeStateBucket,
isTaskChangeSummaryCacheable, isTaskChangeSummaryCacheable,
@ -11,6 +12,7 @@ import * as path from 'path';
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
import { TaskChangeComputer } from './TaskChangeComputer'; import { TaskChangeComputer } from './TaskChangeComputer';
import { TaskChangeLedgerReader } from './TaskChangeLedgerReader';
import { import {
buildTaskChangePresenceDescriptor, buildTaskChangePresenceDescriptor,
computeTaskChangePresenceProjectFingerprint, computeTaskChangePresenceProjectFingerprint,
@ -22,7 +24,6 @@ import {
type TaskChangeEffectiveOptions, type TaskChangeEffectiveOptions,
type TaskChangeTaskMeta, type TaskChangeTaskMeta,
} from './taskChangeWorkerTypes'; } from './taskChangeWorkerTypes';
import { TaskChangeLedgerReader } from './TaskChangeLedgerReader';
import { TeamConfigReader } from './TeamConfigReader'; import { TeamConfigReader } from './TeamConfigReader';
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
@ -31,7 +32,6 @@ import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient';
import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types';
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
const logger = createLogger('Service:ChangeExtractorService'); const logger = createLogger('Service:ChangeExtractorService');

View file

@ -42,8 +42,9 @@ export class FileContentResolver {
/** Invalidate cached content for a file (e.g. after user saves edits) */ /** Invalidate cached content for a file (e.g. after user saves edits) */
invalidateFile(filePath: string): void { invalidateFile(filePath: string): void {
const normalizedFilePath = this.normalizeResolverPath(filePath);
for (const key of this.cache.keys()) { for (const key of this.cache.keys()) {
if (key.endsWith(`:${filePath}`)) { if (key.endsWith(`:${normalizedFilePath}`)) {
this.cache.delete(key); this.cache.delete(key);
} }
} }
@ -76,7 +77,7 @@ export class FileContentResolver {
logger.debug(`Файл недоступен на диске: ${filePath}`); logger.debug(`Файл недоступен на диске: ${filePath}`);
} }
const cacheKey = `${teamName}:${memberName}:${filePath}`; const cacheKey = this.buildCacheKey(teamName, memberName, filePath);
const validationFingerprint = this.buildValidationFingerprint( const validationFingerprint = this.buildValidationFingerprint(
filePath, filePath,
currentContent, currentContent,
@ -294,6 +295,15 @@ export class FileContentResolver {
const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null); const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null);
const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : null); const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : null);
if (original === null && modified === null) { if (original === null && modified === null) {
const hasUnavailableLedgerState = ledgerSnippets.some(
(snippet) =>
snippet.ledger?.beforeState?.unavailableReason ||
snippet.ledger?.afterState?.unavailableReason ||
snippet.ledger?.textAvailability === 'unavailable'
);
if (hasUnavailableLedgerState) {
return { original: null, modified: null, source: 'unavailable' };
}
return null; return null;
} }
@ -613,6 +623,10 @@ export class FileContentResolver {
return normalizePathForComparison(filePath); return normalizePathForComparison(filePath);
} }
private buildCacheKey(teamName: string, memberName: string, filePath: string): string {
return `${teamName}:${memberName}:${this.normalizeResolverPath(filePath)}`;
}
private hashString(input: string): string { private hashString(input: string): string {
return createHash('sha256').update(input).digest('hex'); return createHash('sha256').update(input).digest('hex');
} }

View file

@ -1,5 +1,6 @@
import { computeDiffContextHash } from '@shared/utils/diffContextHash'; import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { applyPatch, structuredPatch } from 'diff'; import { applyPatch, structuredPatch } from 'diff';
import { mkdir, readFile, unlink, writeFile } from 'fs/promises'; import { mkdir, readFile, unlink, writeFile } from 'fs/promises';
@ -611,7 +612,10 @@ export class ReviewApplierService {
'Ledger before content is unavailable; rejecting this change requires manual review.', 'Ledger before content is unavailable; rejecting this change requires manual review.',
}; };
} }
const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256); const guard = await this.checkLedgerCurrentHash(
filePath,
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
);
if (!guard.ok) { if (!guard.ok) {
return guard.outcome; return guard.outcome;
} }
@ -629,8 +633,19 @@ export class ReviewApplierService {
} }
private resolveLedgerOperation(snippets: SnippetDiff[]): 'create' | 'modify' | 'delete' { private resolveLedgerOperation(snippets: SnippetDiff[]): 'create' | 'modify' | 'delete' {
if (snippets.some((snippet) => snippet.ledger?.operation === 'create')) return 'create'; const ledgerSnippets = snippets.filter((snippet) => snippet.ledger);
if (snippets[snippets.length - 1]?.ledger?.operation === 'delete') return 'delete'; const firstLedger = ledgerSnippets[0]?.ledger;
const lastLedger = ledgerSnippets[ledgerSnippets.length - 1]?.ledger;
const baselineExists = firstLedger?.beforeState?.exists;
const finalExists = lastLedger?.afterState?.exists;
if (baselineExists === false && finalExists === true) return 'create';
if (baselineExists === true && finalExists === false) return 'delete';
if (baselineExists === true && finalExists === true) return 'modify';
if (baselineExists === false && finalExists === false) return 'create';
if (lastLedger?.operation === 'delete') return 'delete';
if (firstLedger?.operation === 'create') return 'create';
return 'modify'; return 'modify';
} }
@ -743,8 +758,13 @@ export class ReviewApplierService {
} }
private pathMatchesRelationPath(filePath: string, relationPath: string): boolean { private pathMatchesRelationPath(filePath: string, relationPath: string): boolean {
const normalizedFilePath = filePath.replace(/\\/g, '/'); const caseInsensitive =
const normalizedRelationPath = relationPath.replace(/\\/g, '/'); this.isWindowsReviewPath(filePath) || this.isWindowsReviewPath(relationPath);
const normalizedFilePath = this.normalizeRelationComparisonPath(filePath, caseInsensitive);
const normalizedRelationPath = this.normalizeRelationComparisonPath(
relationPath,
caseInsensitive
);
return ( return (
normalizedFilePath === normalizedRelationPath || normalizedFilePath === normalizedRelationPath ||
normalizedFilePath.endsWith(`/${normalizedRelationPath}`) normalizedFilePath.endsWith(`/${normalizedRelationPath}`)
@ -759,12 +779,35 @@ export class ReviewApplierService {
if (!anchorPath) { if (!anchorPath) {
return null; return null;
} }
const normalizedAnchor = anchorPath.replace(/\\/g, '/'); const slashAnchor = anchorPath.replace(/\\/g, '/');
const normalizedRelation = anchorRelationPath.replace(/\\/g, '/'); const slashRelation = anchorRelationPath.replace(/\\/g, '/');
if (!normalizedAnchor.endsWith(normalizedRelation)) { const caseInsensitive =
this.isWindowsReviewPath(anchorPath) || this.isWindowsReviewPath(anchorRelationPath);
const normalizedAnchor = this.normalizeRelationComparisonPath(anchorPath, caseInsensitive);
const normalizedRelation = this.normalizeRelationComparisonPath(
anchorRelationPath,
caseInsensitive
);
if (!this.matchesRelationSuffix(normalizedAnchor, normalizedRelation)) {
return null; return null;
} }
return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`; return `${slashAnchor.slice(0, slashAnchor.length - slashRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`;
}
private normalizeRelationComparisonPath(filePath: string, caseInsensitive: boolean): string {
const normalized = normalizePathForComparison(filePath);
return caseInsensitive ? normalized.toLowerCase() : normalized;
}
private isWindowsReviewPath(filePath: string): boolean {
return isWindowsishPath(filePath) || filePath.includes('\\');
}
private matchesRelationSuffix(normalizedPath: string, normalizedRelationPath: string): boolean {
return (
normalizedPath === normalizedRelationPath ||
normalizedPath.endsWith(`/${normalizedRelationPath}`)
);
} }
private async checkLedgerCurrentHash( private async checkLedgerCurrentHash(

View file

@ -624,7 +624,7 @@ export class TaskChangeComputer {
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
return { return {
toolUseId: snippet.toolUseId, toolUseId: snippet.toolUseId,
toolName: snippet.toolName as FileEditEvent['toolName'], toolName: snippet.toolName,
timestamp: snippet.timestamp, timestamp: snippet.timestamp,
summary: this.generateEditSummary(snippet), summary: this.generateEditSummary(snippet),
linesAdded: added, linesAdded: added,

View file

@ -1,11 +1,10 @@
import { createLogger } from '@shared/utils/logger';
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { diffLines } from 'diff'; import { diffLines } from 'diff';
import { open, readFile } from 'fs/promises'; import { open, readFile } from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { normalizePathForComparison } from '@shared/utils/platformPath';
import { createLogger } from '@shared/utils/logger';
import type { import type {
FileChangeSummary, FileChangeSummary,
FileEditEvent, FileEditEvent,
@ -66,6 +65,16 @@ function taskArtifactPathCandidates(
); );
} }
function decodeLedgerTextBlob(buffer: Buffer): string | null {
for (const byte of buffer) {
if (byte === 0 || byte < 9 || (byte > 13 && byte < 32)) {
return null;
}
}
const text = buffer.toString('utf8');
return Buffer.from(text, 'utf8').equals(buffer) ? text : null;
}
type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous'; type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous';
interface LedgerContentRef { interface LedgerContentRef {
@ -191,7 +200,7 @@ interface LedgerSummaryScopeV2 {
toolUseIds: string[]; toolUseIds: string[];
toolUseCount: number; toolUseCount: number;
toolUseIdsTruncated?: boolean; toolUseIdsTruncated?: boolean;
phaseSet: Array<'work' | 'review'>; phaseSet: ('work' | 'review')[];
executionSeqRange?: { start: number; end: number }; executionSeqRange?: { start: number; end: number };
confidenceBreakdown?: TaskChangeScope['confidenceBreakdown']; confidenceBreakdown?: TaskChangeScope['confidenceBreakdown'];
visibleFileCount: number; visibleFileCount: number;
@ -270,23 +279,23 @@ interface LedgerFreshnessV2 {
bundleKind: 'summary'; bundleKind: 'summary';
} }
type JournalReadResult<T> = { interface JournalReadResult<T> {
entries: T[]; entries: T[];
recovered: boolean; recovered: boolean;
}; }
type JournalData = { interface JournalData {
events: LedgerEvent[]; events: LedgerEvent[];
notices: LedgerNotice[]; notices: LedgerNotice[];
recovered: boolean; recovered: boolean;
}; }
type SummaryBundleRead = { interface SummaryBundleRead {
bundle: LedgerSummaryBundleV2; bundle: LedgerSummaryBundleV2;
provenance: TaskChangeProvenance; provenance: TaskChangeProvenance;
mode: 'validated' | 'degraded'; mode: 'validated' | 'degraded';
degradedWarning?: string; degradedWarning?: string;
}; }
export class TaskChangeLedgerReader { export class TaskChangeLedgerReader {
async readTaskChanges(params: { async readTaskChanges(params: {
@ -1060,10 +1069,10 @@ export class TaskChangeLedgerReader {
return null; return null;
} }
try { try {
return await readFile( const buffer = await readFile(
path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef), path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef)
'utf8'
); );
return decodeLedgerTextBlob(buffer);
} catch { } catch {
return null; return null;
} }
@ -1198,6 +1207,14 @@ export class TaskChangeLedgerReader {
} }
const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets); const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets);
const worktreeLedger = entry.snippets.find((snippet) => snippet.ledger?.worktreePath)?.ledger; const worktreeLedger = entry.snippets.find((snippet) => snippet.ledger?.worktreePath)?.ledger;
const firstLedger = entry.snippets.find((snippet) => snippet.ledger)?.ledger;
const lastLedger = [...entry.snippets].reverse().find((snippet) => snippet.ledger)?.ledger;
const baselineExists = firstLedger?.beforeState?.exists;
const finalExists = lastLedger?.afterState?.exists;
const isCreatedLifecycle = baselineExists === false && finalExists === true;
const fallbackIsCreated = entry.snippets.some(
(snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create'
);
files.push({ files.push({
filePath: displayPath, filePath: displayPath,
relativePath: this.relativePath(displayPath, projectPath), relativePath: this.relativePath(displayPath, projectPath),
@ -1206,9 +1223,9 @@ export class TaskChangeLedgerReader {
linesRemoved, linesRemoved,
isNewFile: isNewFile:
relation?.kind !== 'rename' && relation?.kind !== 'rename' &&
entry.snippets.some( (baselineExists === undefined || finalExists === undefined
(snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create' ? fallbackIsCreated
), : isCreatedLifecycle),
changeKey: relation changeKey: relation
? this.relationChangeKey(relation, worktreeLedger?.worktreePath) ? this.relationChangeKey(relation, worktreeLedger?.worktreePath)
: `path:${normalizePathForComparison(displayPath)}`, : `path:${normalizePathForComparison(displayPath)}`,
@ -1428,8 +1445,13 @@ export class TaskChangeLedgerReader {
} }
private pathMatchesRelationPath(filePath: string, relationPath: string): boolean { private pathMatchesRelationPath(filePath: string, relationPath: string): boolean {
const normalizedFilePath = filePath.replace(/\\/g, '/'); const caseInsensitive =
const normalizedRelationPath = relationPath.replace(/\\/g, '/'); this.isWindowsReviewPath(filePath) || this.isWindowsReviewPath(relationPath);
const normalizedFilePath = this.normalizeRelationComparisonPath(filePath, caseInsensitive);
const normalizedRelationPath = this.normalizeRelationComparisonPath(
relationPath,
caseInsensitive
);
return ( return (
normalizedFilePath === normalizedRelationPath || normalizedFilePath === normalizedRelationPath ||
normalizedFilePath.endsWith(`/${normalizedRelationPath}`) normalizedFilePath.endsWith(`/${normalizedRelationPath}`)
@ -1441,14 +1463,37 @@ export class TaskChangeLedgerReader {
anchorRelationPath: string, anchorRelationPath: string,
targetRelationPath: string targetRelationPath: string
): string | null { ): string | null {
const normalizedAnchor = anchorPath.replace(/\\/g, '/'); const slashAnchor = anchorPath.replace(/\\/g, '/');
const normalizedAnchorRelation = anchorRelationPath.replace(/\\/g, '/'); const slashAnchorRelation = anchorRelationPath.replace(/\\/g, '/');
if (!normalizedAnchor.endsWith(normalizedAnchorRelation)) { const caseInsensitive =
this.isWindowsReviewPath(anchorPath) || this.isWindowsReviewPath(anchorRelationPath);
const normalizedAnchor = this.normalizeRelationComparisonPath(anchorPath, caseInsensitive);
const normalizedAnchorRelation = this.normalizeRelationComparisonPath(
anchorRelationPath,
caseInsensitive
);
if (!this.matchesRelationSuffix(normalizedAnchor, normalizedAnchorRelation)) {
return null; return null;
} }
return this.normalizeLedgerFilePath( return this.normalizeLedgerFilePath(
`${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}` `${slashAnchor.slice(0, slashAnchor.length - slashAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`
);
}
private normalizeRelationComparisonPath(filePath: string, caseInsensitive: boolean): string {
const normalized = normalizePathForComparison(filePath);
return caseInsensitive ? normalized.toLowerCase() : normalized;
}
private isWindowsReviewPath(filePath: string): boolean {
return isWindowsishPath(filePath) || filePath.includes('\\');
}
private matchesRelationSuffix(normalizedPath: string, normalizedRelationPath: string): boolean {
return (
normalizedPath === normalizedRelationPath ||
normalizedPath.endsWith(`/${normalizedRelationPath}`)
); );
} }

View file

@ -13,8 +13,8 @@ import { readBootstrapLaunchSnapshot } from './TeamBootstrapStateReader';
import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
import { import {
type LaunchStateSummary,
choosePreferredLaunchStateSummary, choosePreferredLaunchStateSummary,
type LaunchStateSummary,
normalizePersistedLaunchSummaryProjection, normalizePersistedLaunchSummaryProjection,
shouldSuppressLegacyLaunchArtifactHeuristic, shouldSuppressLegacyLaunchArtifactHeuristic,
TEAM_LAUNCH_SUMMARY_FILE, TEAM_LAUNCH_SUMMARY_FILE,

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