feat(team): expand opencode review and release support
This commit is contained in:
parent
ba4de3775b
commit
1c07e0fdb6
220 changed files with 24409 additions and 1665 deletions
8
.github/CLA.md
vendored
8
.github/CLA.md
vendored
|
|
@ -1,6 +1,6 @@
|
|||
# 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)
|
||||
|
||||
---
|
||||
|
|
@ -15,10 +15,10 @@ You represent that you have the right to grant the above license and that your c
|
|||
|
||||
## Signed contributors
|
||||
|
||||
| Name | Email | Date |
|
||||
|------|-------|------|
|
||||
| Name | Email | Date |
|
||||
| ---------------- | ---------------------- | ---------- |
|
||||
| Илия (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._
|
||||
|
|
|
|||
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
|
@ -1,6 +1,6 @@
|
|||
# Contributing
|
||||
|
||||
Thanks for contributing to Claude Agent Teams UI!
|
||||
Thanks for contributing to Agent Teams!
|
||||
|
||||
## Before You Start
|
||||
|
||||
|
|
|
|||
8
.github/SECURITY.md
vendored
8
.github/SECURITY.md
vendored
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 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 |
|
||||
|---|---|---|---|
|
||||
|
|
@ -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.
|
||||
- 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.
|
||||
|
||||
## 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`:
|
||||
|
||||
```bash
|
||||
docker build -t claude-agent-teams-ui -f docker/Dockerfile .
|
||||
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
|
||||
docker build -t agent-teams-ai -f docker/Dockerfile .
|
||||
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`.
|
||||
|
|
|
|||
2
.github/workflows/landing.yml
vendored
2
.github/workflows/landing.yml
vendored
|
|
@ -33,6 +33,8 @@ jobs:
|
|||
working-directory: landing
|
||||
env:
|
||||
NUXT_APP_BASE_URL: /claude_agent_teams_ui/
|
||||
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/claude_agent_teams_ui
|
||||
NUXT_PUBLIC_GITHUB_REPO: 777genius/claude_agent_teams_ui
|
||||
run: npx nuxt generate
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -254,7 +254,7 @@ jobs:
|
|||
run: ${{ matrix.dist_command }} --publish never
|
||||
|
||||
- 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
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
|
@ -498,7 +498,7 @@ jobs:
|
|||
|
||||
declare -A FILES=(
|
||||
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"
|
||||
["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}.dmg"
|
||||
["Claude-Agent-Teams-UI-x64.dmg"]="Claude.Agent.Teams.UI-${VERSION}-x64.dmg"
|
||||
["Claude-Agent-Teams-UI-Setup.exe"]="Claude.Agent.Teams.UI.Setup.${VERSION}.exe"
|
||||
["Claude-Agent-Teams-UI.AppImage"]="Claude.Agent.Teams.UI-${VERSION}.AppImage"
|
||||
["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
<a href="docs/screenshots/7.png"><img src="docs/screenshots/7.png" width="75" alt="Code Review" /></a>
|
||||
<a href="docs/screenshots/2.jpg"><img src="docs/screenshots/2.jpg" width="75" alt="Team View" /></a>
|
||||
<a href="docs/screenshots/8.png"><img src="docs/screenshots/8.png" width="75" alt="Task Detail" /></a>
|
||||
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="80" />
|
||||
<img src="resources/icons/png/1024x1024.png" alt="Agent Teams" width="80" />
|
||||
<a href="docs/screenshots/9.png"><img src="docs/screenshots/9.png" width="75" alt="Execution Logs" /></a>
|
||||
<a href="docs/screenshots/3.jpg"><img src="docs/screenshots/3.png" width="75" alt="Agent Comments" /></a>
|
||||
<a href="docs/screenshots/4.png"><img src="docs/screenshots/4.png" width="75" alt="Create Team" /></a>
|
||||
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams UI</a></h1>
|
||||
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams</a></h1>
|
||||
|
||||
<p align="center">
|
||||
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee.</code></strong>
|
||||
|
|
@ -169,7 +169,7 @@ For feature architecture and implementation guidance:
|
|||
|
||||
## 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** | ✅ | ❌ | ❌ | — | ❌ |
|
||||
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) |
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
const kanbanStore = require('./kanbanStore.js');
|
||||
const taskStore = require('./taskStore.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const reviewStateHelpers = require('./reviewState.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 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) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
|
|
@ -15,28 +23,17 @@ function normalizeKey(value) {
|
|||
return normalizeName(value).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeReviewState(value) {
|
||||
const normalized = normalizeName(value);
|
||||
return REVIEW_STATES.has(normalized) ? normalized : 'none';
|
||||
}
|
||||
|
||||
function formatTaskLabel(task) {
|
||||
return `#${task.displayId || task.id}`;
|
||||
}
|
||||
|
||||
function isLeadCandidate(member) {
|
||||
if (!member || typeof member !== 'object') return false;
|
||||
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';
|
||||
return runtimeHelpers.isCanonicalLeadMember(member);
|
||||
}
|
||||
|
||||
function buildQueueRoster(paths) {
|
||||
const resolved = runtimeHelpers.resolveTeamMembers(paths);
|
||||
const explicit = runtimeHelpers.collectExplicitTeamMembers(paths);
|
||||
const membersByKey = new Map();
|
||||
|
||||
for (const member of resolved.members || []) {
|
||||
|
|
@ -61,6 +58,7 @@ function buildQueueRoster(paths) {
|
|||
|
||||
return {
|
||||
membersByKey,
|
||||
explicitMemberKeys: new Set(explicit.membersByKey.keys()),
|
||||
removedNames: resolved.removedNames || new Set(),
|
||||
leadAliases,
|
||||
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) {
|
||||
const normalized = normalizeName(value);
|
||||
if (!normalized) return null;
|
||||
|
|
@ -84,6 +128,7 @@ function resolveQueueActor(value, roster) {
|
|||
|
||||
const member = roster.membersByKey.get(key);
|
||||
if (!member) return null;
|
||||
if (!roster.explicitMemberKeys || !roster.explicitMemberKeys.has(key)) return null;
|
||||
|
||||
if (roster.canonicalLeadName && normalizeKey(member.name) === normalizeKey(roster.canonicalLeadName)) {
|
||||
return { kind: 'lead', memberName: roster.canonicalLeadName };
|
||||
|
|
@ -98,56 +143,8 @@ function areSameActors(left, right) {
|
|||
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) {
|
||||
const historyState = resolveReviewStateFromHistory(task);
|
||||
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',
|
||||
};
|
||||
return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry);
|
||||
}
|
||||
|
||||
function resolveLegacyKanbanReviewer(task, roster, options = {}) {
|
||||
|
|
@ -208,7 +205,10 @@ function resolveCurrentCycleReviewer(task, roster, options = {}) {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -247,17 +247,51 @@ function buildBoardState(paths, teamName) {
|
|||
const kanbanState = kanbanStore.readKanbanState(paths, teamName);
|
||||
const roster = buildQueueRoster(paths);
|
||||
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 {
|
||||
tasks: [...taskRows.tasks].sort(compareTasksByFreshness),
|
||||
tasksById,
|
||||
kanbanState,
|
||||
roster,
|
||||
anomalies: taskRows.anomalies.map((anomaly) => ({
|
||||
code: anomaly.code,
|
||||
detail: anomaly.detail,
|
||||
...(anomaly.taskId ? { taskId: anomaly.taskId } : {}),
|
||||
})),
|
||||
anomalies,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +332,14 @@ function getLastMeaningfulEventAt(task) {
|
|||
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) {
|
||||
const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined;
|
||||
const reviewStateResult = resolveEffectiveReviewState(task, kanbanEntry);
|
||||
|
|
@ -427,6 +469,8 @@ function buildAgendaItem(task, boardState) {
|
|||
|
||||
const watchers = buildWatchers(ownerActor, reviewActor, actionOwner);
|
||||
|
||||
const lastMeaningfulEventAt = getLastMeaningfulEventAt(task);
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
displayId: task.displayId,
|
||||
|
|
@ -447,7 +491,7 @@ function buildAgendaItem(task, boardState) {
|
|||
...(blockedByIds.length > 0 ? { blockedBy: blockedByIds } : {}),
|
||||
...(watchers.length > 0 ? { watchers } : {}),
|
||||
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
|
||||
...(getLastMeaningfulEventAt(task) ? { lastMeaningfulEventAt: getLastMeaningfulEventAt(task) } : {}),
|
||||
...(lastMeaningfulEventAt ? { lastMeaningfulEventAt } : {}),
|
||||
derivedFrom,
|
||||
_fullTask: task,
|
||||
};
|
||||
|
|
@ -539,14 +583,17 @@ function buildAgendaSnapshot(paths, teamName, actor) {
|
|||
});
|
||||
}
|
||||
|
||||
function buildInventoryRow(task, reviewState) {
|
||||
function buildInventoryRow(task, reviewState, kanbanEntry) {
|
||||
return {
|
||||
id: task.id,
|
||||
displayId: task.displayId,
|
||||
subject: task.subject,
|
||||
subject: truncateText(task.subject, MAX_SUBJECT_CHARS),
|
||||
status: task.status,
|
||||
...(normalizeName(task.owner) ? { owner: task.owner } : {}),
|
||||
reviewState,
|
||||
...(kanbanEntry && INVENTORY_KANBAN_COLUMNS.has(kanbanEntry.column)
|
||||
? { kanbanColumn: kanbanEntry.column }
|
||||
: {}),
|
||||
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
|
||||
...(Array.isArray(task.blockedBy) && task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
|
||||
...(Array.isArray(task.blocks) && task.blocks.length > 0 ? { blocks: task.blocks } : {}),
|
||||
|
|
@ -569,7 +616,7 @@ function matchesInventoryFilters(row, filters) {
|
|||
}
|
||||
if (normalizeName(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -590,7 +637,8 @@ function matchesInventoryFilters(row, filters) {
|
|||
|
||||
function listTaskInventory(paths, teamName, filters = {}) {
|
||||
return withTeamBoardLock(paths, () => {
|
||||
const boardState = buildBoardState(paths, teamName);
|
||||
const taskRows = taskStore.listTaskRows(paths);
|
||||
const kanbanState = kanbanStore.readKanbanState(paths, teamName);
|
||||
const resolvedRelatedTo = normalizeName(filters.relatedTo)
|
||||
? taskStore.resolveTaskRef(paths, filters.relatedTo)
|
||||
: '';
|
||||
|
|
@ -602,21 +650,42 @@ function listTaskInventory(paths, teamName, filters = {}) {
|
|||
? Math.max(1, Math.floor(filters.limit))
|
||||
: null;
|
||||
|
||||
const rows = boardState.tasks
|
||||
.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,
|
||||
...(resolvedRelatedTo ? { relatedTo: resolvedRelatedTo } : {}),
|
||||
...(resolvedBlockedBy ? { blockedBy: resolvedBlockedBy } : {}),
|
||||
})
|
||||
);
|
||||
const resolvedFilters = {
|
||||
...filters,
|
||||
...(resolvedRelatedTo ? { relatedTo: resolvedRelatedTo } : {}),
|
||||
...(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) {
|
||||
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) {
|
||||
|
|
@ -649,7 +718,7 @@ function appendExpandedTaskContext(lines, item) {
|
|||
if (!task || typeof task !== 'object') return;
|
||||
|
||||
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 : [];
|
||||
|
|
@ -658,17 +727,37 @@ function appendExpandedTaskContext(lines, item) {
|
|||
lines.push(' Comments:');
|
||||
for (const comment of comments.slice(-5)) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
const memberValidation = validateBriefingMember(paths, teamName, memberName);
|
||||
const snapshot = buildAgendaSnapshot(paths, teamName, {
|
||||
kind: 'member',
|
||||
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.`,
|
||||
];
|
||||
|
||||
if (snapshot.anomalies.length > 0) {
|
||||
if (memberValidation.warnings.length > 0 || snapshot.anomalies.length > 0) {
|
||||
lines.push('', 'Board warnings:');
|
||||
for (const anomaly of snapshot.anomalies) {
|
||||
lines.push(formatAnomalyLine(anomaly));
|
||||
for (const warning of memberValidation.warnings) {
|
||||
lines.push(`- ${warning}`);
|
||||
}
|
||||
appendAnomalies(lines, snapshot.anomalies);
|
||||
}
|
||||
|
||||
if (snapshot.actionable.length === 0 && snapshot.awareness.length === 0) {
|
||||
|
|
@ -693,19 +783,29 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
|||
|
||||
if (snapshot.actionable.length > 0) {
|
||||
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));
|
||||
if (item.status === 'in_progress' || item.reasonCode === 'needs_fix') {
|
||||
appendExpandedTaskContext(lines, item);
|
||||
if (expandedCount < MAX_EXPANDED_CONTEXT_ITEMS) {
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
appendOmittedLine(lines, 'Awareness', awarenessItems.length, snapshot.awareness.length);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
|
|
@ -761,9 +861,7 @@ function formatLeadBriefing(paths, teamName) {
|
|||
|
||||
if (snapshot.anomalies.length > 0) {
|
||||
lines.push('', 'Board anomalies:');
|
||||
for (const anomaly of snapshot.anomalies) {
|
||||
lines.push(formatAnomalyLine(anomaly));
|
||||
}
|
||||
appendAnomalies(lines, snapshot.anomalies);
|
||||
}
|
||||
|
||||
const sections = [
|
||||
|
|
@ -787,9 +885,11 @@ function formatLeadBriefing(paths, teamName) {
|
|||
if (!items || items.length === 0) continue;
|
||||
renderedAnySection = true;
|
||||
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));
|
||||
}
|
||||
appendOmittedLine(lines, title.replace(/:$/, ''), sectionItems.length, items.length);
|
||||
}
|
||||
|
||||
if (!renderedAnySection && snapshot.anomalies.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,62 @@
|
|||
const kanbanStore = require('./kanbanStore.js');
|
||||
const tasks = require('./tasks.js');
|
||||
const reviewStateHelpers = require('./reviewState.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.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) {
|
||||
return kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
}
|
||||
|
||||
function setKanbanColumn(context, taskId, column) {
|
||||
function setKanbanColumn(context, taskId, column, options = {}) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
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));
|
||||
return getKanbanState(context);
|
||||
});
|
||||
|
|
@ -17,6 +65,31 @@ function setKanbanColumn(context, taskId, column) {
|
|||
function clearKanban(context, taskId, options) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
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);
|
||||
return getKanbanState(context);
|
||||
});
|
||||
|
|
@ -28,6 +101,9 @@ function listReviewers(context) {
|
|||
|
||||
function addReviewer(context, reviewer) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
const state = getKanbanState(context);
|
||||
const next = new Set(state.reviewers);
|
||||
next.add(String(reviewer));
|
||||
|
|
|
|||
|
|
@ -70,16 +70,56 @@ function writeKanbanState(paths, 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) {
|
||||
if (column !== 'review' && column !== 'approved') {
|
||||
throw new Error(`Invalid kanban column: ${String(column)}`);
|
||||
}
|
||||
|
||||
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)] =
|
||||
column === 'review'
|
||||
? { column: 'review', reviewer: null, movedAt: nowIso() }
|
||||
: { column: 'approved', movedAt: nowIso() };
|
||||
appendTaskToColumnOrder(state, column, taskId);
|
||||
writeKanbanState(paths, teamName, state);
|
||||
taskStore.updateTask(paths, String(taskId), (task) => ({
|
||||
...task,
|
||||
|
|
@ -91,6 +131,7 @@ function setKanbanColumn(paths, teamName, taskId, column) {
|
|||
function clearKanban(paths, teamName, taskId, options = {}) {
|
||||
const state = readKanbanState(paths, teamName);
|
||||
delete state.tasks[String(taskId)];
|
||||
removeTaskFromColumnOrder(state, taskId);
|
||||
writeKanbanState(paths, teamName, state);
|
||||
const nextReviewState =
|
||||
typeof options.nextReviewState === 'string' ? options.nextReviewState : 'none';
|
||||
|
|
@ -133,7 +174,10 @@ function garbageCollect(paths, teamName, validTaskIds) {
|
|||
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;
|
||||
if (validIds.length > 0) {
|
||||
cleaned[columnId] = validIds;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const kanban = require('./kanban.js');
|
||||
const messages = require('./messages.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const reviewStateHelpers = require('./reviewState.js');
|
||||
const tasks = require('./tasks.js');
|
||||
const { withTeamBoardLock } = require('./boardLock.js');
|
||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||
|
|
@ -26,18 +27,164 @@ function resolveLeadSessionId(context, flags) {
|
|||
return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId);
|
||||
}
|
||||
|
||||
function getReviewStateFromHistory(task) {
|
||||
const result = reviewStateHelpers.getReviewStateFromHistory(task);
|
||||
return result ? result.state : null;
|
||||
}
|
||||
|
||||
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 : [];
|
||||
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 === 'review_requested') {
|
||||
return typeof e.reviewer === 'string' && e.reviewer.trim() ? e.reviewer.trim() : null;
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||
return 'none';
|
||||
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 '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) {
|
||||
|
|
@ -52,7 +199,10 @@ function getLatestReviewLifecycleEvent(task) {
|
|||
) {
|
||||
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;
|
||||
}
|
||||
if (e.type === 'task_created') {
|
||||
|
|
@ -69,17 +219,58 @@ function startReview(context, taskId, flags = {}) {
|
|||
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 prevReviewState = getCurrentReviewState(task);
|
||||
const prevReviewState = getEffectiveReviewState(context, task);
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||
const from = getReviewStartActor(context, task, flags);
|
||||
|
||||
try {
|
||||
kanban.setKanbanColumn(context, task.id, 'review');
|
||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_started',
|
||||
|
|
@ -93,7 +284,7 @@ function startReview(context, taskId, flags = {}) {
|
|||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||
} catch (error) {
|
||||
try {
|
||||
kanban.clearKanban(context, task.id);
|
||||
kanban.clearKanban(context, task.id, { transition: 'rollback' });
|
||||
} catch (rollbackError) {
|
||||
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError);
|
||||
}
|
||||
|
|
@ -110,12 +301,16 @@ function requestReview(context, taskId, flags = {}) {
|
|||
}
|
||||
|
||||
const nextFrom =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const nextReviewer = getReviewer(context, flags);
|
||||
const prevReviewState = getCurrentReviewState(currentTask);
|
||||
resolveKnownActorName(context, flags.from, 'review requester') || 'team-lead';
|
||||
const rawReviewer = getReviewer(context, flags);
|
||||
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 {
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'review');
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'review', { transition: 'request_review' });
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_requested',
|
||||
|
|
@ -129,7 +324,7 @@ function requestReview(context, taskId, flags = {}) {
|
|||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
kanban.clearKanban(context, currentTask.id);
|
||||
kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
|
||||
} catch (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` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>" }\n\n` +
|
||||
`When approved, use MCP tool review_approve:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", 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` +
|
||||
`{ 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}`,
|
||||
source: 'system_notification',
|
||||
|
|
@ -176,14 +371,21 @@ function requestReview(context, taskId, flags = {}) {
|
|||
function approveReview(context, taskId, flags = {}) {
|
||||
const result = withTeamBoardLock(context.paths, () => {
|
||||
const currentTask = tasks.getTask(context, taskId);
|
||||
const nextFrom =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const nextFrom = getReviewDecisionActor(context, currentTask, flags, 'approve review');
|
||||
const nextNote =
|
||||
typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
|
||||
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 (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 {
|
||||
alreadyApproved: true,
|
||||
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) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_approved',
|
||||
|
|
@ -263,13 +467,12 @@ function requestChanges(context, taskId, flags = {}) {
|
|||
throw new Error(`No owner found for task ${String(taskId)}`);
|
||||
}
|
||||
|
||||
const nextFrom =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const nextFrom = getReviewDecisionActor(context, currentTask, flags, 'request changes');
|
||||
const nextComment =
|
||||
typeof flags.comment === 'string' && flags.comment.trim()
|
||||
? flags.comment.trim()
|
||||
: 'Reviewer requested changes.';
|
||||
const prevReviewState = getCurrentReviewState(currentTask);
|
||||
const prevReviewState = assertReviewTransitionAllowed(context, currentTask, 'requesting changes');
|
||||
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
|
|
@ -283,7 +486,10 @@ function requestChanges(context, taskId, flags = {}) {
|
|||
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.addTaskComment(context, currentTask.id, {
|
||||
text: nextComment,
|
||||
|
|
|
|||
112
agent-teams-controller/src/internal/reviewState.js
Normal file
112
agent-teams-controller/src/internal/reviewState.js
Normal 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,
|
||||
};
|
||||
|
|
@ -124,15 +124,70 @@ function getPaths(flags, teamName) {
|
|||
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) {
|
||||
const resolved = resolveTeamMembers(paths);
|
||||
const lead = resolved.members.find(
|
||||
(member) =>
|
||||
member &&
|
||||
((typeof member.agentType === 'string' && member.agentType === 'team-lead') ||
|
||||
(typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) ||
|
||||
member.name === 'team-lead')
|
||||
);
|
||||
const members = resolved.members || [];
|
||||
const lead =
|
||||
members.find(
|
||||
(member) =>
|
||||
member &&
|
||||
typeof member.agentType === 'string' &&
|
||||
member.agentType.trim().toLowerCase() === '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) {
|
||||
return String(lead.name);
|
||||
}
|
||||
|
|
@ -143,6 +198,39 @@ function inferLeadName(paths) {
|
|||
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) {
|
||||
return readJson(path.join(paths.teamDir, 'config.json'), null);
|
||||
}
|
||||
|
|
@ -507,12 +595,16 @@ function saveTaskAttachmentFile(paths, taskId, flags) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
assertExplicitTeamMemberName,
|
||||
collectExplicitTeamMembers,
|
||||
getPaths,
|
||||
inferLeadName,
|
||||
isCanonicalLeadMember,
|
||||
isProcessAlive,
|
||||
listInboxMemberNames,
|
||||
readMembersMeta,
|
||||
readTeamConfig,
|
||||
resolveExplicitTeamMemberName,
|
||||
resolveTeamMembers,
|
||||
getCurrentRuntimeMemberIdentity,
|
||||
resolveCanonicalLeadSessionId,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const reviewStateHelpers = require('./reviewState.js');
|
||||
|
||||
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
||||
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
|
||||
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;
|
||||
|
||||
|
|
@ -65,11 +65,16 @@ function normalizeTask(rawTask, filePath) {
|
|||
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;
|
||||
}
|
||||
|
||||
function normalizeTaskReviewState(value) {
|
||||
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
||||
return reviewStateHelpers.normalizeReviewState(value);
|
||||
}
|
||||
|
||||
function listTaskRows(paths, options = {}) {
|
||||
|
|
@ -82,7 +87,18 @@ function listTaskRows(paths, options = {}) {
|
|||
for (const fileName of entries) {
|
||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||
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.metadata && rawTask.metadata._internal === true) continue;
|
||||
try {
|
||||
|
|
@ -410,7 +426,14 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
}
|
||||
|
||||
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 workIntervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
|
||||
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
|
||||
|
|
@ -437,10 +460,17 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
|
||||
if (status === 'deleted') {
|
||||
task.deletedAt = timestamp;
|
||||
task.reviewState = 'none';
|
||||
} else if (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;
|
||||
});
|
||||
}
|
||||
|
|
@ -672,22 +702,7 @@ function compareTasksByFreshness(a, b) {
|
|||
}
|
||||
|
||||
function getEffectiveReviewState(kanbanEntry, task) {
|
||||
// Derive from historyEvents if available
|
||||
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';
|
||||
return reviewStateHelpers.getEffectiveReviewState(task, kanbanEntry).state;
|
||||
}
|
||||
|
||||
function formatBriefTaskLine(task, reviewState) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,22 @@ function normalizeActorName(value) {
|
|||
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) {
|
||||
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
||||
}
|
||||
|
|
@ -171,6 +187,9 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
|||
}
|
||||
|
||||
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));
|
||||
if (input && input.notifyOwner !== false) {
|
||||
maybeNotifyAssignedOwner(context, task, {
|
||||
|
|
@ -233,18 +252,46 @@ function resolveTaskId(context, taskRef) {
|
|||
}
|
||||
|
||||
function setTaskStatus(context, taskId, status, actor) {
|
||||
return withTeamBoardLock(context.paths, () =>
|
||||
taskStore.setTaskStatus(context.paths, taskId, status, actor)
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
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) {
|
||||
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);
|
||||
if (state.tasks[task.id]) {
|
||||
delete state.tasks[task.id];
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
||||
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;
|
||||
});
|
||||
|
|
@ -322,17 +369,42 @@ function completeTask(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) {
|
||||
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) {
|
||||
const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
|
||||
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 {
|
||||
previousTask: before,
|
||||
updatedTask: after,
|
||||
|
|
@ -553,10 +625,10 @@ function buildMemberTaskProtocol(teamName) {
|
|||
{ 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.
|
||||
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.
|
||||
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.
|
||||
- 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:
|
||||
|
|
|
|||
|
|
@ -559,12 +559,14 @@ describe('agent-teams-controller API', () => {
|
|||
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 controller = createController({ teamName: 'my-team', claudeDir });
|
||||
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' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.taskId).toBe(task.id);
|
||||
|
|
@ -582,7 +584,7 @@ describe('agent-teams-controller API', () => {
|
|||
// Verify history event
|
||||
const reviewEvent = updatedTask.historyEvents.find((e) => e.type === 'review_started');
|
||||
expect(reviewEvent).toBeDefined();
|
||||
expect(reviewEvent.from).toBe('none');
|
||||
expect(reviewEvent.from).toBe('review');
|
||||
expect(reviewEvent.to).toBe('review');
|
||||
expect(reviewEvent.actor).toBe('alice');
|
||||
|
||||
|
|
@ -619,6 +621,148 @@ describe('agent-teams-controller API', () => {
|
|||
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', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -919,6 +1083,357 @@ describe('agent-teams-controller API', () => {
|
|||
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', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
4
bun.lock
4
bun.lock
|
|
@ -156,7 +156,7 @@
|
|||
"version": "1.0.0",
|
||||
},
|
||||
"landing": {
|
||||
"name": "claude-agent-teams-landing",
|
||||
"name": "agent-teams-landing",
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# Mount your ~/.claude directory to make session data available.
|
||||
#
|
||||
# Build: docker build -t claude-agent-teams-ui .
|
||||
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
|
||||
# Build: docker build -t agent-teams-ai .
|
||||
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro agent-teams-ai
|
||||
# =============================================================================
|
||||
|
||||
FROM node:20-slim AS builder
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# =============================================================================
|
||||
# Claude Agent Teams UI — Docker Compose
|
||||
# Agent Teams - Docker Compose
|
||||
#
|
||||
# Quick start:
|
||||
# docker compose -f docker/docker-compose.yml up
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
# =============================================================================
|
||||
|
||||
services:
|
||||
claude-agent-teams-ui:
|
||||
agent-teams-ai:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ Minor release: React 19 + Electron 40 migration, start-task-by-user, auth troubl
|
|||
|
||||
## 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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ EOF
|
|||
## Release Notes Template
|
||||
|
||||
```markdown
|
||||
## Claude Agent Teams UI v<VERSION>
|
||||
## Agent Teams v<VERSION>
|
||||
|
||||
<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" />
|
||||
</a>
|
||||
<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" />
|
||||
</a>
|
||||
</td>
|
||||
|
|
@ -197,9 +197,9 @@ electron-builder generates these artifacts per platform:
|
|||
| Platform | Versioned Name | Stable Name (for /latest/download) |
|
||||
|------------------|--------------------------------------------------|--------------------------------------------|
|
||||
| macOS arm64 DMG | `Claude.Agent.Teams.UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
|
||||
| macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
|
||||
| macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | — |
|
||||
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-mac.zip` | — |
|
||||
| macOS x64 DMG | `Claude.Agent.Teams.UI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
|
||||
| macOS arm64 ZIP | `Claude.Agent.Teams.UI-<VER>-arm64-mac.zip` | - |
|
||||
| macOS x64 ZIP | `Claude.Agent.Teams.UI-<VER>-x64-mac.zip` | - |
|
||||
| Windows | `Claude.Agent.Teams.UI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
|
||||
| Linux AppImage | `Claude.Agent.Teams.UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
|
||||
| Linux deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Team Management Feature
|
||||
|
||||
Интерфейс для управления командами тиммейтов Claude Code внутри Claude Agent Teams UI (Electron).
|
||||
Интерфейс для управления командами тиммейтов Claude Code внутри Agent Teams (Electron).
|
||||
|
||||
## Что делает
|
||||
|
||||
|
|
|
|||
3575
docs/team-management/opencode-native-semantic-messaging-plan.md
Normal file
3575
docs/team-management/opencode-native-semantic-messaging-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -33,7 +33,7 @@ claude # Новая независимая сессия
|
|||
|
||||
---
|
||||
|
||||
## ЧАСТЬ 2: Существующая инфраструктура в Claude Agent Teams UI
|
||||
## ЧАСТЬ 2: Существующая инфраструктура в Agent Teams
|
||||
|
||||
### Уже реализовано (можно переиспользовать)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
|
|||
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
|
||||
project: process.env.SENTRY_PROJECT ?? 'electron',
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
release: { name: `claude-agent-teams-ui@${pkg.version}` },
|
||||
release: { name: `agent-teams-ai@${pkg.version}` },
|
||||
sourcemaps: {
|
||||
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Claude Agent Teams Landing
|
||||
# Agent Teams Landing
|
||||
|
||||
## Quick start
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ const { baseURL } = useRuntimeConfig().app;
|
|||
<NuxtLink to="/" class="app-logo">
|
||||
<img
|
||||
:src="`${baseURL}logo-192.png`"
|
||||
alt="Claude Agent Teams"
|
||||
alt="Agent Teams"
|
||||
class="app-logo__img"
|
||||
width="36"
|
||||
height="36"
|
||||
/>
|
||||
<span class="app-logo__text">Claude Agent Teams</span>
|
||||
<span class="app-logo__text">Agent Teams</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<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">
|
||||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
|
||||
<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>
|
||||
</v-container>
|
||||
</footer>
|
||||
|
|
@ -31,7 +34,7 @@ const year = new Date().getFullYear();
|
|||
.app-footer__copy {
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.app-footer__links {
|
||||
|
|
@ -46,7 +49,7 @@ const year = new Date().getFullYear();
|
|||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.app-footer__link:hover {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const navItems = computed(() => [
|
||||
|
|
@ -28,7 +29,7 @@ const navItems = computed(() => [
|
|||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
href="https://github.com/777genius/claude_agent_teams_ui"
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
class="app-header__github-btn"
|
||||
:prepend-icon="mdiGithub"
|
||||
|
|
@ -60,7 +61,7 @@ const navItems = computed(() => [
|
|||
{{ item.label }}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/777genius/claude_agent_teams_ui"
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
class="mobile-menu__link"
|
||||
@click="menuOpen = false"
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
])
|
||||
|
||||
const competitors = [
|
||||
{ key: 'us', name: 'Claude Agent Teams', highlight: true },
|
||||
{ key: 'us', name: 'Agent Teams', highlight: true },
|
||||
{ key: 'vibeKanban', name: 'Vibe Kanban' },
|
||||
{ key: 'aperant', name: 'Aperant' },
|
||||
{ key: 'cursor', name: 'Cursor' },
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
|
||||
import { downloadAssets } from "~/data/downloads";
|
||||
import type { DownloadOs, DownloadArch } from "~/data/downloads";
|
||||
import { downloadAssets } from '~/data/downloads';
|
||||
import type { DownloadOs, DownloadArch } from '~/data/downloads';
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t, locale } = useI18n();
|
||||
const downloadStore = useDownloadStore();
|
||||
const { data: releaseData, resolve } = useReleaseDownloads();
|
||||
const { trackDownloadClick } = useAnalytics();
|
||||
const { releaseDownloadUrl } = useGithubRepo();
|
||||
|
||||
onMounted(() => downloadStore.init());
|
||||
|
||||
|
|
@ -18,18 +19,18 @@ const platformIcons: Record<string, string> = {
|
|||
};
|
||||
|
||||
const platformColors: Record<string, string> = {
|
||||
macos: "#00f0ff",
|
||||
windows: "#39ff14",
|
||||
linux: "#ffd700",
|
||||
macos: '#00f0ff',
|
||||
windows: '#39ff14',
|
||||
linux: '#ffd700',
|
||||
};
|
||||
|
||||
const visibleAssets = computed(() => {
|
||||
const enriched = downloadAssets.map((asset) => {
|
||||
if (asset.os !== "macos") return { ...asset };
|
||||
if (asset.os !== 'macos') return { ...asset };
|
||||
if (!downloadStore.isMacOs) return { ...asset };
|
||||
return {
|
||||
...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];
|
||||
});
|
||||
|
||||
const getDownloadUrl = (asset: { os: string; arch: string; url: string }) => {
|
||||
const arch = (asset.os === "macos" ? downloadStore.macArch : asset.arch) as DownloadArch;
|
||||
return resolve(asset.os as DownloadOs, arch)?.url || asset.url;
|
||||
const getDownloadUrl = (asset: { os: string; arch: string; fileName: string }) => {
|
||||
const arch = (asset.os === 'macos' ? downloadStore.macArch : asset.arch) as DownloadArch;
|
||||
return resolve(asset.os as DownloadOs, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
};
|
||||
|
||||
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);
|
||||
|
|
@ -90,7 +91,11 @@ const releaseDate = computed(() => {
|
|||
|
||||
<!-- Platform icon -->
|
||||
<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>
|
||||
|
||||
<!-- Platform info -->
|
||||
|
|
@ -103,10 +108,18 @@ const releaseDate = computed(() => {
|
|||
<a
|
||||
class="download-section__btn"
|
||||
: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" />
|
||||
<span>{{ t("download.title") }}</span>
|
||||
<span>{{ t('download.title') }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Active indicator -->
|
||||
|
|
@ -115,7 +128,7 @@ const releaseDate = computed(() => {
|
|||
class="download-section__card-indicator"
|
||||
>
|
||||
<v-icon size="16" :icon="mdiCheckCircle" />
|
||||
<span>{{ t("download.detected") }}</span>
|
||||
<span>{{ t('download.detected') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -245,11 +258,7 @@ const releaseDate = computed(() => {
|
|||
|
||||
.download-section__card--active .download-section__card-glow {
|
||||
opacity: 0.7;
|
||||
background: radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
rgba(57, 255, 20, 0.1),
|
||||
transparent 70%
|
||||
);
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(57, 255, 20, 0.1), transparent 70%);
|
||||
}
|
||||
|
||||
/* Icon wrap */
|
||||
|
|
@ -267,7 +276,9 @@ const releaseDate = computed(() => {
|
|||
);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
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 {
|
||||
|
|
@ -290,7 +301,7 @@ const releaseDate = computed(() => {
|
|||
margin-bottom: 3px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #e0e6ff;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.download-section__card-arch {
|
||||
|
|
@ -319,7 +330,7 @@ const releaseDate = computed(() => {
|
|||
box-shadow 0.25s ease,
|
||||
filter 0.25s ease;
|
||||
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 {
|
||||
|
|
@ -346,7 +357,7 @@ const releaseDate = computed(() => {
|
|||
font-weight: 600;
|
||||
color: #39ff14;
|
||||
opacity: 0.9;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Release info */
|
||||
|
|
@ -360,7 +371,7 @@ const releaseDate = computed(() => {
|
|||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
@keyframes downloadFadeUp {
|
||||
|
|
|
|||
|
|
@ -7,21 +7,26 @@ const { baseURL } = useRuntimeConfig().app;
|
|||
|
||||
const downloadStore = useDownloadStore();
|
||||
const { resolve, data: releaseData } = useReleaseDownloads();
|
||||
const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
|
||||
const releaseVersion = computed(() => releaseData.value?.version || null);
|
||||
const releaseDate = computed(() => {
|
||||
const raw = releaseData.value?.pubDate;
|
||||
if (!raw) return null;
|
||||
return new Date(raw).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
return new Date(raw).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => downloadStore.init());
|
||||
|
||||
const heroDownloadUrl = computed(() => {
|
||||
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;
|
||||
return resolve(asset.os, arch)?.url || asset.url;
|
||||
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -75,17 +80,17 @@ const heroDownloadUrl = computed(() => {
|
|||
<div class="hero-section__trust">
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiRobotOutline" />
|
||||
<span>{{ t("hero.trust.agentTeams") }}</span>
|
||||
<span>{{ t('hero.trust.agentTeams') }}</span>
|
||||
</div>
|
||||
<div class="hero-section__trust-divider" />
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiViewDashboardOutline" />
|
||||
<span>{{ t("hero.trust.kanban") }}</span>
|
||||
<span>{{ t('hero.trust.kanban') }}</span>
|
||||
</div>
|
||||
<div class="hero-section__trust-divider" />
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiOpenSourceInitiative" />
|
||||
<span>{{ t("hero.trust.openSource") }}</span>
|
||||
<span>{{ t('hero.trust.openSource') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
|
@ -108,7 +113,6 @@ const heroDownloadUrl = computed(() => {
|
|||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -262,7 +266,12 @@ const heroDownloadUrl = computed(() => {
|
|||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.2), rgba(255, 0, 255, 0.2), rgba(57, 255, 20, 0.1));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 240, 255, 0.2),
|
||||
rgba(255, 0, 255, 0.2),
|
||||
rgba(57, 255, 20, 0.1)
|
||||
);
|
||||
filter: blur(20px);
|
||||
opacity: 0.4;
|
||||
z-index: 0;
|
||||
|
|
@ -270,8 +279,15 @@ const heroDownloadUrl = computed(() => {
|
|||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.02); }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── SSR Fallback ─── */
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { testimonials } from '~/data/testimonials'
|
||||
import { useLandingContent } from '~/composables/useLandingContent'
|
||||
import { useDisplay } from 'vuetify';
|
||||
import { testimonials } from '~/data/testimonials';
|
||||
import { useLandingContent } from '~/composables/useLandingContent';
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t } = useI18n();
|
||||
const { issuesUrl } = useGithubRepo();
|
||||
const { smAndUp } = useDisplay();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
|
@ -16,7 +17,7 @@ const items = computed(() =>
|
|||
if (!contentItem) return null;
|
||||
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(() => {
|
||||
|
|
@ -24,9 +25,7 @@ const visibleItems = computed(() => {
|
|||
return items.value.slice(0, smAndUp.value ? 4 : 2);
|
||||
});
|
||||
|
||||
const hasMore = computed(() =>
|
||||
!expanded.value && items.value.length > (smAndUp.value ? 4 : 2)
|
||||
);
|
||||
const hasMore = computed(() => !expanded.value && items.value.length > (smAndUp.value ? 4 : 2));
|
||||
|
||||
const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
||||
</script>
|
||||
|
|
@ -36,32 +35,21 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
|||
<v-container>
|
||||
<div class="testimonials-section__header">
|
||||
<h2 class="testimonials-section__title">
|
||||
{{ t("testimonials.sectionTitle") }}
|
||||
{{ t('testimonials.sectionTitle') }}
|
||||
</h2>
|
||||
<p class="testimonials-section__subtitle">
|
||||
{{ t("testimonials.sectionSubtitle") }}
|
||||
{{ t('testimonials.sectionSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-row justify="center">
|
||||
<v-col
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="item.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<div
|
||||
class="testimonials-section__card-wrap"
|
||||
:style="{ '--delay': `${index * 0.08}s` }"
|
||||
>
|
||||
<v-col v-for="(item, index) in visibleItems" :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__quote">"</div>
|
||||
<p class="testimonial-card__text">{{ item.text }}</p>
|
||||
<div class="testimonial-card__author">
|
||||
<div
|
||||
class="testimonial-card__avatar"
|
||||
:style="{ background: item.avatar }"
|
||||
>
|
||||
<div class="testimonial-card__avatar" :style="{ background: item.avatar }">
|
||||
{{ getInitial(item.name) }}
|
||||
</div>
|
||||
<div class="testimonial-card__info">
|
||||
|
|
@ -75,17 +63,14 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
|||
</v-row>
|
||||
|
||||
<div v-if="hasMore || expanded" class="testimonials-section__toggle">
|
||||
<button
|
||||
class="testimonials-section__toggle-btn"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<button class="testimonials-section__toggle-btn" @click="expanded = !expanded">
|
||||
{{ expanded ? t('testimonials.showLess') : t('testimonials.showMore') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="testimonials-section__feedback-cta">
|
||||
{{ 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>
|
||||
</v-container>
|
||||
</section>
|
||||
|
|
@ -139,7 +124,9 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
|||
height: 100%;
|
||||
display: flex;
|
||||
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 {
|
||||
|
|
|
|||
23
landing/composables/useGithubRepo.ts
Normal file
23
landing/composables/useGithubRepo.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -22,7 +22,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
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 title = computed(() => t(titleKey));
|
||||
|
|
@ -149,7 +149,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
htmlAttrs: { lang: locale.value || "en" },
|
||||
link: links,
|
||||
meta: [
|
||||
{ name: "author", content: "Claude Agent Teams" },
|
||||
{ name: "author", content: "Agent Teams" },
|
||||
{ name: "application-name", content: siteName },
|
||||
{ name: "apple-mobile-web-app-title", content: siteName },
|
||||
{ name: "format-detection", content: "telephone=no" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "أنت المدير التقني، والوكلاء هم فريقك. يتولون المهام بأنفسهم، يتواصلون مع بعضهم، يراجعون كود بعضهم البعض. وأنت فقط تنظر للوحة كانبان وتشرب قهوتك."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "ما هو Claude Agent Teams؟",
|
||||
"question": "ما هو Agent Teams؟",
|
||||
"answer": "تطبيق سطح مكتب لتنظيم فرق وكلاء الذكاء الاصطناعي عبر طبقة تنسيق محلية خاصة بنا. لكل وكيل دور، يعمل بشكل مستقل، ويتعاون عبر لوحة كانبان، ويمكن تشغيله مع Anthropic أو Codex."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"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."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"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."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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é."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"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."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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é."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"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."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "आप CTO हैं, एजेंट आपकी टीम हैं। वे खुद टास्क संभालते हैं, आपस में बात करते हैं, एक-दूसरे का कोड रिव्यू करते हैं। आप बस कानबन बोर्ड देखें और कॉफी पिएँ।"
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "Claude Agent Teams क्या है?",
|
||||
"question": "Agent Teams क्या है?",
|
||||
"answer": "यह एक डेस्कटॉप ऐप है जो हमारी अपनी लोकल coordination layer के साथ AI agent teams को orchestrate करता है। हर agent की एक भूमिका होती है, वह स्वायत्त रूप से काम करता है, kanban board पर सहयोग करता है, और Anthropic या Codex पर चल सकता है।"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "あなたはCTO、エージェントはあなたのチーム。タスクを自分で処理し、互いにメッセージを送り、コードをレビューする。あなたはカンバンボードを見ながらコーヒーを飲むだけ。"
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "Claude Agent Teamsとは?",
|
||||
"question": "Agent Teamsとは?",
|
||||
"answer": "独自のローカル協調レイヤーでAIエージェントチームをオーケストレーションできるデスクトップアプリです。各エージェントは役割を持ち、自律的に動き、カンバン上で連携し、Anthropic または Codex で実行できます。"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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é."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"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."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "Вы — CTO, агенты — ваша команда. Они сами берут задачи, переписываются друг с другом, ревьюят код друг друга. А вы просто смотрите на канбан-доску и пьёте кофе."
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "Что такое Claude Agent Teams?",
|
||||
"question": "Что такое Agent Teams?",
|
||||
"answer": "Десктопное приложение для оркестрации команд ИИ-агентов с нашей собственной локальной координацией. У агентов есть роли, они работают автономно, взаимодействуют через канбан-доску и могут запускаться на Anthropic или Codex."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Claude Agent Teams",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "你是 CTO,智能体是你的团队。它们自己处理任务、互相沟通、审查彼此的代码。你只需看着看板喝咖啡。"
|
||||
},
|
||||
"features": [
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"faq": [
|
||||
{
|
||||
"id": "whatIsIt",
|
||||
"question": "什么是 Claude Agent Teams?",
|
||||
"question": "什么是 Agent Teams?",
|
||||
"answer": "一个桌面应用,通过我们自己的本地协调层来编排 AI 智能体团队。每个智能体都有角色,可自主工作,在看板上协作,并且可以运行在 Anthropic 或 Codex 上。"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
export type DownloadOs = "macos" | "windows" | "linux";
|
||||
export type DownloadArch = "arm64" | "x64" | "universal";
|
||||
export type DownloadOs = 'macos' | 'windows' | 'linux';
|
||||
export type DownloadArch = 'arm64' | 'x64' | 'universal';
|
||||
|
||||
export const downloadAssets = [
|
||||
{
|
||||
id: "macos",
|
||||
os: "macos",
|
||||
arch: "universal",
|
||||
label: "macOS",
|
||||
archLabel: "Apple Silicon / Intel",
|
||||
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg"
|
||||
id: 'macos',
|
||||
os: 'macos',
|
||||
arch: 'universal',
|
||||
label: 'macOS',
|
||||
archLabel: 'Apple Silicon / Intel',
|
||||
fileName: 'Claude-Agent-Teams-UI-arm64.dmg',
|
||||
},
|
||||
{
|
||||
id: "windows-x64",
|
||||
os: "windows",
|
||||
arch: "x64",
|
||||
label: "Windows",
|
||||
archLabel: "64-bit",
|
||||
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe"
|
||||
id: 'windows-x64',
|
||||
os: 'windows',
|
||||
arch: 'x64',
|
||||
label: 'Windows',
|
||||
archLabel: '64-bit',
|
||||
fileName: 'Claude-Agent-Teams-UI-Setup.exe',
|
||||
},
|
||||
{
|
||||
id: "linux-appimage",
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
label: "Linux",
|
||||
archLabel: "64-bit",
|
||||
url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage"
|
||||
}
|
||||
id: 'linux-appimage',
|
||||
os: 'linux',
|
||||
arch: 'x64',
|
||||
label: 'Linux',
|
||||
archLabel: '64-bit',
|
||||
fileName: 'Claude-Agent-Teams-UI.AppImage',
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "حمّل الآن",
|
||||
"ctaPrimary": "تحميل لـ {platform}",
|
||||
"ctaSecondary": "مقارنة",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "لديك أسئلة؟ لدينا إجابات",
|
||||
"subtitle": "كل ما تحتاج معرفته عن Claude Agent Teams"
|
||||
"subtitle": "كل ما تحتاج معرفته عن Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "كيف نقارن",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "اعرف المزيد"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "تنسيق وكلاء الذكاء الاصطناعي للمطورين",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — تنسيق وكلاء الذكاء الاصطناعي للمطورين",
|
||||
"homeTitle": "Agent Teams - تنسيق وكلاء الذكاء الاصطناعي للمطورين",
|
||||
"homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Herunterladen",
|
||||
"ctaPrimary": "Für {platform} herunterladen",
|
||||
"ctaSecondary": "Vergleichen",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"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": {
|
||||
"sectionTitle": "Wie wir im Vergleich abschneiden",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Mehr erfahren"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "KI-Agenten-Orchestrierung für Entwickler",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Download Now",
|
||||
"ctaPrimary": "Download for {platform}",
|
||||
"ctaSecondary": "Compare",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"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": {
|
||||
"sectionTitle": "How we compare",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Learn more"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "AI agent orchestration for developers",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Descargar ahora",
|
||||
"ctaPrimary": "Descargar para {platform}",
|
||||
"ctaSecondary": "Comparar",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"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": {
|
||||
"sectionTitle": "Cómo nos comparamos",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Más información"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orquestación de agentes IA para desarrolladores",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Télécharger",
|
||||
"ctaPrimary": "Télécharger pour {platform}",
|
||||
"ctaSecondary": "Comparer",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"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": {
|
||||
"sectionTitle": "Comment nous nous comparons",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "En savoir plus"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orchestration d'agents IA pour développeurs",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "अभी डाउनलोड करें",
|
||||
"ctaPrimary": "{platform} के लिए डाउनलोड करें",
|
||||
"ctaSecondary": "तुलना करें",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "सवाल हैं? हमारे पास जवाब हैं",
|
||||
"subtitle": "Claude Agent Teams के बारे में सब कुछ"
|
||||
"subtitle": "Agent Teams के बारे में सब कुछ"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "तुलना करें",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "और जानें"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
|
||||
"homeTitle": "Agent Teams - डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
|
||||
"homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।"
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "ダウンロード",
|
||||
"ctaPrimary": "{platform}版をダウンロード",
|
||||
"ctaSecondary": "比較する",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "よくある質問",
|
||||
"subtitle": "Claude Agent Teamsについて知っておくべきこと"
|
||||
"subtitle": "Agent Teamsについて知っておくべきこと"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "他ツールとの比較",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "詳細"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "開発者向けAIエージェントオーケストレーション",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — 開発者向けAIエージェントオーケストレーション",
|
||||
"homeTitle": "Agent Teams - 開発者向けAIエージェントオーケストレーション",
|
||||
"homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。"
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Baixar agora",
|
||||
"ctaPrimary": "Baixar para {platform}",
|
||||
"ctaSecondary": "Comparar",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "Tem perguntas? Temos respostas",
|
||||
"subtitle": "Tudo sobre o Claude Agent Teams"
|
||||
"subtitle": "Tudo sobre o Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "Como nos comparamos",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Saiba mais"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orquestração de agentes IA para desenvolvedores",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "Скачать",
|
||||
"ctaPrimary": "Скачать для {platform}",
|
||||
"ctaSecondary": "Сравнить",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "Есть вопросы? У нас есть ответы",
|
||||
"subtitle": "Всё, что нужно знать о Claude Agent Teams"
|
||||
"subtitle": "Всё, что нужно знать о Agent Teams"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "Сравнение с конкурентами",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "Подробнее"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Оркестрация ИИ-агентов для разработчиков",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — Оркестрация ИИ-агентов для разработчиков",
|
||||
"homeTitle": "Agent Teams - Оркестрация ИИ-агентов для разработчиков",
|
||||
"homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально."
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"viewOnGithub": "View on GitHub"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Claude Agent Teams",
|
||||
"badge": "Agent Teams",
|
||||
"downloadNow": "立即下载",
|
||||
"ctaPrimary": "下载 {platform} 版",
|
||||
"ctaSecondary": "对比",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
},
|
||||
"faq": {
|
||||
"sectionTitle": "有问题?我们有答案",
|
||||
"subtitle": "关于 Claude Agent Teams 的一切"
|
||||
"subtitle": "关于 Agent Teams 的一切"
|
||||
},
|
||||
"comparison": {
|
||||
"sectionTitle": "功能对比",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"learnMore": "了解更多"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} Claude Agent Teams",
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "面向开发者的 AI 智能体编排",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
}
|
||||
},
|
||||
"meta": {
|
||||
"homeTitle": "Claude Agent Teams — 面向开发者的 AI 智能体编排",
|
||||
"homeTitle": "Agent Teams - 面向开发者的 AI 智能体编排",
|
||||
"homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。"
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { generateI18nRoutes, supportedLocales } from "./data/i18n";
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare const process: any;
|
||||
|
||||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://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 githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
|
||||
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
|
||||
|
|
@ -88,7 +88,7 @@ export default defineNuxtConfig({
|
|||
// @ts-expect-error - field provided by nuxt modules
|
||||
site: {
|
||||
url: siteUrl,
|
||||
name: "Claude Agent Teams"
|
||||
name: "Agent Teams"
|
||||
},
|
||||
runtimeConfig: {
|
||||
github: {
|
||||
|
|
|
|||
4
landing/package-lock.json
generated
4
landing/package-lock.json
generated
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "claude-agent-teams-landing",
|
||||
"name": "agent-teams-landing",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-agent-teams-landing",
|
||||
"name": "agent-teams-landing",
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@nuxtjs/i18n": "^9.5.6",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "claude-agent-teams-landing",
|
||||
"name": "agent-teams-landing",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export default defineEventHandler((event) => {
|
||||
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");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const buildDate = new Date().toISOString().split("T")[0];
|
|||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://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");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "agent-teams-mcp",
|
||||
"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",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
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({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
|
|
@ -36,7 +37,8 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
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({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(note ? { note } : {}),
|
||||
...(notifyOwner !== false ? { 'notify-owner': true } : {}),
|
||||
...(notifyOwner === true ? { 'notify-owner': true } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -996,7 +996,6 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
claudeDir,
|
||||
teamName: 'review-roundtrip-team',
|
||||
taskId: roundtripTask.id,
|
||||
from: 'bob',
|
||||
},
|
||||
39
|
||||
);
|
||||
|
|
@ -1563,7 +1562,6 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
claudeDir,
|
||||
teamName: 'terminal-routing-team',
|
||||
taskId: approvedTask.id,
|
||||
from: 'bob',
|
||||
},
|
||||
87
|
||||
);
|
||||
|
|
@ -1779,4 +1777,140 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -910,7 +910,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
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(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
|
|
@ -919,17 +919,14 @@ describe('agent-teams-mcp tools', () => {
|
|||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
const reviewStarted = parseJsonToolResult(
|
||||
await getTool('review_start').execute({
|
||||
await expect(
|
||||
getTool('review_start').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: pendingTask.id,
|
||||
from: 'alice',
|
||||
})
|
||||
);
|
||||
expect(reviewStarted.ok).toBe(true);
|
||||
expect(reviewStarted.column).toBe('review');
|
||||
expect(reviewStarted.taskId).toBe(pendingTask.id);
|
||||
).rejects.toThrow('must be completed before starting review');
|
||||
|
||||
const pid = process.pid;
|
||||
|
||||
|
|
@ -979,6 +976,159 @@ describe('agent-teams-mcp tools', () => {
|
|||
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 () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'gamma';
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -2,7 +2,7 @@
|
|||
"name": "claude-agent-teams-ui",
|
||||
"type": "module",
|
||||
"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",
|
||||
"author": {
|
||||
"name": "Илия (777genius)",
|
||||
|
|
@ -259,6 +259,7 @@
|
|||
"main": "dist-electron/main/index.cjs"
|
||||
},
|
||||
"mac": {
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}-mac.${ext}",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"minimumSystemVersion": "12.0",
|
||||
"target": [
|
||||
|
|
@ -273,7 +274,8 @@
|
|||
"icon": "resources/icons/mac/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"sign": false
|
||||
"sign": false,
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
|
|
@ -291,10 +293,14 @@
|
|||
"icon": "resources/icons/png",
|
||||
"category": "Development"
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "Claude.Agent.Teams.UI-${version}.${ext}"
|
||||
},
|
||||
"deb": {
|
||||
"afterInstall": "resources/afterInstall.sh"
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "Claude.Agent.Teams.UI.Setup.${version}.${ext}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
|
|
@ -302,6 +308,8 @@
|
|||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "777genius",
|
||||
"repo": "claude_agent_teams_ui",
|
||||
"releaseType": "release"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -312,7 +312,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -340,7 +341,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -368,7 +370,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -396,7 +399,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -424,7 +428,8 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -452,7 +457,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"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": {
|
||||
"input_cost_per_token": 0,
|
||||
|
|
@ -494,7 +501,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -522,7 +531,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -550,7 +561,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
|
|
@ -578,7 +591,9 @@
|
|||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -605,7 +620,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -632,7 +648,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
|
|
@ -659,7 +676,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
|
|
@ -686,7 +704,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
|
|
@ -713,7 +732,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -1014,7 +1034,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"input_cost_per_token": 0.000005,
|
||||
|
|
@ -1042,7 +1063,9 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": 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": {
|
||||
"cache_creation_input_token_cost": 0.00001875,
|
||||
|
|
@ -1106,7 +1129,8 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": 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": {
|
||||
"input_cost_per_token": 0.00000223,
|
||||
|
|
@ -1682,7 +1706,8 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": 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": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -1876,7 +1901,8 @@
|
|||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-opus-4-6-20260205": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -1908,7 +1934,8 @@
|
|||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_max_reasoning_effort": true
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -1940,7 +1967,9 @@
|
|||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
}
|
||||
},
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-opus-4-7-20260416": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -1972,7 +2001,9 @@
|
|||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
}
|
||||
},
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
"deprecation_date": "2026-05-14",
|
||||
|
|
@ -3233,7 +3264,8 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": 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": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -3271,7 +3303,8 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": 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": {
|
||||
"input_cost_per_image": 0.0048,
|
||||
|
|
@ -4051,7 +4084,8 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vercel_ai_gateway/anthropic/claude-sonnet-4": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -4425,7 +4459,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -4452,7 +4487,8 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"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": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -4479,7 +4515,9 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": 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": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -4506,7 +4544,9 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": 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": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -4558,7 +4598,8 @@
|
|||
"search_context_size_high": 0.01,
|
||||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
}
|
||||
},
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vertex_ai/claude-sonnet-4-5@20250929": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
|
|
@ -4697,7 +4738,8 @@
|
|||
"search_context_size_high": 0.01,
|
||||
"search_context_size_low": 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": {
|
||||
"cache_creation_input_token_cost": 0.0000015,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.6",
|
||||
"sourceRef": "v0.0.6",
|
||||
"version": "0.0.7",
|
||||
"sourceRef": "v0.0.7",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.6.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.7.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const defaultEvidencePath = path.join(
|
||||
resolveAppDataDir(),
|
||||
'claude-agent-teams-ui',
|
||||
'Agent Teams UI',
|
||||
'opencode-bridge',
|
||||
'production-e2e-evidence.json'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
export {
|
||||
reconcileAnthropicRuntimeSelections,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
|
||||
export type {
|
||||
AnthropicFastModeResolution,
|
||||
AnthropicRuntimeProfileSource,
|
||||
AnthropicRuntimeReconciliation,
|
||||
AnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
export {
|
||||
reconcileAnthropicRuntimeSelections,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
export {
|
||||
reconcileAnthropicRuntimeSelections,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
|
||||
export type {
|
||||
AnthropicFastModeResolution,
|
||||
AnthropicRuntimeProfileSource,
|
||||
AnthropicRuntimeReconciliation,
|
||||
AnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
export {
|
||||
reconcileAnthropicRuntimeSelections,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
} from '../core/domain/resolveAnthropicRuntimeProfile';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
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 { createStaticCodexModelCatalogModels } from '@features/codex-model-catalog/core/domain/codexModelCatalogFallback';
|
||||
import { normalizeCodexAppServerModels } from '@features/codex-model-catalog/core/domain/normalizeCodexAppServerModel';
|
||||
|
|
@ -15,6 +13,8 @@ import {
|
|||
import { CodexModelCatalogAppServerClient } from '../infrastructure/CodexModelCatalogAppServerClient';
|
||||
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 { Logger } from '@shared/utils/logger';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
|
||||
describe('CodexModelCatalogAppServerClient', () => {
|
||||
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;
|
||||
const session: CodexAppServerSession = {
|
||||
initializeResponse: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
export type {
|
||||
CodexFastCapabilitySource,
|
||||
CodexFastModeResolution,
|
||||
CodexRuntimeProfileSource,
|
||||
CodexRuntimeReconciliation,
|
||||
CodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
export {
|
||||
buildCodexFastModeArgs,
|
||||
CODEX_FAST_CREDIT_COST_MULTIPLIER,
|
||||
|
|
@ -7,11 +14,3 @@ export {
|
|||
resolveCodexFastMode,
|
||||
resolveCodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
|
||||
export type {
|
||||
CodexFastCapabilitySource,
|
||||
CodexFastModeResolution,
|
||||
CodexRuntimeProfileSource,
|
||||
CodexRuntimeReconciliation,
|
||||
CodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
export type {
|
||||
CodexFastCapabilitySource,
|
||||
CodexFastModeResolution,
|
||||
CodexRuntimeProfileSource,
|
||||
CodexRuntimeReconciliation,
|
||||
CodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
export {
|
||||
buildCodexFastModeArgs,
|
||||
CODEX_FAST_CREDIT_COST_MULTIPLIER,
|
||||
|
|
@ -7,11 +14,3 @@ export {
|
|||
resolveCodexFastMode,
|
||||
resolveCodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
|
||||
export type {
|
||||
CodexFastCapabilitySource,
|
||||
CodexFastModeResolution,
|
||||
CodexRuntimeProfileSource,
|
||||
CodexRuntimeReconciliation,
|
||||
CodexRuntimeSelection,
|
||||
} from '../core/domain/resolveCodexRuntimeProfile';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import {
|
|||
type DashboardRecentProjectsPayload,
|
||||
normalizeDashboardRecentProjectsPayload,
|
||||
} from '@features/recent-projects/contracts';
|
||||
import {
|
||||
CodexBinaryResolver,
|
||||
JsonRpcStdioClient,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
|
||||
import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase';
|
||||
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 { LoggerPort } from '../../core/application/ports/LoggerPort';
|
||||
import {
|
||||
CodexBinaryResolver,
|
||||
JsonRpcStdioClient,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
import type { ServiceContext } from '@main/services';
|
||||
|
||||
export interface RecentProjectsFeatureFacade {
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export class CodexAppServerClient {
|
|||
'initialize',
|
||||
{
|
||||
clientInfo: {
|
||||
name: 'claude-agent-teams-ui',
|
||||
name: 'agent-teams-ai',
|
||||
title: 'Agent Teams UI',
|
||||
version: '0.1.0',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@ export type TeamRuntimeLanePlan =
|
|||
mode: 'mixed_opencode_side_lanes';
|
||||
primaryMembers: PlannedRuntimeMember[];
|
||||
allMembers: PlannedRuntimeMember[];
|
||||
sideLanes: Array<{
|
||||
sideLanes: {
|
||||
laneId: string;
|
||||
providerId: 'opencode';
|
||||
member: PlannedRuntimeMember;
|
||||
}>;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TeamRuntimeLanePlanErrorReason = 'unsupported_opencode_led_mixed_team';
|
||||
|
|
|
|||
5
src/main/bootstrapUserDataMigration.ts
Normal file
5
src/main/bootstrapUserDataMigration.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { app } from 'electron';
|
||||
|
||||
import { migrateElectronUserDataDirectory } from './utils/electronUserDataMigration';
|
||||
|
||||
export const earlyElectronUserDataMigrationResult = migrateElectronUserDataDirectory(app);
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { validateTeamName } from '@main/ipc/guards';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import {
|
||||
formatEffortLevelListForProvider,
|
||||
isTeamEffortLevelForProvider,
|
||||
} from '@shared/utils/effortLevels';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
|
@ -224,7 +224,7 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
}
|
||||
|
||||
const teamProvisioningService = getTeamProvisioningService(services);
|
||||
teamProvisioningService.stopTeam(validatedTeamName.value!);
|
||||
await teamProvisioningService.stopTeam(validatedTeamName.value!);
|
||||
return reply.send(await teamProvisioningService.getRuntimeState(validatedTeamName.value!));
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function registerUpdaterRoutes(app: FastifyInstance, services: HttpServic
|
|||
|
||||
app.post('/api/updater/install', async () => {
|
||||
try {
|
||||
services.updaterService.quitAndInstall();
|
||||
await services.updaterService.quitAndInstall();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/updater/install:', getErrorMessage(error));
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@
|
|||
// On Windows this saturates all threads, blocking the event loop.
|
||||
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 {
|
||||
|
|
@ -53,6 +56,7 @@ import {
|
|||
resolveAgentTeamsMcpLaunchSpec,
|
||||
TeamMcpConfigBuilder,
|
||||
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import {
|
||||
CONTEXT_CHANGED,
|
||||
|
|
@ -179,6 +183,20 @@ import type { FileChangeEvent } from '@main/types';
|
|||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
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();
|
||||
|
||||
// Windows: set AppUserModelId early so native notifications show the correct
|
||||
|
|
@ -533,6 +551,70 @@ let rendererRecoveryAttempts = 0;
|
|||
let fileChangeCleanup: (() => void) | null = null;
|
||||
let todoChangeCleanup: (() => 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.
|
||||
|
|
@ -714,13 +796,12 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
const timerKey = `${teamName}:${detail}`;
|
||||
const existing = inboxNotifyTimers.get(timerKey);
|
||||
if (existing) clearTimeout(existing);
|
||||
inboxNotifyTimers.set(
|
||||
timerKey,
|
||||
setTimeout(() => {
|
||||
inboxNotifyTimers.delete(timerKey);
|
||||
void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
|
||||
}, INBOX_NOTIFY_DEBOUNCE_MS)
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
inboxNotifyTimers.delete(timerKey);
|
||||
void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
|
||||
}, INBOX_NOTIFY_DEBOUNCE_MS);
|
||||
timer.unref?.();
|
||||
inboxNotifyTimers.set(timerKey, timer);
|
||||
}
|
||||
|
||||
// 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 existing = inboxNotifyTimers.get(timerKey);
|
||||
if (existing) clearTimeout(existing);
|
||||
inboxNotifyTimers.set(
|
||||
timerKey,
|
||||
setTimeout(() => {
|
||||
inboxNotifyTimers.delete(timerKey);
|
||||
void notifyNewSentMessages(teamName).catch(() => undefined);
|
||||
}, INBOX_NOTIFY_DEBOUNCE_MS)
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
inboxNotifyTimers.delete(timerKey);
|
||||
void notifyNewSentMessages(teamName).catch(() => undefined);
|
||||
}, 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
|
||||
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();
|
||||
ptyTerminalService = new PtyTerminalService();
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
|
|
@ -1177,6 +1270,10 @@ async function initializeServices(): Promise<void> {
|
|||
async function startHttpServer(
|
||||
modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void>
|
||||
): Promise<void> {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (httpServer.isRunning()) {
|
||||
await syncTeamControlApiState();
|
||||
|
|
@ -1200,6 +1297,11 @@ async function startHttpServer(
|
|||
modeSwitchHandler,
|
||||
config.httpServer?.port ?? 3456
|
||||
);
|
||||
if (isShutdownStarted()) {
|
||||
await httpServer.stop().catch(() => undefined);
|
||||
await clearTeamControlApiState().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await syncTeamControlApiState();
|
||||
logger.info(`HTTP sidecar server running on port ${port}`);
|
||||
} catch (error) {
|
||||
|
|
@ -1212,100 +1314,115 @@ async function startHttpServer(
|
|||
/**
|
||||
* Shuts down all services.
|
||||
*/
|
||||
function shutdownServices(): void {
|
||||
logger.info('Shutting down services...');
|
||||
|
||||
// Clear pending auto-resume timers before anything else — otherwise the
|
||||
// dangling setTimeout handles keep the event loop alive past shutdown and
|
||||
// may fire against a torn-down provisioning service.
|
||||
clearAutoResumeService();
|
||||
|
||||
// Kill all team CLI processes via SIGKILL BEFORE anything else.
|
||||
// This must happen before the OS closes stdin pipes (on app exit),
|
||||
// because stdin EOF triggers CLI's graceful shutdown which deletes team files.
|
||||
if (teamProvisioningService) {
|
||||
teamProvisioningService.stopAllTeams();
|
||||
async function shutdownServices(): Promise<void> {
|
||||
if (shutdownPromise) {
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
// Best-effort cleanup of MCP config files owned by this process
|
||||
void new TeamMcpConfigBuilder().gcOwnConfigs();
|
||||
shutdownPromise = (async () => {
|
||||
logger.info('Shutting down services...');
|
||||
|
||||
// Sync backup all team data (files are stable after SIGKILL).
|
||||
if (teamBackupService) {
|
||||
teamBackupService.runShutdownBackupSync();
|
||||
}
|
||||
clearStartupTimers();
|
||||
clearInboxNotifyTimers();
|
||||
|
||||
// Stop HTTP server
|
||||
if (httpServer?.isRunning()) {
|
||||
void httpServer.stop();
|
||||
}
|
||||
void clearTeamControlApiState();
|
||||
// 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();
|
||||
|
||||
// Clean up file watcher event listeners
|
||||
if (fileChangeCleanup) {
|
||||
fileChangeCleanup();
|
||||
fileChangeCleanup = null;
|
||||
}
|
||||
if (todoChangeCleanup) {
|
||||
todoChangeCleanup();
|
||||
todoChangeCleanup = null;
|
||||
}
|
||||
if (teamChangeCleanup) {
|
||||
teamChangeCleanup();
|
||||
teamChangeCleanup = null;
|
||||
}
|
||||
// Kill all team CLI processes via SIGKILL before anything else.
|
||||
// This must happen before the OS closes stdin pipes on app exit, because
|
||||
// stdin EOF triggers CLI cleanup that can delete team files.
|
||||
if (teamProvisioningService) {
|
||||
await runShutdownStep('stop all teams', () => teamProvisioningService.stopAllTeams(), 10_000);
|
||||
}
|
||||
await runShutdownStep('tracked CLI subprocess cleanup', () =>
|
||||
killTrackedCliProcesses('SIGKILL')
|
||||
);
|
||||
|
||||
// Clean up editor state (watcher, git service)
|
||||
cleanupEditorState();
|
||||
await runShutdownStep('MCP config GC', () => new TeamMcpConfigBuilder().gcOwnConfigs());
|
||||
|
||||
// Dispose all contexts (including local)
|
||||
if (contextRegistry) {
|
||||
contextRegistry.dispose();
|
||||
}
|
||||
// Sync backup all team data. Files are stable after SIGKILL.
|
||||
if (teamBackupService) {
|
||||
await runShutdownStep('team backup sync', () => teamBackupService?.runShutdownBackupSync());
|
||||
}
|
||||
|
||||
// Dispose SSH connection manager
|
||||
if (sshConnectionManager) {
|
||||
sshConnectionManager.dispose();
|
||||
}
|
||||
if (httpServer?.isRunning()) {
|
||||
await runShutdownStep('HTTP server stop', () => httpServer.stop());
|
||||
}
|
||||
await runShutdownStep('team control state cleanup', () => clearTeamControlApiState());
|
||||
|
||||
// Stop background polling timers (prevents hanging shutdown).
|
||||
if (teamDataService) {
|
||||
teamDataService.stopProcessHealthPolling();
|
||||
}
|
||||
if (teamTaskStallMonitor) {
|
||||
void teamTaskStallMonitor.stop();
|
||||
teamTaskStallMonitor = null;
|
||||
}
|
||||
branchStatusService?.dispose();
|
||||
branchStatusService = null;
|
||||
await runShutdownStep('file watcher event cleanup', () => {
|
||||
if (fileChangeCleanup) {
|
||||
fileChangeCleanup();
|
||||
fileChangeCleanup = null;
|
||||
}
|
||||
if (todoChangeCleanup) {
|
||||
todoChangeCleanup();
|
||||
todoChangeCleanup = null;
|
||||
}
|
||||
if (teamChangeCleanup) {
|
||||
teamChangeCleanup();
|
||||
teamChangeCleanup = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Stop scheduled task execution and croner jobs
|
||||
if (schedulerService) {
|
||||
void schedulerService.stop();
|
||||
}
|
||||
await runShutdownStep('editor cleanup', () => cleanupEditorState());
|
||||
|
||||
void skillsWatcherService?.stopAll();
|
||||
providerConnectionService.setCodexModelCatalogFeature(null);
|
||||
providerConnectionService.setCodexAccountFeature(null);
|
||||
void codexModelCatalogFeature?.dispose();
|
||||
codexModelCatalogFeature = null;
|
||||
void codexAccountFeature?.dispose();
|
||||
codexAccountFeature = null;
|
||||
if (contextRegistry) {
|
||||
await runShutdownStep('context registry dispose', () => contextRegistry.dispose());
|
||||
}
|
||||
|
||||
// Kill all PTY processes
|
||||
if (ptyTerminalService) {
|
||||
ptyTerminalService.killAll();
|
||||
}
|
||||
if (sshConnectionManager) {
|
||||
await runShutdownStep('SSH connection manager dispose', () => sshConnectionManager.dispose());
|
||||
}
|
||||
|
||||
// Remove IPC handlers
|
||||
removeIpcHandlers();
|
||||
removeCodexAccountIpc(ipcMain);
|
||||
removeRecentProjectsIpc(ipcMain);
|
||||
if (teamDataService) {
|
||||
await runShutdownStep('team data polling stop', () =>
|
||||
teamDataService.stopProcessHealthPolling()
|
||||
);
|
||||
}
|
||||
if (updaterService) {
|
||||
await runShutdownStep('updater periodic check stop', () =>
|
||||
updaterService.stopPeriodicCheck()
|
||||
);
|
||||
}
|
||||
if (teamTaskStallMonitor) {
|
||||
await runShutdownStep('team task stall monitor stop', () => teamTaskStallMonitor?.stop());
|
||||
teamTaskStallMonitor = null;
|
||||
}
|
||||
await runShutdownStep('branch status dispose', () => branchStatusService?.dispose());
|
||||
branchStatusService = null;
|
||||
|
||||
// Dispose backup service timers
|
||||
teamBackupService?.dispose();
|
||||
if (schedulerService) {
|
||||
await runShutdownStep('scheduler stop', () => schedulerService.stop());
|
||||
}
|
||||
|
||||
logger.info('Services shut down successfully');
|
||||
await runShutdownStep('skills watcher stop', () => skillsWatcherService?.stopAll());
|
||||
await runShutdownStep('provider connection feature detach', () => {
|
||||
providerConnectionService.setCodexModelCatalogFeature(null);
|
||||
providerConnectionService.setCodexAccountFeature(null);
|
||||
});
|
||||
await runShutdownStep('Codex model catalog dispose', () => codexModelCatalogFeature?.dispose());
|
||||
codexModelCatalogFeature = null;
|
||||
await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose());
|
||||
codexAccountFeature = null;
|
||||
|
||||
if (ptyTerminalService) {
|
||||
await runShutdownStep('PTY terminals kill', () => ptyTerminalService.killAll());
|
||||
}
|
||||
|
||||
await runShutdownStep('IPC handlers cleanup', () => {
|
||||
removeIpcHandlers();
|
||||
removeCodexAccountIpc(ipcMain);
|
||||
removeRecentProjectsIpc(ipcMain);
|
||||
});
|
||||
|
||||
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());
|
||||
|
||||
logger.info('Services shut down successfully');
|
||||
})();
|
||||
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1322,6 +1439,9 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
|
|||
}
|
||||
|
||||
function scheduleRendererRecovery(win: BrowserWindow): void {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
if (rendererRecoveryTimer) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1336,6 +1456,9 @@ function scheduleRendererRecovery(win: BrowserWindow): void {
|
|||
|
||||
rendererRecoveryTimer = setTimeout(() => {
|
||||
rendererRecoveryTimer = null;
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
if (!mainWindow || mainWindow !== win || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1347,12 +1470,17 @@ function scheduleRendererRecovery(win: BrowserWindow): void {
|
|||
logger.error(`Renderer recovery reload failed: ${String(error)}`);
|
||||
}
|
||||
}, delayMs);
|
||||
rendererRecoveryTimer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the main application window.
|
||||
*/
|
||||
function createWindow(): void {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
|
|
@ -1440,6 +1568,9 @@ function createWindow(): void {
|
|||
});
|
||||
|
||||
mainWindow.webContents.on('did-start-loading', () => {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
markRendererUnavailable(mainWindow);
|
||||
branchStatusService?.resetAllTracking();
|
||||
});
|
||||
|
|
@ -1447,6 +1578,9 @@ function createWindow(): void {
|
|||
// Set traffic light position + notify renderer on first load, and auto-check for updates
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
markRendererReady(mainWindow);
|
||||
rendererRecoveryAttempts = 0;
|
||||
if (rendererRecoveryTimer) {
|
||||
|
|
@ -1455,9 +1589,12 @@ function createWindow(): void {
|
|||
}
|
||||
logger.warn('[startup] renderer did-finish-load');
|
||||
syncTrafficLightPosition(mainWindow);
|
||||
setTimeout(() => {
|
||||
safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, mainWindow?.isFullScreen());
|
||||
const fullscreenSyncTimer = setTimeout(() => {
|
||||
if (!isShutdownStarted()) {
|
||||
safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, mainWindow?.isFullScreen());
|
||||
}
|
||||
}, 0);
|
||||
fullscreenSyncTimer.unref?.();
|
||||
// Start file watchers now that the window is visible and responsive.
|
||||
// Deferred from initializeServices() to avoid blocking window creation
|
||||
// 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
|
||||
// its initial IPC calls without UV thread pool contention. Recursive
|
||||
// fs.watch() on NTFS saturates all 4 default UV threads.
|
||||
setTimeout(() => activeContext.startFileWatcher(), 1500);
|
||||
scheduleStartupTask(() => activeContext.startFileWatcher(), 1500);
|
||||
} else {
|
||||
activeContext.startFileWatcher();
|
||||
if (!isShutdownStarted()) {
|
||||
activeContext.startFileWatcher();
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => updaterService.checkForUpdates(), 3000);
|
||||
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
||||
if (!isShutdownStarted()) {
|
||||
scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000);
|
||||
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Defer non-critical startup work to avoid thread pool contention.
|
||||
// The window is now visible and responsive; these run in the background.
|
||||
setTimeout(() => {
|
||||
scheduleStartupTask(() => {
|
||||
void teamProvisioningService.warmup();
|
||||
teamDataService.startProcessHealthPolling();
|
||||
void schedulerService?.start();
|
||||
|
|
@ -1546,11 +1687,12 @@ function createWindow(): void {
|
|||
|
||||
// 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') {
|
||||
setTimeout(() => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const zoomSyncTimer = setTimeout(() => {
|
||||
if (!isShutdownStarted() && mainWindow && !mainWindow.isDestroyed()) {
|
||||
syncTrafficLightPosition(mainWindow);
|
||||
}
|
||||
}, 100);
|
||||
zoomSyncTimer.unref?.();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1588,6 +1730,9 @@ function createWindow(): void {
|
|||
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
logger.error('Renderer process gone:', details.reason, details.exitCode);
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
markRendererUnavailable(mainWindow);
|
||||
branchStatusService?.resetAllTracking();
|
||||
const activeContext = contextRegistry.getActive();
|
||||
|
|
@ -1666,6 +1811,9 @@ void app.whenReady().then(async () => {
|
|||
|
||||
// Listen for notification click events
|
||||
notificationManager.on('notification-clicked', (_error) => {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
|
|
@ -1679,6 +1827,9 @@ void app.whenReady().then(async () => {
|
|||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
|
|
@ -1689,7 +1840,10 @@ void app.whenReady().then(async () => {
|
|||
* All windows closed handler.
|
||||
*/
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
const shouldQuitWhenAllWindowsClosed =
|
||||
process.platform !== 'darwin' || !configManager.getConfig().general.showDockIcon;
|
||||
|
||||
if (shouldQuitWhenAllWindowsClosed) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
|
@ -1697,6 +1851,25 @@ app.on('window-all-closed', () => {
|
|||
/**
|
||||
* Before quit handler - cleanup.
|
||||
*/
|
||||
app.on('before-quit', () => {
|
||||
shutdownServices();
|
||||
app.on('before-quit', (event) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
* Prevents invalid/unknown data from mutating persisted config.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
AppConfig,
|
||||
|
|
|
|||
|
|
@ -90,19 +90,19 @@ import {
|
|||
extractUserFlags,
|
||||
PROTECTED_CLI_FLAGS,
|
||||
} from '@shared/utils/cliArgsParser';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
formatEffortLevelListForProvider,
|
||||
isTeamEffortLevelForProvider,
|
||||
} from '@shared/utils/effortLevels';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import {
|
||||
buildStandaloneSlashCommandMeta,
|
||||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import crypto from 'crypto';
|
||||
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -153,6 +153,7 @@ import type {
|
|||
TeamProvisioningService,
|
||||
} from '../services';
|
||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||
import type { TeamMembersMetaFile } from '../services/team/TeamMembersMetaStore';
|
||||
import type {
|
||||
AddTaskCommentRequest,
|
||||
AgentActionMode,
|
||||
|
|
@ -189,11 +190,11 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamFastMode,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamFastMode,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
|
|
@ -209,7 +210,6 @@ import type {
|
|||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||
import type { TeamMembersMetaFile } from '../services/team/TeamMembersMetaStore';
|
||||
|
||||
const logger = createLogger('IPC:teams');
|
||||
|
||||
|
|
@ -1018,7 +1018,7 @@ async function handleDeleteTeam(
|
|||
}
|
||||
return wrapTeamHandler('deleteTeam', async () => {
|
||||
getAutoResumeService().cancelPendingAutoResume(validated.value!);
|
||||
getTeamProvisioningService().stopTeam(validated.value!);
|
||||
await getTeamProvisioningService().stopTeam(validated.value!);
|
||||
await getTeamDataService().deleteTeam(validated.value!);
|
||||
});
|
||||
}
|
||||
|
|
@ -1217,7 +1217,7 @@ function parseOptionalTeamFastMode(
|
|||
};
|
||||
}
|
||||
|
||||
type RuntimeRosterMutationMember = {
|
||||
interface RuntimeRosterMutationMember {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
|
|
@ -1228,7 +1228,7 @@ type RuntimeRosterMutationMember = {
|
|||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
removedAt?: number | string | null;
|
||||
};
|
||||
}
|
||||
|
||||
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.';
|
||||
|
|
@ -3402,7 +3402,7 @@ async function handleStopTeam(
|
|||
return wrapTeamHandler('stop', async () => {
|
||||
addMainBreadcrumb('team', 'stop', { teamName: 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(
|
||||
previousMembers
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => [member.name.trim().toLowerCase(), member as RuntimeRosterMutationMember])
|
||||
.map((member) => [member.name.trim().toLowerCase(), member])
|
||||
);
|
||||
const nextByName = new Map(
|
||||
members.map((member) => [
|
||||
|
|
@ -4556,7 +4556,7 @@ async function handleGetSavedRequest(
|
|||
),
|
||||
model: meta.model,
|
||||
effort: meta.effort as TeamCreateRequest['effort'],
|
||||
fastMode: meta.fastMode as TeamCreateRequest['fastMode'],
|
||||
fastMode: meta.fastMode,
|
||||
skipPermissions: meta.skipPermissions,
|
||||
worktree: meta.worktree,
|
||||
extraCliArgs: meta.extraCliArgs,
|
||||
|
|
|
|||
|
|
@ -66,9 +66,9 @@ async function handleDownload(_event: IpcMainInvokeEvent): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function handleInstall(_event: IpcMainInvokeEvent): void {
|
||||
async function handleInstall(_event: IpcMainInvokeEvent): Promise<void> {
|
||||
try {
|
||||
updaterService.quitAndInstall();
|
||||
await updaterService.quitAndInstall();
|
||||
} catch (error) {
|
||||
logger.error('Error in updater:install:', getErrorMessage(error));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
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';
|
||||
|
||||
|
|
@ -18,14 +18,20 @@ function getMainWindow(): BrowserWindow | 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 {
|
||||
ipcMain.handle('window:minimize', () => {
|
||||
const win = getMainWindow();
|
||||
ipcMain.handle('window:minimize', (event) => {
|
||||
const win = getWindowForEvent(event);
|
||||
if (win && !win.isDestroyed()) win.minimize();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
const win = getMainWindow();
|
||||
ipcMain.handle('window:maximize', (event) => {
|
||||
const win = getWindowForEvent(event);
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (win.isMaximized()) win.unmaximize();
|
||||
else win.maximize();
|
||||
|
|
@ -33,23 +39,22 @@ export function registerWindowHandlers(ipcMain: IpcMain): void {
|
|||
});
|
||||
|
||||
ipcMain.handle('window:close', () => {
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) win.close();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:isMaximized', (): boolean => {
|
||||
const win = getMainWindow();
|
||||
ipcMain.handle('window:isMaximized', (event): boolean => {
|
||||
const win = getWindowForEvent(event);
|
||||
return win != null && !win.isDestroyed() && win.isMaximized();
|
||||
});
|
||||
|
||||
ipcMain.handle(WINDOW_IS_FULLSCREEN, (): boolean => {
|
||||
const win = getMainWindow();
|
||||
ipcMain.handle(WINDOW_IS_FULLSCREEN, (event): boolean => {
|
||||
const win = getWindowForEvent(event);
|
||||
return win != null && !win.isDestroyed() && win.isFullScreen();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:relaunch', () => {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
logger.info('Window handlers registered');
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ function githubGet(url: string): Promise<{ statusCode: number; body: string }> {
|
|||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'claude-devtools',
|
||||
'User-Agent': 'agent-teams-ui',
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
* - Load configuration from disk on initialization
|
||||
|
|
@ -25,10 +25,51 @@ import type { SshConnectionProfile } from '@shared/types/api';
|
|||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* NotificationManager service - Manages native notifications and notification history.
|
||||
*
|
||||
* 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)
|
||||
* - Two adapters: addError() for error notifications, addTeamNotification() for team events
|
||||
* - Shared internal pipeline: storeNotification() for unconditional storage + IPC emission
|
||||
|
|
@ -93,7 +93,19 @@ const MAX_NOTIFICATIONS = 100;
|
|||
const THROTTLE_MS = 5000;
|
||||
|
||||
/** 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';
|
||||
|
||||
|
|
@ -111,6 +123,64 @@ function getNotificationClass(): NotificationClass | 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
|
||||
// =============================================================================
|
||||
|
|
@ -133,6 +203,7 @@ export class NotificationManager extends EventEmitter {
|
|||
* Used by addError() to wait for notifications to be loaded from disk
|
||||
* before writing, preventing a race where save overwrites unloaded data. */
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private notificationsPath = NOTIFICATIONS_PATH;
|
||||
|
||||
constructor(configManager?: ConfigManager) {
|
||||
super();
|
||||
|
|
@ -183,6 +254,7 @@ export class NotificationManager extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
this.notificationsPath = await migrateLegacyNotificationPath();
|
||||
await this.loadNotifications();
|
||||
this.pruneNotifications();
|
||||
this.isInitialized = true;
|
||||
|
|
@ -208,7 +280,7 @@ export class NotificationManager extends EventEmitter {
|
|||
*/
|
||||
private async loadNotifications(): Promise<void> {
|
||||
try {
|
||||
const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
|
||||
const data = await fsp.readFile(this.notificationsPath, 'utf8');
|
||||
const parsed = JSON.parse(data) as unknown;
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
|
|
@ -233,11 +305,11 @@ export class NotificationManager extends EventEmitter {
|
|||
*/
|
||||
private saveNotifications(): void {
|
||||
const data = JSON.stringify(this.notifications, null, 2);
|
||||
const dir = path.dirname(NOTIFICATIONS_PATH);
|
||||
const dir = path.dirname(this.notificationsPath);
|
||||
|
||||
fsp
|
||||
.mkdir(dir, { recursive: true })
|
||||
.then(() => fsp.writeFile(NOTIFICATIONS_PATH, data, 'utf8'))
|
||||
.then(() => fsp.writeFile(this.notificationsPath, data, 'utf8'))
|
||||
.catch((error) => {
|
||||
logger.error('Error saving notifications:', error);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ const { autoUpdater } = electronUpdater;
|
|||
import { app, net } from 'electron';
|
||||
|
||||
import {
|
||||
getExpectedReleaseAssetUrl,
|
||||
getLatestMacMetadataUrl,
|
||||
getExpectedReleaseAssetUrls,
|
||||
getLatestMacMetadataUrls,
|
||||
isLatestMacMetadataCompatible,
|
||||
} 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> {
|
||||
try {
|
||||
const response = await net.fetch(url, { method: 'GET' });
|
||||
|
|
@ -60,6 +69,7 @@ export class UpdaterService {
|
|||
private mainWindow: BrowserWindow | null = null;
|
||||
private periodicTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private downloadedVersion: string | null = null;
|
||||
private beforeQuitAndInstall: (() => Promise<void>) | null = null;
|
||||
|
||||
constructor() {
|
||||
autoUpdater.autoDownload = false;
|
||||
|
|
@ -75,6 +85,10 @@ export class UpdaterService {
|
|||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
setBeforeQuitAndInstall(handler: (() => Promise<void>) | null): void {
|
||||
this.beforeQuitAndInstall = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for available updates.
|
||||
*/
|
||||
|
|
@ -104,7 +118,7 @@ export class UpdaterService {
|
|||
* On Windows (NSIS): isSilent=true runs the installer with /S (no wizard);
|
||||
* isForceRunAfter=true launches the app after install. Other platforms ignore these.
|
||||
*/
|
||||
quitAndInstall(): void {
|
||||
async quitAndInstall(): Promise<void> {
|
||||
if (!this.downloadedVersion || !this.isNewerThanCurrent(this.downloadedVersion)) {
|
||||
logger.warn(
|
||||
`Refusing to install non-newer update. current=${app.getVersion()} downloaded=${this.downloadedVersion ?? 'unknown'}`
|
||||
|
|
@ -116,6 +130,7 @@ export class UpdaterService {
|
|||
return;
|
||||
}
|
||||
|
||||
await this.beforeQuitAndInstall?.();
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
}
|
||||
|
||||
|
|
@ -156,14 +171,16 @@ export class UpdaterService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const metadataUrl = getLatestMacMetadataUrl(version);
|
||||
const metadataText = await fetchText(metadataUrl);
|
||||
if (!metadataText) {
|
||||
logger.warn(`latest-mac.yml is not available for ${version} (${metadataUrl})`);
|
||||
return false;
|
||||
const metadataUrls = getLatestMacMetadataUrls(version);
|
||||
for (const metadataUrl of metadataUrls) {
|
||||
const metadataText = await fetchText(metadataUrl);
|
||||
if (metadataText && isLatestMacMetadataCompatible(metadataText, version, process.arch)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const url = getExpectedReleaseAssetUrl(info.version, process.platform, process.arch);
|
||||
if (url) {
|
||||
const exists = await assetExists(url);
|
||||
const urls = getExpectedReleaseAssetUrls(info.version, process.platform, process.arch);
|
||||
if (urls.length > 0) {
|
||||
const exists = await assetExistsInAnyRepo(urls);
|
||||
if (!exists) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export class CodexAppServerSessionFactory {
|
|||
'initialize',
|
||||
{
|
||||
clientInfo: {
|
||||
name: 'claude-agent-teams-ui',
|
||||
name: 'agent-teams-ai',
|
||||
title: 'Agent Teams UI',
|
||||
version: '0.1.0',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
const REPO_OWNER = '777genius';
|
||||
const REPO_NAME = 'claude_agent_teams_ui';
|
||||
const PLANNED_REPO_NAME = 'agent-teams-ai';
|
||||
|
||||
export function buildReleaseAssetBase(version: string): string {
|
||||
return `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/v${version}`;
|
||||
export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string {
|
||||
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(
|
||||
|
|
@ -16,7 +21,7 @@ export function getExpectedReleaseAssetUrl(
|
|||
case 'darwin':
|
||||
return arch === 'arm64'
|
||||
? `${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':
|
||||
return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`;
|
||||
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 {
|
||||
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(
|
||||
version: string,
|
||||
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.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 {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
|
||||
import {
|
||||
createDefaultCliExtensionCapabilities,
|
||||
createLegacyRuntimeFallbackCliExtensionCapabilities,
|
||||
} from '@shared/utils/providerExtensionCapabilities';
|
||||
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
|
||||
|
||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import type {
|
|||
CodexAccountSnapshotDto,
|
||||
} from '@features/codex-account/contracts';
|
||||
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 {
|
||||
CliProviderAuthMode,
|
||||
CliProviderConnectionInfo,
|
||||
|
|
@ -107,6 +111,20 @@ export class ProviderConnectionService {
|
|||
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 {
|
||||
this.apiKeyService = apiKeyService;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
|
||||
import {
|
||||
getTaskChangeStateBucket,
|
||||
isTaskChangeSummaryCacheable,
|
||||
|
|
@ -11,6 +12,7 @@ import * as path from 'path';
|
|||
|
||||
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
|
||||
import { TaskChangeComputer } from './TaskChangeComputer';
|
||||
import { TaskChangeLedgerReader } from './TaskChangeLedgerReader';
|
||||
import {
|
||||
buildTaskChangePresenceDescriptor,
|
||||
computeTaskChangePresenceProjectFingerprint,
|
||||
|
|
@ -22,7 +24,6 @@ import {
|
|||
type TaskChangeEffectiveOptions,
|
||||
type TaskChangeTaskMeta,
|
||||
} from './taskChangeWorkerTypes';
|
||||
import { TaskChangeLedgerReader } from './TaskChangeLedgerReader';
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
|
||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||
|
|
@ -31,7 +32,6 @@ import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient';
|
|||
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
||||
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types';
|
||||
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
|
||||
|
||||
const logger = createLogger('Service:ChangeExtractorService');
|
||||
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ export class FileContentResolver {
|
|||
|
||||
/** Invalidate cached content for a file (e.g. after user saves edits) */
|
||||
invalidateFile(filePath: string): void {
|
||||
const normalizedFilePath = this.normalizeResolverPath(filePath);
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.endsWith(`:${filePath}`)) {
|
||||
if (key.endsWith(`:${normalizedFilePath}`)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +77,7 @@ export class FileContentResolver {
|
|||
logger.debug(`Файл недоступен на диске: ${filePath}`);
|
||||
}
|
||||
|
||||
const cacheKey = `${teamName}:${memberName}:${filePath}`;
|
||||
const cacheKey = this.buildCacheKey(teamName, memberName, filePath);
|
||||
const validationFingerprint = this.buildValidationFingerprint(
|
||||
filePath,
|
||||
currentContent,
|
||||
|
|
@ -294,6 +295,15 @@ export class FileContentResolver {
|
|||
const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null);
|
||||
const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : 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;
|
||||
}
|
||||
|
||||
|
|
@ -613,6 +623,10 @@ export class FileContentResolver {
|
|||
return normalizePathForComparison(filePath);
|
||||
}
|
||||
|
||||
private buildCacheKey(teamName: string, memberName: string, filePath: string): string {
|
||||
return `${teamName}:${memberName}:${this.normalizeResolverPath(filePath)}`;
|
||||
}
|
||||
|
||||
private hashString(input: string): string {
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
|
||||
import { createHash } from 'crypto';
|
||||
import { applyPatch, structuredPatch } from 'diff';
|
||||
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.',
|
||||
};
|
||||
}
|
||||
const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256);
|
||||
const guard = await this.checkLedgerCurrentHash(
|
||||
filePath,
|
||||
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
|
||||
);
|
||||
if (!guard.ok) {
|
||||
return guard.outcome;
|
||||
}
|
||||
|
|
@ -629,8 +633,19 @@ export class ReviewApplierService {
|
|||
}
|
||||
|
||||
private resolveLedgerOperation(snippets: SnippetDiff[]): 'create' | 'modify' | 'delete' {
|
||||
if (snippets.some((snippet) => snippet.ledger?.operation === 'create')) return 'create';
|
||||
if (snippets[snippets.length - 1]?.ledger?.operation === 'delete') return 'delete';
|
||||
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger);
|
||||
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';
|
||||
}
|
||||
|
||||
|
|
@ -743,8 +758,13 @@ export class ReviewApplierService {
|
|||
}
|
||||
|
||||
private pathMatchesRelationPath(filePath: string, relationPath: string): boolean {
|
||||
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
||||
const normalizedRelationPath = relationPath.replace(/\\/g, '/');
|
||||
const caseInsensitive =
|
||||
this.isWindowsReviewPath(filePath) || this.isWindowsReviewPath(relationPath);
|
||||
const normalizedFilePath = this.normalizeRelationComparisonPath(filePath, caseInsensitive);
|
||||
const normalizedRelationPath = this.normalizeRelationComparisonPath(
|
||||
relationPath,
|
||||
caseInsensitive
|
||||
);
|
||||
return (
|
||||
normalizedFilePath === normalizedRelationPath ||
|
||||
normalizedFilePath.endsWith(`/${normalizedRelationPath}`)
|
||||
|
|
@ -759,12 +779,35 @@ export class ReviewApplierService {
|
|||
if (!anchorPath) {
|
||||
return null;
|
||||
}
|
||||
const normalizedAnchor = anchorPath.replace(/\\/g, '/');
|
||||
const normalizedRelation = anchorRelationPath.replace(/\\/g, '/');
|
||||
if (!normalizedAnchor.endsWith(normalizedRelation)) {
|
||||
const slashAnchor = anchorPath.replace(/\\/g, '/');
|
||||
const slashRelation = anchorRelationPath.replace(/\\/g, '/');
|
||||
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 `${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(
|
||||
|
|
|
|||
|
|
@ -624,7 +624,7 @@ export class TaskChangeComputer {
|
|||
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
|
||||
return {
|
||||
toolUseId: snippet.toolUseId,
|
||||
toolName: snippet.toolName as FileEditEvent['toolName'],
|
||||
toolName: snippet.toolName,
|
||||
timestamp: snippet.timestamp,
|
||||
summary: this.generateEditSummary(snippet),
|
||||
linesAdded: added,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
|
||||
import { createHash } from 'crypto';
|
||||
import { diffLines } from 'diff';
|
||||
import { open, readFile } from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import { normalizePathForComparison } from '@shared/utils/platformPath';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type {
|
||||
FileChangeSummary,
|
||||
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';
|
||||
|
||||
interface LedgerContentRef {
|
||||
|
|
@ -191,7 +200,7 @@ interface LedgerSummaryScopeV2 {
|
|||
toolUseIds: string[];
|
||||
toolUseCount: number;
|
||||
toolUseIdsTruncated?: boolean;
|
||||
phaseSet: Array<'work' | 'review'>;
|
||||
phaseSet: ('work' | 'review')[];
|
||||
executionSeqRange?: { start: number; end: number };
|
||||
confidenceBreakdown?: TaskChangeScope['confidenceBreakdown'];
|
||||
visibleFileCount: number;
|
||||
|
|
@ -270,23 +279,23 @@ interface LedgerFreshnessV2 {
|
|||
bundleKind: 'summary';
|
||||
}
|
||||
|
||||
type JournalReadResult<T> = {
|
||||
interface JournalReadResult<T> {
|
||||
entries: T[];
|
||||
recovered: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type JournalData = {
|
||||
interface JournalData {
|
||||
events: LedgerEvent[];
|
||||
notices: LedgerNotice[];
|
||||
recovered: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type SummaryBundleRead = {
|
||||
interface SummaryBundleRead {
|
||||
bundle: LedgerSummaryBundleV2;
|
||||
provenance: TaskChangeProvenance;
|
||||
mode: 'validated' | 'degraded';
|
||||
degradedWarning?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class TaskChangeLedgerReader {
|
||||
async readTaskChanges(params: {
|
||||
|
|
@ -1060,10 +1069,10 @@ export class TaskChangeLedgerReader {
|
|||
return null;
|
||||
}
|
||||
try {
|
||||
return await readFile(
|
||||
path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef),
|
||||
'utf8'
|
||||
const buffer = await readFile(
|
||||
path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef)
|
||||
);
|
||||
return decodeLedgerTextBlob(buffer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1198,6 +1207,14 @@ export class TaskChangeLedgerReader {
|
|||
}
|
||||
const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets);
|
||||
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({
|
||||
filePath: displayPath,
|
||||
relativePath: this.relativePath(displayPath, projectPath),
|
||||
|
|
@ -1206,9 +1223,9 @@ export class TaskChangeLedgerReader {
|
|||
linesRemoved,
|
||||
isNewFile:
|
||||
relation?.kind !== 'rename' &&
|
||||
entry.snippets.some(
|
||||
(snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create'
|
||||
),
|
||||
(baselineExists === undefined || finalExists === undefined
|
||||
? fallbackIsCreated
|
||||
: isCreatedLifecycle),
|
||||
changeKey: relation
|
||||
? this.relationChangeKey(relation, worktreeLedger?.worktreePath)
|
||||
: `path:${normalizePathForComparison(displayPath)}`,
|
||||
|
|
@ -1428,8 +1445,13 @@ export class TaskChangeLedgerReader {
|
|||
}
|
||||
|
||||
private pathMatchesRelationPath(filePath: string, relationPath: string): boolean {
|
||||
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
||||
const normalizedRelationPath = relationPath.replace(/\\/g, '/');
|
||||
const caseInsensitive =
|
||||
this.isWindowsReviewPath(filePath) || this.isWindowsReviewPath(relationPath);
|
||||
const normalizedFilePath = this.normalizeRelationComparisonPath(filePath, caseInsensitive);
|
||||
const normalizedRelationPath = this.normalizeRelationComparisonPath(
|
||||
relationPath,
|
||||
caseInsensitive
|
||||
);
|
||||
return (
|
||||
normalizedFilePath === normalizedRelationPath ||
|
||||
normalizedFilePath.endsWith(`/${normalizedRelationPath}`)
|
||||
|
|
@ -1441,14 +1463,37 @@ export class TaskChangeLedgerReader {
|
|||
anchorRelationPath: string,
|
||||
targetRelationPath: string
|
||||
): string | null {
|
||||
const normalizedAnchor = anchorPath.replace(/\\/g, '/');
|
||||
const normalizedAnchorRelation = anchorRelationPath.replace(/\\/g, '/');
|
||||
if (!normalizedAnchor.endsWith(normalizedAnchorRelation)) {
|
||||
const slashAnchor = anchorPath.replace(/\\/g, '/');
|
||||
const slashAnchorRelation = anchorRelationPath.replace(/\\/g, '/');
|
||||
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 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}`)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { readBootstrapLaunchSnapshot } from './TeamBootstrapStateReader';
|
|||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||
import {
|
||||
type LaunchStateSummary,
|
||||
choosePreferredLaunchStateSummary,
|
||||
type LaunchStateSummary,
|
||||
normalizePersistedLaunchSummaryProjection,
|
||||
shouldSuppressLegacyLaunchArtifactHeuristic,
|
||||
TEAM_LAUNCH_SUMMARY_FILE,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue