diff --git a/.github/CLA.md b/.github/CLA.md index d9591004..087b7d27 100644 --- a/.github/CLA.md +++ b/.github/CLA.md @@ -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._ diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ef24b2b9..ce84b163 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for contributing to Claude Agent Teams UI! +Thanks for contributing to Agent Teams! ## Before You Start diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 579f1c17..bd33d82a 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -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`. diff --git a/.github/workflows/landing.yml b/.github/workflows/landing.yml index 303564d3..c57c16f4 100644 --- a/.github/workflows/landing.yml +++ b/.github/workflows/landing.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82ac7260..4f6edcfd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/CLAUDE.md b/CLAUDE.md index 19a14f8e..ed080edb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 522f3bf9..b4e7aacc 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ Code Review  Team View  Task Detail  - Claude Agent Teams UI  + Agent Teams  Execution Logs  Agent Comments  Create Team  Settings

-

Agent Teams UI

+

Agent Teams

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. @@ -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) | diff --git a/agent-teams-controller/src/internal/agenda.js b/agent-teams-controller/src/internal/agenda.js index 2a032ee2..913af749 100644 --- a/agent-teams-controller/src/internal/agenda.js +++ b/agent-teams-controller/src/internal/agenda.js @@ -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) { diff --git a/agent-teams-controller/src/internal/kanban.js b/agent-teams-controller/src/internal/kanban.js index 46d49d6d..e195522b 100644 --- a/agent-teams-controller/src/internal/kanban.js +++ b/agent-teams-controller/src/internal/kanban.js @@ -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)); diff --git a/agent-teams-controller/src/internal/kanbanStore.js b/agent-teams-controller/src/internal/kanbanStore.js index 2ae590ae..8b3414e5 100644 --- a/agent-teams-controller/src/internal/kanbanStore.js +++ b/agent-teams-controller/src/internal/kanbanStore.js @@ -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; diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index d66c40b9..dc56da68 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -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: "" }\n\n` + `When approved, use MCP tool review_approve:\n` + - `{ teamName: "${context.teamName}", taskId: "${task.id}", note?: "", notifyOwner: true }\n\n` + + `{ teamName: "${context.teamName}", taskId: "${task.id}", from: "", 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: "", 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, diff --git a/agent-teams-controller/src/internal/reviewState.js b/agent-teams-controller/src/internal/reviewState.js new file mode 100644 index 00000000..64daa740 --- /dev/null +++ b/agent-teams-controller/src/internal/reviewState.js @@ -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, +}; diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index f8fadc91..e7ee7ca4 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -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, diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index 09ad7383..62f489d3 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -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) { diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index d7b8cc29..feece6e0 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -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: "", from: "" } 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: "", note?: "", notifyOwner: true } + { teamName: "${teamName}", taskId: "", from: "", 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: "", comment: "" } + { teamName: "${teamName}", taskId: "", from: "", comment: "" } 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: diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 967b28a0..71fde494 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -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 }); diff --git a/bun.lock b/bun.lock index 750e48b5..13c2badc 100644 --- a/bun.lock +++ b/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=="], diff --git a/docker/Dockerfile b/docker/Dockerfile index c1cd5213..51fe7153 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index db820738..4b169984 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a3ffdfd9..825bb941 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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 +## Agent Teams v <1-2 sentence summary of the release> @@ -144,7 +144,7 @@ EOF macOS Apple Silicon
- + macOS Intel @@ -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--arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | -| macOS x64 DMG | `Claude.Agent.Teams.UI-.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | -| macOS arm64 ZIP | `Claude.Agent.Teams.UI--arm64-mac.zip` | — | -| macOS x64 ZIP | `Claude.Agent.Teams.UI--mac.zip` | — | +| macOS x64 DMG | `Claude.Agent.Teams.UI--x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | +| macOS arm64 ZIP | `Claude.Agent.Teams.UI--arm64-mac.zip` | - | +| macOS x64 ZIP | `Claude.Agent.Teams.UI--x64-mac.zip` | - | | Windows | `Claude.Agent.Teams.UI.Setup..exe` | `Claude-Agent-Teams-UI-Setup.exe` | | Linux AppImage | `Claude.Agent.Teams.UI-.AppImage` | `Claude-Agent-Teams-UI.AppImage` | | Linux deb | `claude-agent-teams-ui__amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` | diff --git a/docs/team-management/README.md b/docs/team-management/README.md index f6b29dc7..c320774b 100644 --- a/docs/team-management/README.md +++ b/docs/team-management/README.md @@ -1,6 +1,6 @@ # Team Management Feature -Интерфейс для управления командами тиммейтов Claude Code внутри Claude Agent Teams UI (Electron). +Интерфейс для управления командами тиммейтов Claude Code внутри Agent Teams (Electron). ## Что делает diff --git a/docs/team-management/opencode-native-semantic-messaging-plan.md b/docs/team-management/opencode-native-semantic-messaging-plan.md new file mode 100644 index 00000000..5f5a668f --- /dev/null +++ b/docs/team-management/opencode-native-semantic-messaging-plan.md @@ -0,0 +1,3575 @@ +# OpenCode Native Semantic Messaging Plan + +Status: planning document +Scope: `claude_team` + `agent_teams_orchestrator` +Goal: make OpenCode teammates use the correct app MCP messaging protocol without breaking Codex/Claude native teammates. + +## Problem + +OpenCode teammates currently run in a different tool environment than Codex/Claude native teammates. + +Native teammates can use `SendMessage`. +OpenCode teammates must use app MCP tools exposed by the `agent-teams` server, especially: + +- `agent-teams_message_send` +- `agent-teams_cross_team_send` for messages to other teams +- `agent-teams_member_briefing` +- `agent-teams_runtime_bootstrap_checkin` +- board tools such as `task_briefing`, `task_start`, `task_add_comment`, `task_complete` + +The current code already tells OpenCode to use `agent-teams_message_send` in some places, but other downstream prompts still contain hardcoded `SendMessage`. That creates inconsistent instructions: + +- OpenCode launch prompt says: use `agent-teams_message_send`. +- `member_briefing` says: use `SendMessage`. +- task assignment notification says: use `SendMessage`. +- clarification protocol says: use `SendMessage`. + +This can make OpenCode teammates look started but not answer through the Messages UI. + +## Decision + +Chosen approach: OpenCode-native semantic messaging seam. + +Option 1: frontend-only display patch - 🎯 2 🛡️ 2 🧠 2, about 50-120 LOC +This hides symptoms only. It does not fix the wrong tool instructions sent to OpenCode. + +Option 2: orchestrator-only patch - 🎯 6 🛡️ 6 🧠 4, about 180-320 LOC +This is necessary for runtime identity and MCP proof, but not sufficient because `member_briefing` and task assignment messages are produced in `claude_team`. + +Option 3: orchestrator + `claude_team` controller/MCP semantic seam - 🎯 9 🛡️ 9 🧠 7, about 1300-2200 LOC with tests +This fixes the actual contract. Orchestrator owns OpenCode session identity. `claude_team` owns team protocol text and MCP tool schemas. + +## Extra Research Corrections + +This section records the higher-risk places that were checked after the first draft. + +- `member_briefing` is not the only source of `SendMessage` wording. `buildAssignmentMessage()` and `buildMemberTaskProtocol()` also contain hardcoded native instructions, so the fix must cover assignment and clarification paths too. +- Controller member resolution currently drops provider metadata. Without preserving `providerId`/`provider`, task assignment notifications cannot reliably choose OpenCode wording for an OpenCode owner. +- `message_send` storage already supports `taskRefs`, but MCP schema does not expose it yet. If prompts mention task traceability, schema must accept `taskRefs` or the plan creates another mismatch. +- `message_send` currently uses the raw `to` value as the inbox filename. If an agent sends to the alias `team-lead` while the configured lead is actually named `lead`, the row can land in `inboxes/team-lead.json` and bypass lead relay. `message_send` must canonicalize local recipients and sender aliases before persistence. +- OpenCode tool names appear through multiple aliases: `agent-teams_message_send`, `agent_teams_message_send`, `mcp__agent-teams__message_send`, `mcp__agent_teams__message_send`, and sometimes plain `message_send`. Capture/logging code must not hardcode only one spelling. +- `runtime_bootstrap_checkin` needs `runtimeSessionId`. The adapter cannot know it. Only orchestrator knows `record.opencodeSessionId` after `ensureSession()`, so identity injection belongs in `agent_teams_orchestrator`. +- `runtime_bootstrap_checkin` does not accept `laneId`. `laneId` is bridge/session routing state, not an MCP tool argument. The plan must not show examples with unsupported payload fields. +- `runtime_deliver_message` is a real delivery tool, not a dummy readiness marker. It writes through `RuntimeDeliveryService` into app-owned destinations. That makes it dangerous to leave ambiguous: OpenCode may choose it for ordinary replies unless descriptions/prompts clearly say normal human/team replies use `message_send` in v1. +- The required app-tool proof must cover all teammate-operational tools that `member_briefing` can instruct, not just `message_send` and four task tools. +- `claude_team` already exports `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` from `agent-teams-controller`; app-side required tools should derive from that instead of duplicating a second list. +- Orchestrator direct `mcp:tools/list` proof sees plain MCP names like `message_send`, not OpenCode canonical ids. Do not compare direct stdio results against `agent_teams_message_send` or `agent-teams_message_send`. +- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`, and the production live evidence builder reuses that field as `evidence.mcpTools.observedTools`. So direct proof should match plain names internally, but should still emit canonical OpenCode ids for public bridge/evidence output, or add a separate explicit `observedDirectToolNames` field. Do not silently change `observedMcpTools` to plain names. +- `agent_teams_orchestrator` does not currently depend on `agent-teams-controller`. Do not import the controller catalog into the orchestrator in v1 unless intentionally adding a new cross-repo/package dependency. +- `OpenCodeReadinessBridge.applyProductionE2EGate()` still builds `requiredMcpTools` from runtime-only tools. If app-side readiness starts requiring teammate-operational tools, the production E2E gate must be moved to the same app tool contract or it will validate a weaker/stale artifact. +- `assertOpenCodeProductionE2EArtifactGate()` compares expected tool ids against `evidence.mcpTools.observedTools` exactly. Stale evidence generated before this change should fail production mode clearly instead of silently pretending the stronger proof exists. +- `scripts/prove-opencode-production.mjs` is only a launcher. The production evidence JSON is built in `test/main/services/team/OpenCodeProductionGate.live.test.ts` inside `buildCandidateEvidence()`. Updating the script alone does nothing; update the live test builder and gate expectations. +- Current controller teammate-operational catalog includes more than the obvious message/task-start tools: `task_attach_comment_file`, `task_attach_file`, `task_create`, `task_create_from_message`, `task_link`, and `task_unlink` are also teammate-operational and must be included in any explicit orchestrator v1 list. +- `mcp-server/src/agent-teams-controller.d.ts` and `src/types/agent-teams-controller.d.ts` mirror controller signatures and must be updated when `memberBriefing(memberName, options)` is added. +- `agent-teams-controller` is CommonJS and existing TS code imports it as `import * as agentTeamsControllerModule from 'agent-teams-controller'`; use that pattern in new app-side imports instead of assuming a default ESM export. +- `agentTeamsToolNames.ts` currently canonicalizes only `mcp__agent-teams__` and `mcp__agent_teams__`. Its regex helper for task-boundary lines must be updated with the same alias prefixes as the canonicalizer, or task logs can keep missing OpenCode `agent-teams_task_start` style tool names. +- `TeamProvisioningService.captureSendMessages()` intentionally ignores normal non-native `message_send` after cross-team fallback handling because the MCP tool itself persists the inbox row. Alias support must not turn OpenCode `message_send` into a second live lead-process message. +- `OpenCodeSessionBridge.promptAsync()` returns after enqueueing the prompt, and `runLaunch()` currently reconciles immediately. A tool-only/bootstrap response can arrive just after that first reconcile, so launch confirmation needs a short bounded settle/preview step before final launch-state mapping. +- `cross_team_send` is a separate teammate-operational transport, not a recipient for `message_send`. The semantic seam must keep local/user/team-lead messages separate from cross-team messages. +- Large `SendMessage` blocks in `TeamProvisioningService`, `teamBootstrapPromptBuilder`, `useInboxPoller`, and native swarm prompts are mostly native runtime contracts. Do not mass-rewrite them. Instead, add routing tests proving OpenCode teammates receive the OpenCode runtime adapter/orchestrator prompt path, while native teammates keep the native `SendMessage` path. +- `OpenCodeSendMessageCommandBody` is declared twice in `OpenCodeBridgeCommandContract.ts`. TypeScript interface merging makes it compile, but it is a high-risk edit point because a future change can update only one declaration. Consolidate it before adding run-id recovery semantics. +- `RuntimeRunTombstoneStore.assertEvidenceAccepted()` rejects OpenCode runtime evidence when `currentRunId` is null. The durable `activeRunId` is not in `lanes.json`; it lives in the lane-scoped `RuntimeStoreManifest`. Evidence acceptance and message delivery recovery must read that manifest after app restart instead of adding a second run-id source to `lanes.json`. +- `cross_team_send` schema currently lacks `taskRefs` even though shared cross-team types already include `taskRefs`. Either keep cross-team taskRefs out of v1 prompts or wire it end-to-end. Do not let the semantic helper generate unsupported `taskRefs` for cross-team messages. +- UI direct-message delivery currently persists a native `memberDeliveryText` that tells teammates to use `SendMessage`, then sends the same text to OpenCode. `OpenCodeTeamRuntimeAdapter` recovers by saying "treat SendMessage as abstraction" and regex-parsing the recipient from that text. This is fragile. OpenCode runtime delivery should receive explicit recipient/actionMode/taskRefs metadata and OpenCode-native wording, not parse native prompt text. +- UI direct-message delivery currently starts OpenCode runtime delivery with `void provisioning.deliverOpenCodeMemberMessage(...)` after the inbox write succeeds. Native teammates can still read the persisted inbox row, but OpenCode lanes do not watch that inbox path. For OpenCode, a post-persist runtime delivery failure can be invisible unless the result is surfaced in `SendMessageResult` or an equivalent observable channel. +- Renderer `sendTeamMessage` is typed as `Promise` and catches IPC errors without rethrowing. Call sites in `MessagesPanel` and `TeamDetailView` attach `.catch(...)` to clear pending replies, but that catch path is currently dead. The semantic seam must not add more delivery states on top of a store action that hides failure from callers. +- `message_send` to a non-lead OpenCode teammate can be only a file write to `inboxes/.json`. Codex/Claude native teammates read their inbox files, but OpenCode secondary lanes do not. UI direct-send has a runtime bridge escape hatch, but OpenCode-to-OpenCode teammate messages, task/system notifications, and other persisted inbox routes need an OpenCode-targeted runtime relay or they can silently sit unread. +- Runtime delivery has two event shapes: `RuntimeDeliveryTeamChangeEvent` carries `data.detail`, while public `TeamChangeEvent` carries top-level `detail`. `TeamProvisioningService.createOpenCodeRuntimeDeliveryService()` currently adapts this shape before emitting to the app. Keep this adapter explicit and tested, because renderer refreshes are type-based but relay/notification/detail-sensitive app branches expect `event.detail`. +- `message_send.from` is optional today. If an OpenCode teammate calls `message_send` to `user` without `from`, `messageStore.buildMessage()` defaults to `from: "user"`. That makes the reply durable but semantically wrong: `MessagesPanel` clears pending replies by `message.to === "user"` and `message.from === memberName`. Add a guard so user-directed MCP messages require a real sender instead of silently writing a user-to-user row. + +## Non Goals + +- Do not rewrite the whole toolset abstraction. +- Do not rename native `SendMessage`. +- Do not make `runtime_deliver_message` the normal reply path. +- Do not implement a broad frontend workaround. +- Do not change Codex/Claude native flow except where a helper default keeps current wording. + +## Architecture + +### Runtime contracts + +Native teammate contract: + +```text +Use SendMessage with fields: +to, summary, message +``` + +OpenCode teammate contract: + +```text +Use MCP tool agent-teams_message_send with fields: +teamName, to, from, text, summary +For messages to other teams, use agent-teams_cross_team_send with: +teamName, toTeam, fromMember, text, summary, conversationId?, replyToConversationId? +``` + +OpenCode bootstrap contract: + +```text +1. Call agent-teams_runtime_bootstrap_checkin with runtime identity: teamName, runId, memberName, runtimeSessionId. +2. Call agent-teams_member_briefing with runtimeProvider="opencode". +3. Use agent-teams_message_send for visible local team/user messages. +4. Use agent-teams_cross_team_send for messages to other teams. +5. Do not answer app/team messages only as plain assistant text when message_send is available. +``` + +### Why not `runtime_deliver_message` + +`runtime_deliver_message` is low-level runtime evidence delivery. It requires: + +- `idempotencyKey` +- `runId` +- `teamName` +- `fromMemberName` +- `runtimeSessionId` +- `to` +- `text` +- current-run/tombstone validation + +That is too fragile as the main LLM-visible reply API. It should remain an audit/runtime channel, not the normal human-facing message tool. + +This is a conscious v1 choice, not because `runtime_deliver_message` cannot write messages. It can write through `RuntimeDeliveryService`, but making it the normal reply API would require a different contract: + +- prompts must teach idempotency key generation +- user/member/cross-team destination semantics must be unified around `to` +- taskRefs must use runtime delivery envelope shape +- UI capture must normalize runtime-delivered messages with normal `message_send` rows +- all native runtimes would still need their existing `SendMessage` abstraction + +V1 keeps the simpler visible-message contract: + +- OpenCode normal reply: `agent-teams_message_send` +- OpenCode cross-team reply: `agent-teams_cross_team_send` +- OpenCode runtime evidence/liveness: `runtime_bootstrap_checkin`, `runtime_heartbeat`, `runtime_task_event` +- OpenCode low-level idempotent runtime delivery: `runtime_deliver_message`, only when a prompt explicitly instructs the runtime-evidence flow + +### Runtime Tool Schema Guard + +`runtime_bootstrap_checkin` currently accepts: + +```ts +{ + teamName: string; + runId: string; + memberName: string; + runtimeSessionId: string; + claudeDir?: string; + controlUrl?: string; + waitTimeoutMs?: number; + observedAt?: string; + diagnostics?: string[]; + metadata?: Record; +} +``` + +It does not accept `laneId`. + +Implementation rule: + +- Use `laneId` only to find the stored OpenCode session and route bridge commands. +- Do not include `laneId` in the MCP tool payload shown to the model. +- Add a test or assertion that the identity block example does not contain `"laneId"` inside the `runtime_bootstrap_checkin` JSON. + +## File Map + +`claude_team` files: + +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/agentTeamsToolNames.ts` +- `/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js` +- `/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messages.js` +- `/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/runtimeHelpers.js` +- `/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/memberMessagingProtocol.js` +- `/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/mcpToolCatalog.js` +- `/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/taskTools.ts` +- `/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts` +- `/Users/belief/dev/projects/claude/claude_team/mcp-server/src/agent-teams-controller.d.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/types/agent-teams-controller.d.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts` +- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts` +- `/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts` +- `/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs` + +`agent_teams_orchestrator` files: + +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.test.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeEventTranslator.test.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapPromptBuilder.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/hooks/useInboxPoller.ts` + +## Implementation Steps + +### Step 1 - Add a small messaging protocol helper in controller + +Preferred location: + +```text +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/memberMessagingProtocol.js +``` + +Keep it tiny. It should produce instructions only, not send messages itself. + +Example: + +```js +function normalizeRuntimeProvider(value) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'opencode' ? 'opencode' : 'native'; +} + +function createMemberMessagingProtocol(runtimeProvider) { + const provider = normalizeRuntimeProvider(runtimeProvider); + + if (provider === 'opencode') { + return { + runtimeProvider: 'opencode', + sendToolName: 'agent-teams_message_send', + sendToolAliases: [ + 'agent-teams_message_send', + 'agent_teams_message_send', + 'mcp__agent-teams__message_send', + 'mcp__agent_teams__message_send', + 'message_send', + ], + sendLeadPhrase: 'call MCP tool agent-teams_message_send', + crossTeamPhrase: 'call MCP tool agent-teams_cross_team_send', + buildLeadMessageExample({ teamName, leadName, fromName, text, summary }) { + return `agent-teams_message_send { teamName: "${teamName}", to: "${leadName}", from: "${fromName}", text: "${text}", summary: "${summary}" }`; + }, + buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) { + return `agent-teams_cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`; + }, + }; + } + + return { + runtimeProvider: 'native', + sendToolName: 'SendMessage', + sendToolAliases: ['SendMessage'], + sendLeadPhrase: 'use SendMessage', + crossTeamPhrase: 'use the cross-team MCP tool cross_team_send', + buildLeadMessageExample({ leadName, text, summary }) { + return `SendMessage { to: "${leadName}", summary: "${summary}", message: "${text}" }`; + }, + buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) { + return `cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`; + }, + }; +} + +function isOpenCodeMember(member) { + const provider = String(member?.providerId || member?.provider || '').trim().toLowerCase(); + return provider === 'opencode'; +} + +module.exports = { + createMemberMessagingProtocol, + isOpenCodeMember, + normalizeRuntimeProvider, +}; +``` + +Acceptance: + +- No UI code depends on this helper. +- No runtime side effects. +- Native default stays `SendMessage`. +- OpenCode wording says to use the exposed alias if the exact canonical name differs. +- Cross-team wording stays on `cross_team_send`; never instruct `message_send` with `to: "cross_team_send"` or a remote team as if it were a local teammate. + +### Step 2 - Preserve provider metadata in controller member resolution + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/runtimeHelpers.js +``` + +Current risk: + +`normalizeMemberRecord()` keeps `name`, `role`, `workflow`, `agentType`, `color`, `cwd`, `removedAt`, but drops provider/model metadata. Task assignment notification cannot know that an owner is OpenCode, and future controller-side protocol inference can drift away from UI/runtime metadata. + +Edit pattern: + +```js +function copyTrimmedString(member, key) { + return typeof member[key] === 'string' && member[key].trim() + ? { [key]: member[key].trim() } + : {}; +} +``` + +Then preserve fields: + +```js +return { + name, + ...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}), + ...(typeof member.workflow === 'string' && member.workflow.trim() ? { workflow: member.workflow.trim() } : {}), + ...(typeof member.agentType === 'string' && member.agentType.trim() ? { agentType: member.agentType.trim() } : {}), + ...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}), + ...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}), + ...copyTrimmedString(member, 'providerId'), + ...copyTrimmedString(member, 'providerBackendId'), + ...copyTrimmedString(member, 'provider'), + ...copyTrimmedString(member, 'model'), + ...copyTrimmedString(member, 'effort'), + ...copyTrimmedString(member, 'fastMode'), + ...(typeof member.removedAt === 'number' ? { removedAt: member.removedAt } : {}), +}; +``` + +Also merge those fields in `mergeResolvedMember()`: + +```js +...(source.providerId ? { providerId: source.providerId } : {}), +...(source.providerBackendId ? { providerBackendId: source.providerBackendId } : {}), +...(source.provider ? { provider: source.provider } : {}), +...(source.model ? { model: source.model } : {}), +...(source.effort ? { effort: source.effort } : {}), +...(source.fastMode ? { fastMode: source.fastMode } : {}), +``` + +Acceptance: + +- Existing members without provider metadata behave unchanged. +- OpenCode owners can be detected from resolved team metadata. +- `members.meta.json` can override or fill provider fields from `config.json` without dropping model/effort/backend details. + +### Step 3 - Add `runtimeProvider` to `member_briefing` + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/taskTools.ts +``` + +Add optional schema field: + +```ts +runtimeProvider: z.enum(['native', 'opencode']).optional(), +``` + +Update execute: + +```ts +execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => ({ + content: [ + { + type: 'text' as const, + text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, { + runtimeProvider, + }), + }, + ], +}), +``` + +Then update the controller method signature. + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js +``` + +Change: + +```js +async function memberBriefing(context, memberName) { +``` + +To: + +```js +async function memberBriefing(context, memberName, options = {}) { +``` + +Inside the function: + +```js +const explicitRuntimeProvider = options.runtimeProvider; +const inferredRuntimeProvider = explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native'); +const messagingProtocol = createMemberMessagingProtocol(inferredRuntimeProvider); +``` + +Update the TypeScript declaration too. + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/mcp-server/src/agent-teams-controller.d.ts +/Users/belief/dev/projects/claude/claude_team/src/types/agent-teams-controller.d.ts +``` + +Change: + +```ts +memberBriefing(memberName: string): Promise; +``` + +To: + +```ts +memberBriefing( + memberName: string, + options?: { runtimeProvider?: 'native' | 'opencode' } +): Promise; +``` + +Why this matters: + +`mcp-server/src/tools/taskTools.ts` and app main-process TS code call into the JS controller through declarations. If both declarations are not updated, the implementation may work at runtime but fail typecheck or drift again later. + +Acceptance: + +- `member_briefing` with `runtimeProvider: "opencode"` emits OpenCode-safe instructions. +- `member_briefing` without `runtimeProvider` falls back to resolved member provider metadata. +- `member_briefing` without `runtimeProvider` and without OpenCode provider metadata remains native. +- `pnpm --filter agent-teams-mcp typecheck` stays green. + +### Step 4 - Replace hardcoded `SendMessage` in member briefing and task protocol + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js +``` + +Change: + +```js +function buildMemberTaskProtocol(teamName) { +``` + +To: + +```js +function buildMemberTaskProtocol(teamName, messagingProtocol) { +``` + +Replace hardcoded lines like: + +```text +After task_complete, notify your team lead via SendMessage. +``` + +Do not only change `buildMemberTaskProtocol()`. Current `memberBriefing()` also pushes direct top-level lines before `buildMemberTaskProtocol()`: + +```text +CRITICAL: ... A SendMessage to the lead is NOT a substitute ... +After task_complete, notify your team lead via SendMessage ... +``` + +Those lines must also be generated from `messagingProtocol`; otherwise OpenCode still receives contradictory briefing text even if the long task protocol is fixed. + +Also replace these protocol fragments: + +```text +When sending a message about a specific task, include its short display label like # in your SendMessage summary field... +STEP 3 - THEN, send a message to your team lead via SendMessage so they notice it promptly. +``` + +For OpenCode, the equivalent must mention `agent-teams_message_send` and its `summary` field, not `SendMessage`. + +With protocol-specific text: + +```js +const notifyLeadExample = messagingProtocol.buildLeadMessageExample({ + teamName, + leadName: '', + fromName: '', + text: '#abcd1234 done. Full details in task comment e5f6a7b8. Moving to #efgh5678.', + summary: '#abcd1234 done', +}); +``` + +Then use: + +```text +After task_complete, notify your team lead via ${messagingProtocol.sendLeadPhrase}. +Example: ${notifyLeadExample} +``` + +Important OpenCode wording: + +```text +When using agent-teams_message_send, always include teamName, to, from, text, and summary. +Always set from to your teammate name. +Do not answer only as plain assistant text when agent-teams_message_send is available. +For cross-team replies or messages to another team, use agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to. +``` + +Acceptance: + +- Native text still uses `SendMessage`. +- OpenCode text does not instruct the model to call `SendMessage`. +- Board comment remains the durable primary result channel for both runtimes. +- Cross-team instructions remain on `cross_team_send`, not `message_send`. + +### Step 5 - Fix task assignment notification protocol + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js +``` + +Current function: + +```js +function buildAssignmentMessage(context, task, options = {}) { +``` + +Change to compute protocol: + +```js +function buildAssignmentMessage(context, task, options = {}) { + const messagingProtocol = options.messagingProtocol || createMemberMessagingProtocol('native'); + const ownerName = typeof task.owner === 'string' ? task.owner.trim() : ''; + const leadName = runtimeHelpers.inferLeadName(context.paths); + ... +} +``` + +Where owner notification is sent, pass OpenCode protocol if owner is OpenCode: + +```js +const owner = resolved.members.find( + (member) => normalizeMemberName(member.name) === normalizeMemberName(task.owner) +); + +const messagingProtocol = createMemberMessagingProtocol( + isOpenCodeMember(owner) ? 'opencode' : 'native' +); + +text: buildAssignmentMessage(context, task, { + ...options, + messagingProtocol, +}), +``` + +Acceptance: + +- OpenCode owner receives assignment instructions using `agent-teams_message_send`. +- Native owner still receives `SendMessage`. + +### Step 6 - Extend `message_send` with `taskRefs` + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts +``` + +Storage already supports `taskRefs` in: + +```text +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messageStore.js +``` + +Add schema: + +```ts +taskRefs: z + .array( + z.object({ + taskId: z.string().min(1), + displayId: z.string().min(1), + teamName: z.string().min(1), + }) + ) + .optional(), +``` + +Forward it: + +```ts +...(taskRefs?.length ? { taskRefs } : {}), +``` + +Acceptance: + +- OpenCode can include the same traceability metadata native prompts already mention. +- Existing `message_send` callers remain valid. +- `message_send({ to: "user", from: "" })` continues to write `inboxes/user.json`, which the existing Messages feed already reads. + +### Step 6.1 - Guard `message_send` replies to user against missing sender + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messages.js +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messageStore.js +``` + +Current risk: + +```js +from: + typeof flags.from === 'string' && flags.from.trim() + ? flags.from.trim() + : defaults.from || 'user', +``` + +For `message_send({ teamName, to: "user", text: "done" })`, this writes: + +```json +{ "from": "user", "to": "user", "text": "done" } +``` + +That row is durable, but it is not a teammate reply. It will not reliably clear pending reply state and can make the user think the OpenCode agent ignored the message. + +Do not make `from` required for every `message_send` call in v1. That can break older/manual uses where `message_send` is acting as user-to-member delivery. + +Preferred narrow guard: + +```js +function assertUserDirectedMessageHasSender(context, flags) { + const to = typeof flags.to === 'string' ? flags.to.trim().toLowerCase() : ''; + if (to !== 'user') return; + + const from = typeof flags.from === 'string' ? flags.from.trim() : ''; + if (!from || from.toLowerCase() === 'user') { + throw new Error('message_send to user requires from to be the responding team member name'); + } + + runtimeHelpers.assertExplicitTeamMemberName(context.paths, from, 'from', { + allowLeadAliases: true, + }); +} +``` + +Call it in `agent-teams-controller/src/internal/messages.js` before `messageStore.sendInboxMessage(...)`: + +```js +function sendMessage(context, flags) { + assertUserDirectedMessageHasSender(context, flags || {}); + return messageStore.sendInboxMessage(context.paths, flags); +} +``` + +Keep the MCP schema `from: z.string().optional()` so existing non-user-directed callers remain valid, but update the tool description: + +```text +When to is "user", from is required and must be your configured teammate name. +``` + +Reason: + +- OpenCode is instructed to include `from`, but model compliance is not a safety boundary. +- A tool error is better than a wrong durable `from: "user"` message row. +- This guard affects the generic Agent Teams MCP path, not only OpenCode, but only for semantically invalid user-directed messages. + +Acceptance: + +- `message_send({ to: "user", from: "bob" })` succeeds and writes `from: "bob"`. +- `message_send({ to: "user" })` fails with a clear actionable error. +- `message_send({ to: "user", from: "user" })` fails. +- `message_send({ to: "alice", text: "..." })` still succeeds and defaults to user-origin delivery for legacy/manual uses. +- OpenCode prompt examples continue to include `from: ""`. + +### Step 6.2 - Disambiguate `message_send` from `runtime_deliver_message` + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts +/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/runtimeTools.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js +``` + +Current risk: + +- `runtime_deliver_message` is visible in the same Agent Teams MCP server as `message_send`. +- Its description says it delivers an OpenCode runtime message to app-owned destinations. +- It really can write user/member/cross-team destinations through `RuntimeDeliveryService`. +- If prompts say "deliver a message" loosely, OpenCode can choose `runtime_deliver_message` instead of the v1 semantic reply tool. + +Do not hide `runtime_deliver_message` from readiness or app tool availability proof. It is still required for runtime evidence and journal recovery paths. + +Instead, make tool descriptions and OpenCode prompts explicitly route normal replies: + +```ts +description: + 'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.' +``` + +```ts +description: + 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.' +``` + +OpenCode-specific prompt wording should avoid generic "deliver message" language: + +```text +For normal visible replies, call agent-teams_message_send. +Do not use runtime_deliver_message for ordinary replies unless a runtime-delivery prompt explicitly asks for runId/runtimeSessionId/idempotencyKey delivery. +``` + +Acceptance: + +- `message_send` description is the most obvious visible-message tool for OpenCode replies. +- `runtime_deliver_message` description says it is low-level and not the normal reply path. +- OpenCode launch/direct-message/task prompts do not use ambiguous "deliver a message" phrasing without naming `agent-teams_message_send`. +- Readiness still requires runtime tools where appropriate; this is prompt/tool-description disambiguation, not a capability removal. + +### Step 6.3 - Canonicalize `message_send` recipients before persistence + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messages.js +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messageStore.js +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/runtimeHelpers.js +/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts +``` + +Current risk: + +```js +const memberName = + typeof flags.member === 'string' && flags.member.trim() + ? flags.member.trim() + : typeof flags.to === 'string' && flags.to.trim() + ? flags.to.trim() + : ''; +appendRow(getInboxPath(paths, memberName), payload); +``` + +This writes the raw `to` string as the inbox filename. + +Bad cases: + +- `to: "team-lead"` when the actual configured lead is `lead` writes `inboxes/team-lead.json`; lead relay reads `inboxes/lead.json`. +- `to: "lead"` when the configured lead is `team-lead` can create a separate alias inbox. +- `to: "cross_team_send"` creates a misleading local inbox instead of a clear error telling the agent to use `cross_team_send`. +- `from: "team-lead"` can be stored as an alias instead of the canonical lead name, which breaks pending-reply and activity attribution. + +Add a controller-level normalizer before `messageStore.sendInboxMessage()`: + +```js +function normalizeMessageSendFlags(context, flags) { + const next = { ...(flags || {}) }; + const rawTo = typeof next.to === 'string' ? next.to.trim() : ''; + + if (!rawTo) { + throw new Error('message_send requires to'); + } + + if (rawTo.toLowerCase() === 'user') { + next.to = 'user'; + } else { + const resolvedTo = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, rawTo, { + allowLeadAliases: true, + }); + if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamRecipient?.(rawTo)) { + throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.'); + } + if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient?.(rawTo)) { + throw new Error('message_send cannot target cross_team_send. Use cross_team_send with toTeam.'); + } + if (!resolvedTo) { + throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`); + } + next.to = resolvedTo; + next.member = next.to; + } + + if (typeof next.from === 'string' && next.from.trim()) { + const rawFrom = next.from.trim(); + if (rawFrom.toLowerCase() !== 'user') { + next.from = runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFrom, 'from', { + allowLeadAliases: true, + }); + } + } + + return next; +} +``` + +Then run the user-directed sender guard on the normalized flags. + +Important: + +- `to: "user"` remains a special destination and does not require a configured member named `user`. +- Local member/lead recipients must resolve to configured member names. +- Cross-team team names and cross-team tool names should not be silently treated as local inboxes. The error should tell the model to use `cross_team_send`. +- If the existing app intentionally supports dotted local member names, do not reject them when they are configured members. Resolve against config/members.meta before applying cross-team heuristics. +- If `runtimeHelpers` does not export cross-team recipient predicates today, add a small shared helper there instead of duplicating ad hoc regexes in `messages.js`. + +Acceptance: + +- `message_send({ to: "team-lead", from: "bob" })` writes to the actual configured lead inbox. +- `message_send({ to: "lead", from: "bob" })` also writes to the actual configured lead inbox when `lead` is a lead alias. +- `message_send({ to: "alice", from: "team-lead" })` stores `from` as the canonical configured lead name. +- `message_send({ to: "unknown", from: "bob" })` fails clearly instead of creating `inboxes/unknown.json`. +- `message_send({ to: "cross_team_send", from: "bob" })` fails with a `use cross_team_send` error. +- `message_send({ to: "user", from: "bob" })` remains valid and writes to `inboxes/user.json`. + +### Step 6.4 - Decide cross-team `taskRefs` policy before helper generalization + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/crossTeamTools.ts +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/crossTeam.js +/Users/belief/dev/projects/claude/claude_team/src/shared/types/team.ts +``` + +Current fact: + +- `CrossTeamMessage` and `CrossTeamSendRequest` already include `taskRefs`. +- `cross_team_send` MCP schema does not expose `taskRefs`. +- `agent-teams-controller/src/internal/crossTeam.js` does not persist `taskRefs` to target inbox or `sent-cross-team.json`. + +Options: + +Option A: keep cross-team `taskRefs` out of v1 prompts - 🎯 8 🛡️ 8 🧠 2, about 0-25 LOC +Safest if we want the smallest messaging seam. The helper must not accept or render `taskRefs` for `buildCrossTeamMessageExample()` yet. + +Option B: wire cross-team `taskRefs` end-to-end now - 🎯 8 🛡️ 9 🧠 4, about 70-150 LOC +Best if the helper is meant to be a real semantic messaging seam with uniform traceability. Add `taskRefs` to `cross_team_send` schema, normalize it in controller, store it in target inbox row, append it to sent message, and persist it in `sent-cross-team.json`. + +Chosen for v1 if Step 1 helper has a generic `taskRefs` option: Option B. +Chosen for v1 if Step 1 helper only renders static examples: Option A. + +Implementation for Option B: + +```ts +taskRefs: z + .array( + z.object({ + taskId: z.string().min(1), + displayId: z.string().min(1), + teamName: z.string().min(1), + }) + ) + .optional(), +``` + +Controller storage should use the same shape as `messageStore.normalizeTaskRefs()`: + +```js +const taskRefs = normalizeTaskRefs(flags.taskRefs); + +list.push({ + ..., + ...(taskRefs ? { taskRefs } : {}), +}); + +messageStore.appendSentMessage(context.paths, { + ..., + ...(taskRefs ? { taskRefs } : {}), +}); + +outList.push({ + ..., + ...(taskRefs ? { taskRefs } : {}), +}); +``` + +Acceptance: + +- The helper never emits unsupported `taskRefs` for `cross_team_send`. +- If cross-team taskRefs are enabled, they persist in target inbox, local sent message, and `sent-cross-team.json`. +- `message_send` and `cross_team_send` taskRefs use identical validation rules. + +### Step 7 - Centralize Agent Teams tool-name alias matching + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/agentTeamsToolNames.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +``` + +Current risk: + +`TeamProvisioningService.captureSendMessages()` recognizes only: + +```ts +part.name === 'mcp__agent-teams__message_send' +``` + +But OpenCode and MCP tooling can expose names as: + +```text +message_send +agent-teams_message_send +agent_teams_message_send +mcp__agent-teams__message_send +mcp__agent_teams__message_send +``` + +Add shared helpers: + +```ts +const AGENT_TEAMS_PREFIXES = [ + 'mcp__agent-teams__', + 'mcp__agent_teams__', + 'agent-teams_', + 'agent_teams_', +] as const; + +export function canonicalizeAgentTeamsToolName(rawName: string): string { + const normalized = rawName.trim().replace(/^proxy_/, ''); + for (const prefix of AGENT_TEAMS_PREFIXES) { + if (normalized.startsWith(prefix)) { + return normalized.slice(prefix.length); + } + } + return normalized; +} + +export function isAgentTeamsToolName(rawName: string, canonicalName: string): boolean { + return canonicalizeAgentTeamsToolName(rawName).toLowerCase() === canonicalName.toLowerCase(); +} +``` + +Do not treat every plain `message_send` in every transcript as Agent Teams. Add a stricter predicate for plain tool names: + +```ts +export function isAgentTeamsToolUse(input: { + rawName: string; + canonicalName: string; + toolInput?: Record; + currentTeamName?: string; +}): boolean { + const rawName = input.rawName.trim(); + const canonical = canonicalizeAgentTeamsToolName(rawName); + if (canonical.toLowerCase() !== input.canonicalName.toLowerCase()) { + return false; + } + + const hasKnownPrefix = + rawName !== canonical || + AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix)); + if (hasKnownPrefix) { + return true; + } + + // Plain names are accepted only when the payload looks like our app MCP contract. + if (input.canonicalName === 'message_send') { + return ( + typeof input.toolInput?.teamName === 'string' && + input.toolInput.teamName === input.currentTeamName && + typeof input.toolInput?.to === 'string' && + typeof input.toolInput?.text === 'string' + ); + } + + return false; +} +``` + +This keeps OpenCode plain direct-MCP aliases working without broadening capture to arbitrary third-party tools with the same short name. + +Then update `captureSendMessages()`: + +```ts +const canonicalToolName = canonicalizeAgentTeamsToolName(part.name); +const isNativeSendMessage = part.name === 'SendMessage'; +const isTeamMessageSendTool = isAgentTeamsToolUse({ + rawName: part.name, + canonicalName: 'message_send', + toolInput: input as Record, + currentTeamName: run.teamName, +}); +const isDirectCrossTeamSendTool = isAgentTeamsToolUse({ + rawName: part.name, + canonicalName: 'cross_team_send', + toolInput: input as Record, + currentTeamName: run.teamName, +}); +``` + +Keep the existing no-duplicate-persistence rule: + +```ts +if (isDirectCrossTeamSendTool) { + // Use this only to trigger cross-team refresh/fallback handling. + continue; +} + +if (!isNativeSendMessage) { + // message_send persists through the MCP tool handler itself. + // Do not also push a lead-process message here. + continue; +} +``` + +Also update `TASK_BOUNDARY_TOOL_LINE_PATTERN` in the same file to include the same aliases as `canonicalizeAgentTeamsToolName()`: + +```ts +const AGENT_TEAMS_PREFIXES = [ + 'mcp__agent-teams__', + 'mcp__agent_teams__', + 'agent-teams_', + 'agent_teams_', +] as const; +``` + +Acceptance: + +- Logs/capture/task-log code recognizes the same aliases that prompts and readiness allow. +- Existing `mcp__agent-teams__...` names still work. +- Plain `message_send` is only treated as Agent Teams when it appears in the known app/team runtime context and its input has our `teamName/to/text` shape for the current team. +- This does not automatically loosen production readiness. If readiness currently requires canonical OpenCode ids, keep that policy explicit and test it separately from transcript/capture alias parsing. +- Normal MCP `message_send` is not double-persisted as a lead-process message. +- Task boundary detection works for `agent-teams_task_start`, `agent_teams_task_start`, `mcp__agent-teams__task_start`, and proxy-prefixed forms. + +### Step 8 - Move OpenCode runtime identity injection to orchestrator + +File: + +```text +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts +``` + +Related app contract file: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +``` + +First clean up the duplicate app-side `OpenCodeSendMessageCommandBody` declarations in `OpenCodeBridgeCommandContract.ts`. TypeScript currently merges them, but this is too easy to edit incorrectly when adding run-id recovery. Keep a single declaration: + +```ts +export interface OpenCodeSendMessageCommandBody { + runId?: string; + laneId: string; + teamId: string; + teamName: string; + projectPath: string; + memberName: string; + text: string; + messageId?: string; + agent?: string; + noReply?: boolean; +} +``` + +Current launch flow: + +```ts +record = await openCodeSessionBridge.ensureSession(...) +await openCodeSessionBridge.promptAsync(record, { + text: prompt, + agent: 'teammate', +}) +``` + +Add a helper: + +```ts +function buildOpenCodeRuntimeIdentityBlock(input: { + teamName: string + memberName: string + runId: string + runtimeSessionId: string +}): string { + const checkinPayload = { + teamName: input.teamName, + runId: input.runId, + memberName: input.memberName, + runtimeSessionId: input.runtimeSessionId, + } + + const briefingPayload = { + teamName: input.teamName, + memberName: input.memberName, + runtimeProvider: 'opencode', + } + + return [ + '', + 'You are an OpenCode teammate managed by the desktop app.', + 'Your first app-team MCP action must be runtime bootstrap check-in.', + `Call the exposed Agent Teams runtime_bootstrap_checkin tool, usually agent-teams_runtime_bootstrap_checkin or mcp__agent-teams__runtime_bootstrap_checkin, with: ${JSON.stringify(checkinPayload)}`, + 'After check-in succeeds, request your teammate rules.', + `Call the exposed Agent Teams member_briefing tool, usually agent-teams_member_briefing or mcp__agent-teams__member_briefing, with: ${JSON.stringify(briefingPayload)}`, + 'For visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.', + '', + ].join('\n') +} +``` + +Wrap launch prompt: + +```ts +const runtimeIdentityBlock = buildOpenCodeRuntimeIdentityBlock({ + teamName: teamId, + memberName: name, + runId, + runtimeSessionId: record.opencodeSessionId, +}) + +await openCodeSessionBridge.promptAsync(record, { + text: `${runtimeIdentityBlock}\n\n${prompt}`, + agent: 'teammate', +}) +``` + +Then add a bounded launch-settle helper before mapping the member as final `created`/`confirmed_alive`. + +Reason: + +`promptAsync()` only enqueues the OpenCode prompt. The current immediate `reconcileSession(record)` can run before the assistant/tool message materializes. That produces a false `created` state even when the teammate is about to call `runtime_bootstrap_checkin` or `member_briefing`. + +Do not add this as a serial wait inside the existing member loop. + +Options: + +Option A: serial settle inside the existing loop - 🎯 4 🛡️ 5 🧠 2, about 30-60 LOC +Easy, but bad for UX. Three OpenCode teammates with an 8 second preview cap can add 24 seconds of launch latency. + +Option B: two-phase launch with bounded concurrent settle - 🎯 8 🛡️ 8 🧠 5, about 140-260 LOC +First ensure sessions and enqueue prompts for all members. Then run bounded preview/reconcile concurrently per prompted member with a small local concurrency cap. This fixes early false `created` without multiplying wait time by teammate count or opening one preview stream per teammate in large teams. + +Option C: no settle, rely only on later reconcile - 🎯 5 🛡️ 6 🧠 1, about 0-20 LOC +Avoids launch delay, but keeps the stale/early UI state that caused OpenCode teammates to look unspawned or stuck. + +Chosen for v1: Option B with a local cap of 3 concurrent settle observers. Do not add a dependency just for this; use a tiny local mapper/helper in the orchestrator testable unit. + +Example: + +```ts +async function reconcileAfterOpenCodeLaunchPrompt(record: OpenCodeSessionRecord) { + await openCodeSessionBridge + .observePreview(record, { + timeoutMs: 8_000, + idleTimeoutMs: 1_500, + }) + .catch(() => null); + + return openCodeSessionBridge.reconcileSession(record, { limit: 50 }); +} +``` + +Restructure `runLaunch()` into two phases: + +```ts +const promptedMembers: Array<{ + name: string; + record: OpenCodeSessionRecord; +}> = []; + +for (const item of membersRaw) { + const record = await openCodeSessionBridge.ensureSession(...); + await openCodeSessionBridge.promptAsync(record, { + text: `${runtimeIdentityBlock}\n\n${prompt}`, + agent: 'teammate', + }); + promptedMembers.push({ name, record }); +} + +const settledMembers = await mapWithConcurrency(promptedMembers, 3, async ({ name, record }) => ({ + name, + record, + reconciled: await reconcileAfterOpenCodeLaunchPrompt(record), + }) +); +``` + +Keep per-member prompt/ensure failures isolated. If one member fails before prompt enqueue, mark only that member failed and still prompt/settle the rest. + +Use the same bounded helper after permission-answer recovery paths where the UI expects launch state to advance, but keep it concurrent across lane records. + +Do not wait indefinitely and do not convert preview timeout into `failed`. A settle timeout should fall back to the current reconcile result and leave the member in `runtime_pending_bootstrap`/`created` rather than producing a false hard failure. + +Acceptance: + +- Adapter does not need to know `opencodeSessionId`. +- Every OpenCode teammate receives exact session identity. +- `member_briefing` gets `runtimeProvider: "opencode"`. +- Identity prompt names the canonical OpenCode tool ids and acceptable exposed aliases, not only one spelling. +- `laneId` stays in `runLaunch()` as bridge/session routing context only. +- The identity helper should not accept `laneId`, so nobody accidentally serializes it into the MCP payload later. +- Launch state gets one short chance to observe tool-only/bootstrap assistant activity before deciding the bridge member state. +- Launch settle runs bounded-concurrently across OpenCode members, not serially and not unbounded. +- A launch-settle timeout is not a launch failure. + +Also add a smaller recovery prefix in `runSendMessage()` when `body.runId` is present. + +Reason: + +If the initial launch prompt was interrupted before check-in, a later user message can help the OpenCode teammate self-heal. Do not invent a `runId` if `body.runId` is absent. + +Example: + +```ts +const runId = asString(body.runId); +const identityReminder = runId + ? buildOpenCodeRuntimeIdentityBlock({ + teamName: teamId, + memberName, + runId, + runtimeSessionId: record.opencodeSessionId, + }) + : null; + +await openCodeSessionBridge.promptAsync(record, { + text: identityReminder ? `${identityReminder}\n\n${text}` : text, + agent: asString(body.agent) ?? 'teammate', + noReply: body.noReply === true, +}); +``` + +Post-send reconcile must not redefine prompt acceptance. + +Current `runSendMessage()` shape: + +```ts +await openCodeSessionBridge.promptAsync(record, { text, agent, noReply }); +const reconciled = await openCodeSessionBridge.reconcileSession(record, { limit: 50 }); +return { accepted: true, diagnostics: reconciled.summary.diagnostics }; +``` + +Risk: + +- If `promptAsync()` succeeds but `reconcileSession()` throws or times out, the prompt may already be enqueued in OpenCode. +- Reporting `accepted: false` in that case makes the app retry a message that the agent might already process. +- That creates duplicate OpenCode prompts while the inbox row may still look unread. + +Use this semantic split: + +```ts +let reconcileDiagnostics: TeamDiagnostic[] = []; +let runtimePid: number | undefined; + +await openCodeSessionBridge.promptAsync(record, { + text: identityReminder ? `${identityReminder}\n\n${text}` : text, + agent: asString(body.agent) ?? 'teammate', + noReply: body.noReply === true, +}); + +try { + const reconciled = await openCodeSessionBridge.reconcileSession(record, { limit: 50 }); + runtimePid = resolvedRuntimePidFrom(record, reconciled); + reconcileDiagnostics = reconciled.summary.diagnostics.map((message) => + teamDiagnostic('opencode_send_reconcile', message, 'info') + ); +} catch (error) { + reconcileDiagnostics = [ + teamDiagnostic( + 'opencode_send_reconcile_failed_after_prompt_accept', + error instanceof Error ? error.message : String(error), + 'warning' + ), + ]; +} + +return { + accepted: true, + sessionId: record.opencodeSessionId, + memberName, + ...(runtimePid ? { runtimePid } : {}), + diagnostics: reconcileDiagnostics, +}; +``` + +Only `promptAsync()` failure should make `accepted: false` or throw as delivery failure. Reconcile failure after prompt acceptance is a warning diagnostic because it affects fresh runtime evidence, not whether the app handed the message to OpenCode. + +Acceptance: + +- `runSendMessage()` can repair missing check-in when it has a run id. +- `runSendMessage()` does not fabricate runtime identity when no run id exists. +- `runSendMessage()` returns `accepted: true` when `promptAsync()` succeeds even if post-send reconcile fails. +- `runSendMessage()` returns a warning diagnostic for post-accept reconcile failure, not a false delivery failure. +- App-side inbox relay can mark read after prompt acceptance without waiting for assistant reply text. + +### Step 9 - Keep adapter prompt generic and non-conflicting + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +``` + +Current prompt says: + +```text +If available, your first app-team action is to call MCP tool agent-teams_member_briefing... +``` + +Change it so it does not conflict with orchestrator identity block: + +```text +The desktop bridge may prepend runtime identity and bootstrap instructions. Follow those first. +After runtime identity check-in, call agent-teams_member_briefing with runtimeProvider="opencode" if you have not already done so. +``` + +Keep this line: + +```text +When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send... +``` + +Acceptance: + +- No duplicate "first action" conflict. +- OpenCode launch remains understandable even if the orchestrator identity block is absent during tests. + +### Step 9.5 - Guard native-only prompt boundaries + +Files to audit: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapPromptBuilder.ts +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/hooks/useInboxPoller.ts +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammatePromptAddendum.ts +``` + +These files contain many valid native-runtime `SendMessage` instructions. They should not be bulk-replaced. + +Risk: + +- A naive global replacement breaks Codex/Claude native agents. +- Leaving them untouched is safe only if OpenCode teammates do not receive these native prompt paths. + +Add routing guard tests instead of rewriting native prompts: + +```ts +it('does not send native member spawn prompt to OpenCode runtime members', async () => { + // Create mixed team with native alice and OpenCode bob. + // Spy on native Agent/Codex spawn prompt builder and OpenCodeTeamRuntimeAdapter.launch. + // Assert bob launch uses OpenCodeTeamRuntimeAdapter prompt. + // Assert bob prompt contains agent-teams_message_send. + // Assert bob prompt does not contain "Use the SendMessage tool". +}); +``` + +```ts +it('keeps native teammate prompt using SendMessage', async () => { + // Create native alice. + // Assert native spawn prompt still contains SendMessage guidance. +}); +``` + +For `teamBootstrapPromptBuilder` and `useInboxPoller`, add an explicit comment/test boundary: + +```ts +// Native persistent-teammate bootstrap only. OpenCode runtime bootstrap is +// injected by OpenCodeBridgeCommandHandler and must not use this prompt path. +``` + +Acceptance: + +- OpenCode teammates never receive generic native spawn/reconnect prompts that mention only `SendMessage`. +- Codex/Claude/Gemini native prompts are unchanged unless a runtime-specific helper is explicitly introduced. +- Future maintainers see that remaining `SendMessage` strings are not missed OpenCode work by default. + +### Step 9.6 - Make OpenCode direct-message delivery explicit + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/ipc/teams.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/index.ts +``` + +Current flow: + +```ts +const memberDeliveryText = buildMessageDeliveryText(baseText, { + actionMode, + isLeadRecipient, + replyRecipient: typeof payload.from === 'string' ? payload.from : 'user', +}); + +await sendMessage(... text: memberDeliveryText ...); + +void provisioning.deliverOpenCodeMemberMessage(tn, { + memberName, + text: memberDeliveryText, + messageId: result.messageId, +}); +``` + +Then OpenCode-specific code does this: + +```ts +const replyRecipient = extractRequestedReplyRecipient(input.text); +``` + +Risk: + +- OpenCode receives native-only hidden wording that says `SendMessage`. +- Recipient routing depends on regex matching English prompt text. +- If `buildMessageDeliveryText()` wording changes, OpenCode may send to the wrong recipient or fall back to vague "requested recipient". +- `taskRefs` and action mode are embedded as text instead of explicit runtime metadata. + +Options: + +Option A: keep current text parsing - 🎯 4 🛡️ 5 🧠 1, about 0-15 LOC +Smallest, but fragile and contradicts the semantic seam goal. + +Option B: pass explicit metadata while still sending the native stored text to OpenCode - 🎯 7 🛡️ 7 🧠 3, about 50-100 LOC +Better recipient reliability, but still leaves confusing `SendMessage` wording inside the OpenCode prompt. + +Option C: keep native inbox text only for native recipients, persist base text for OpenCode recipients, and deliver an OpenCode-native runtime message - 🎯 9 🛡️ 9 🧠 6, about 180-320 LOC with tests +Best shape. Codex/Claude keep the existing persisted inbox text because they read inbox files directly. OpenCode inbox rows stay clean/retryable with base user text, while OpenCode receives explicit runtime delivery metadata through the adapter/relay. + +Chosen for v1: Option C. + +Add explicit fields: + +```ts +import type { AgentActionMode, TaskRef } from '@shared/types'; + +export interface OpenCodeTeamRuntimeMessageInput { + runId?: string; + teamName: string; + laneId: string; + memberName: string; + cwd: string; + text: string; + messageId?: string; + replyRecipient?: string; + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; +} +``` + +Update IPC call: + +```ts +const baseText = payload.text!.trim(); +const replyRecipient = typeof payload.from === 'string' && payload.from.trim() + ? payload.from.trim() + : 'user'; +const memberDeliveryText = buildMessageDeliveryText(baseText, { + actionMode, + isLeadRecipient, + replyRecipient, +}); +const isOpenCodeRecipient = await provisioning.isOpenCodeRuntimeRecipient(tn, memberName); +const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText; + +const result = await sendMessage(... text: inboxText ...); + +if (isOpenCodeRecipient) { + await provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, { + onlyMessageId: result.messageId, + source: 'ui-send', + deliveryMetadata: { + replyRecipient, + actionMode, + taskRefs: validatedTaskRefs.value, + }, + }); +} +``` + +Keep the native inbox write unchanged for native recipients. For OpenCode recipients, do not persist native hidden `SendMessage` instructions into the inbox row; runtime delivery builds the OpenCode-native wrapper from metadata. This keeps FileWatcher retry safe after a transient OpenCode bridge failure. + +Update `deliverOpenCodeMemberMessage()` signature: + +```ts +input: { + memberName: string; + text: string; + messageId?: string; + replyRecipient?: string; + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; +} +``` + +Update `buildOpenCodeRuntimeMessageText()`: + +```ts +function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string { + const replyRecipient = input.replyRecipient?.trim() || 'user'; + const taskRefs = input.taskRefs?.length ? JSON.stringify(input.taskRefs) : null; + + return [ + '', + 'You are running in OpenCode.', + `Use agent-teams_message_send with teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`, + 'Do not answer only as plain assistant text when agent-teams_message_send is available.', + input.actionMode ? `Action mode for this message: ${input.actionMode}.` : null, + taskRefs ? `If your reply is about these tasks, include taskRefs exactly: ${taskRefs}` : null, + input.messageId ? `Inbound app messageId: ${input.messageId}.` : null, + '', + '', + input.text, + ].filter((line): line is string => line !== null).join('\n'); +} +``` + +Keep `extractRequestedReplyRecipient()` only as a fallback for older callers/tests, not as the normal path: + +```ts +const replyRecipient = + input.replyRecipient?.trim() || extractRequestedReplyRecipient(input.text) || 'user'; +``` + +Acceptance: + +- Stored member inbox text for native teammates remains unchanged. +- Stored member inbox text for OpenCode teammates is base user/team text, not native hidden `SendMessage` delivery instructions. +- OpenCode runtime delivery prompt does not contain native-only "CRITICAL: Reply using the SendMessage tool" wording. +- OpenCode recipient routing does not depend on regex-parsing hidden native instructions. +- `replyRecipient`, `actionMode`, and `taskRefs` are available to OpenCode as structured runtime metadata. +- Existing callers without `replyRecipient` still work through fallback parsing. + +### Step 9.7 - Make OpenCode runtime delivery outcome observable + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/src/shared/types/team.ts +/Users/belief/dev/projects/claude/claude_team/src/main/ipc/teams.ts +/Users/belief/dev/projects/claude/claude_team/src/renderer/store/slices/teamSlice.ts +/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/messages/MessageComposer.tsx +/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/dialogs/SendMessageDialog.tsx +``` + +Current flow after persisting an inbox row: + +```ts +if (!isLeadRecipient && isAlive) { + void provisioning + .deliverOpenCodeMemberMessage(tn, { + memberName, + text: memberDeliveryText, + messageId: result.messageId, + }) + .then(...) + .catch(...); +} +``` + +Risk: + +- The IPC call returns success before OpenCode runtime delivery succeeds or fails. +- Native teammates can still read the persisted inbox file, so fire-and-forget is acceptable there. +- OpenCode secondary lanes do not watch the member inbox file, so runtime delivery failure means the visible UI send can be a silent no-op for the agent. +- `SendMessageDialog` auto-closes on any `lastResult`, so adding a warning field without changing UI behavior can still hide the problem. +- Renderer `sendTeamMessage` currently returns `Promise` and swallows IPC errors after setting store state. Existing caller `.catch(...)` blocks for pending-reply cleanup do not run. + +Options: + +Option A: keep fire-and-forget and add more logs - 🎯 5 🛡️ 5 🧠 1, about 10-30 LOC +This helps debugging but keeps the user-facing contract dishonest. + +Option B: await OpenCode runtime relay for live OpenCode non-lead sends, return additive delivery status, and fix renderer action result/error propagation - 🎯 9 🛡️ 9 🧠 5, about 160-300 LOC with tests +This keeps native persistence behavior unchanged, makes OpenCode failure visible, keeps retry routing in one OpenCode inbox relay path, and fixes the existing dead caller catch path that controls pending-reply cleanup. + +Option C: add a durable OpenCode delivery queue with retries and UI retry state - 🎯 8 🛡️ 10 🧠 8, about 350-700 LOC +This is the best long-term reliability shape, but it is too much to bundle into the semantic messaging seam unless delivery reliability remains flaky after v1. + +Chosen for v1: Option B. + +Add an optional result field: + +```ts +export interface SendMessageRuntimeDeliveryResult { + providerId?: TeamProviderId; + attempted: boolean; + delivered: boolean; + reason?: string; + diagnostics?: string[]; +} + +export interface SendMessageResult { + deliveredToInbox: boolean; + deliveredViaStdin?: boolean; + messageId: string; + deduplicated?: boolean; + runtimeDelivery?: SendMessageRuntimeDeliveryResult; +} +``` + +Update `handleSendMessage()` after the inbox write: + +```ts +let runtimeDelivery: SendMessageResult['runtimeDelivery']; + +if (!isLeadRecipient && isAlive) { + const delivery = await withTimeout( + provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, { + onlyMessageId: result.messageId, + source: 'ui-send', + deliveryMetadata: { + replyRecipient, + actionMode, + taskRefs: validatedTaskRefs.value, + }, + }), + 12_000, + { attempted: 1, delivered: 0, failed: 1, diagnostics: ['opencode_runtime_delivery_timeout'] } + ); + + if (delivery.attempted > 0) { + const delivered = delivery.failed === 0 && delivery.delivered > 0; + runtimeDelivery = { + providerId: 'opencode', + attempted: true, + delivered, + ...(!delivered + ? { reason: delivery.diagnostics[0] ?? 'opencode_runtime_delivery_failed' } + : {}), + ...(delivery.diagnostics?.length ? { diagnostics: delivery.diagnostics } : {}), + }; + } +} + +return runtimeDelivery ? { ...result, runtimeDelivery } : result; +``` + +The timeout helper can be local to `teams.ts`. It must not cancel the underlying OpenCode bridge operation unless a cancellation primitive already exists; it only bounds the IPC response. + +Renderer behavior: + +```ts +function isRuntimeDeliveryFailed(result: SendMessageResult | null | undefined): boolean { + return Boolean(result?.runtimeDelivery?.attempted && !result.runtimeDelivery.delivered); +} +``` + +Change the store action contract: + +```ts +sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; +``` + +and implementation: + +```ts +sendTeamMessage: async (teamName, request) => { + set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null }); + try { + const result = await unwrapIpc('team:sendMessage', () => + api.teams.sendMessage(teamName, request) + ); + // existing optimistic row and state update + return result; + } catch (error) { + set({ + sendingMessage: false, + lastSendMessageResult: null, + sendMessageError: mapSendMessageError(error), + }); + throw error; + } +} +``` + +Update call sites so pending-reply state reflects actual delivery truth: + +```ts +const result = await sendTeamMessage(teamName, request); +if (isRuntimeDeliveryFailed(result)) { + clearPendingReplyFor(member, sentAtMs); +} +``` + +- `SendMessageDialog` should not auto-close when `isRuntimeDeliveryFailed(lastResult)` is true. +- `MessageComposer` and `SendMessageDialog` should show a concise warning such as: `Message saved, but OpenCode runtime delivery failed: `. +- Keep the optimistic user-sent message row, because the inbox write did succeed and is useful audit state. +- Do not surface `recipient_is_not_opencode`; native recipients should behave as before. + +Acceptance: + +- Sending to a native teammate returns the same result shape as today unless another existing field applies. +- Sending to a live OpenCode teammate returns `runtimeDelivery: { attempted: true, delivered: true }` when bridge delivery accepts the prompt. +- Sending to a live OpenCode teammate with bridge/runtime failure returns `runtimeDelivery.delivered === false`, leaves the message persisted, and keeps the dialog/composer warning visible. +- IPC/send failures reject the renderer store action after updating `sendMessageError`, so existing caller cleanup code runs. +- Pending-reply state is cleared when OpenCode runtime delivery fails, because the agent did not actually receive the live prompt. +- OpenCode runtime delivery uses base user text plus explicit metadata from Step 9.6, not native `memberDeliveryText`. +- IPC remains bounded; an OpenCode delivery hang cannot hang the UI indefinitely. + +### Step 9.8 - Relay persisted OpenCode-targeted inbox messages to runtime lanes + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/index.ts +/Users/belief/dev/projects/claude/claude_team/src/main/ipc/teams.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxReader.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxWriter.ts +``` + +Current delivery split: + +- Native teammates read their own `inboxes/.json` files. +- Native lead does not read inbox files, so FileWatcher calls `relayLeadInboxMessages()` and that path writes into the native CLI stdin. +- UI-to-OpenCode direct messages are manually pushed through `deliverOpenCodeMemberMessage()`. +- OpenCode teammates do not watch inbox files, so a generic `message_send` into an OpenCode teammate inbox is not enough. +- Pure OpenCode runtime-adapter launches are marked alive through `runtimeAdapterRunByTeam`, but they do not create a `ProvisioningRun.child`; `relayLeadInboxMessages()` currently returns `0` in that shape. +- The current OpenCode bridge launch handler iterates `body.members` and creates teammate sessions only. `leadPrompt` is carried in the command body, but it does not currently create a stored `team-lead` OpenCode session. +- Existing `relayMemberInboxMessages()` is a native-lead-mediated relay. It sends an internal turn to the native lead and asks it to forward with `SendMessage`; do not reuse it for OpenCode-native runtime delivery. + +Risk examples: + +- OpenCode `bob` calls `agent-teams_message_send({ to: "jack", from: "bob", text: "please review" })`, and `jack` is also OpenCode. +- Task/system notification writes `inboxes/jack.json` for an OpenCode teammate. +- FileWatcher sees `inboxes/jack.json`, but current code intentionally skips non-lead relay because native teammates read their inbox files. +- A pure OpenCode team gets `message_send({ to: "team-lead", ... })`. FileWatcher treats it like a lead inbox, but there is no native stdin process and no proven OpenCode lead session to receive it. Marking it read would lose the message. + +Options: + +Option A: only support UI direct-send to OpenCode in v1 - 🎯 5 🛡️ 5 🧠 2, about 0-40 LOC +This leaves OpenCode-to-OpenCode and system notification routes unreliable. It is not enough for a real team messaging seam. + +Option B: add OpenCode-targeted inbox runtime relay with messageId dedupe/read marking, plus explicit unsupported-lead diagnostics - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests +This preserves native behavior, routes only recipients whose provider is OpenCode, and makes any persisted inbox row deliverable to live OpenCode lanes. + +Option C: replace both native and OpenCode inbox handling with a new durable delivery queue - 🎯 8 🛡️ 10 🧠 9, about 600-1200 LOC +Architecturally clean long-term, but too large for this seam and risky with existing native watchers. + +Chosen for v1: Option B. + +Add one shared recipient-provider predicate and reuse it from both IPC send and relay: + +```ts +async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise { + // Use the same config + members.meta provider resolution as deliverOpenCodeMemberMessage(). + // Do not infer solely from model label if explicit providerId/provider metadata exists. +} +``` + +This avoids a split-brain bug where `handleSendMessage()` persists base text because it thinks the recipient is OpenCode, while relay later treats the same recipient as native or unavailable. + +Add a small routing selector so FileWatcher does not encode provider-specific details: + +```ts +async relayInboxFileToLiveRecipient( + teamName: string, + inboxName: string, + opts?: { + source?: 'watcher' | 'ui-send' | 'manual'; + onlyMessageId?: string; + deliveryMetadata?: { + replyRecipient?: string; + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; + }; + } +): Promise { + // 1. Resolve canonical lead name from config/data service. + // 2. If inboxName is the lead and there is a current native run child, call relayLeadInboxMessages(). + // 3. If inboxName is OpenCode and there is a stored OpenCode session for that recipient, use OpenCode runtime relay. + // 4. If inboxName is OpenCode lead but no lead session exists, return a visible diagnostic and do not mark rows read. + // 5. If inboxName is native non-lead, no-op because native teammates read inbox files directly. +} +``` + +Do not make FileWatcher call `relayLeadInboxMessages()` directly after this change. The service selector owns the distinction between native lead, OpenCode member, unsupported OpenCode lead, and native teammate. + +Add a provisioning service method for OpenCode runtime-addressable recipients: + +```ts +async relayOpenCodeMemberInboxMessages( + teamName: string, + memberName: string, + opts?: { + onlyMessageId?: string; + source?: 'watcher' | 'ui-send' | 'manual'; + deliveryMetadata?: { + replyRecipient?: string; + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; + }; + } +): Promise<{ attempted: number; delivered: number; failed: number; diagnostics: string[] }> { + // 1. Return immediately if recipient is not OpenCode. + // 2. Read inboxes/.json. + // 3. Select unread messages with stable messageId, optionally restricted to onlyMessageId. + // 4. Skip messageIds already delivered in a per-team/member dedupe set. + // 5. For each message, call deliverOpenCodeMemberMessage() with: + // text: visible message text + // messageId + // replyRecipient: opts.deliveryMetadata?.replyRecipient || message.from || 'user' + // actionMode: opts.deliveryMetadata?.actionMode + // taskRefs: opts.deliveryMetadata?.taskRefs || message.taskRefs + // 6. Mark successfully delivered rows read. + // 7. Keep failed rows unread for retry unless the failure is terminal, e.g. recipient_removed. +} +``` + +Delivery commit semantics: + +- The v1 relay is at-least-once with no data loss, not a new exactly-once queue. +- The durable commit is the inbox row read flag. A relay attempt is "successful" only after OpenCode prompt delivery is accepted and the specific inbox message is marked read. +- In-memory messageId dedupe is only for same-process FileWatcher bursts and UI-send/watch double events. Do not rely on it after app restart. +- If OpenCode accepts the prompt but `markInboxMessagesRead()` fails, return a diagnostic like `opencode_inbox_mark_read_failed_after_delivery`. The row remains retryable and may be delivered again. That is safer than marking an undelivered message read. +- Do not mark an OpenCode-targeted inbox row read before the bridge accepts the runtime prompt. +- Do not reuse `RuntimeDeliveryJournal` for this direction without a separate design. That journal models OpenCode runtime writing to app destinations via `runtime_deliver_message`; this relay is app-to-OpenCode prompt delivery. + +OpenCode lead rule: + +- Mixed team with native Codex/Claude/Gemini lead: keep the existing `relayLeadInboxMessages()` path. +- OpenCode teammate or secondary lane: use `relayOpenCodeMemberInboxMessages()`. +- Pure OpenCode lead inbox in v1: do not mark messages read and do not report delivery success unless a real stored OpenCode `team-lead` session exists. Return a diagnostic like `opencode_lead_runtime_session_missing`. +- Do not fake lead delivery by sending to a random teammate session. That would make messages appear delivered while the actual recipient never saw them. +- A future explicit OpenCode lead lane can reuse this selector by teaching the bridge to create/store a `team-lead` session and by passing `agent: "team-lead"` where the bridge supports it. That is not part of this v1 seam. + +FileWatcher change: + +```ts +return teamProvisioningService.relayInboxFileToLiveRecipient(teamName, inboxName, { + source: 'watcher', +}); +``` + +UI direct-send integration: + +```ts +const result = await getTeamDataService().sendMessage(...); +const runtimeDelivery = await provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, { + onlyMessageId: result.messageId, + source: 'ui-send', + deliveryMetadata: { replyRecipient, actionMode, taskRefs: validatedTaskRefs.value }, +}); +``` + +For UI direct-send, Step 9.6 must persist base text for OpenCode recipients, not native `memberDeliveryText`. Then retry through FileWatcher is safe because the inbox row no longer contains native-only `SendMessage` instructions. + +Do not add per-message schema fields unless needed. V1 can pass rich metadata directly for the immediate `ui-send` relay. Watcher/manual retries can fall back to `message.from`, `message.taskRefs`, and default action mode. + +Acceptance: + +- FileWatcher calls a single provisioning-service relay selector instead of embedding lead/member/provider routing itself. +- Native lead inbox messages still go through `relayLeadInboxMessages()` internally. +- FileWatcher still does not relay native teammate inbox messages. +- FileWatcher does relay unread inbox messages for OpenCode recipients through `deliverOpenCodeMemberMessage()`. +- Pure OpenCode lead inbox messages are not marked read or reported as delivered unless a real OpenCode lead runtime session exists. +- Pure OpenCode lead inbox messages without a runtime session produce an explicit diagnostic instead of silently returning success. +- UI direct-send to OpenCode does not double-deliver after FileWatcher sees the inbox write. +- Successful OpenCode runtime relay marks that specific inbox message read; in-memory dedupe only coalesces duplicate events before the read commit is visible. +- Failed transient OpenCode runtime relay leaves the row retryable and reports diagnostics. +- Prompt-accepted-but-mark-read-failed returns a diagnostic instead of pretending exactly-once success. +- OpenCode-to-OpenCode `message_send` becomes live-delivered to the target OpenCode lane. + +### Step 10 - Expand OpenCode app MCP readiness proof + +File: + +```text +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts +``` + +First decide how the required teammate-operational tool list is owned. + +Option A: import `agent-teams-controller` into `agent_teams_orchestrator` - 🎯 5 🛡️ 6 🧠 6, about 80-160 LOC +This removes list duplication, but it adds a new package dependency from the runtime/orchestrator repo into the app controller package. That is a larger architecture decision than this fix needs. + +Option B: keep an explicit direct-MCP required list in orchestrator v1 - 🎯 8 🛡️ 8 🧠 3, about 60-140 LOC +This matches the current repo boundary. The orchestrator only needs plain MCP names for direct `Client.listTools()` proof. Add tests that fail when critical teammate tools like `message_send`, `member_briefing`, `task_start`, or `cross_team_send` are missing. + +Option C: generate a shared protocol contract artifact consumed by both repos - 🎯 8 🛡️ 9 🧠 7, about 250-450 LOC +This is the best long-term shape, but it needs generation, publishing, and CI checks. Treat it as a follow-up after v1 proves the semantic seam. + +Chosen for v1: Option B. Do not import `agent-teams-controller` into `agent_teams_orchestrator` in this change. + +Before editing, snapshot the current controller catalog from `claude_team`: + +```bash +cd /Users/belief/dev/projects/claude/claude_team +node - <<'NODE' +const catalog = require('./agent-teams-controller/src/mcpToolCatalog.js') +console.log(catalog.AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.join('\n')) +NODE +``` + +Use that output as the explicit orchestrator list for v1. This keeps the repo boundary clean while making the duplication intentional and reviewable. + +Current proof only checks runtime tools: + +```ts +const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [ + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', +] as const +``` + +Change to route-specific direct MCP names. + +Important: + +- `Client.listTools()` returns plain names such as `message_send`. +- Do not prefix direct stdio results with `agent-teams_`. +- Only OpenCode app/API tool-id proof and production E2E evidence should deal with canonical ids like `agent-teams_message_send`. +- `agent_teams_message_send` is an accepted alias, not the canonical id produced by `buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')`. +- The orchestrator explicit list should be treated as a boundary adapter, not as the source of truth for app-side UI/readiness. +- `readiness.evidence.observedMcpTools` is already consumed by production evidence as OpenCode tool ids. Keep that public field canonical. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`. + +```ts +const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [ + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', +] as const + +const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [ + 'member_briefing', + 'task_add_comment', + 'task_attach_comment_file', + 'task_attach_file', + 'task_briefing', + 'task_complete', + 'task_create', + 'task_create_from_message', + 'task_get', + 'task_get_comment', + 'task_link', + 'task_list', + 'task_set_clarification', + 'task_set_owner', + 'task_set_status', + 'task_start', + 'task_unlink', + 'review_approve', + 'review_request', + 'review_request_changes', + 'review_start', + 'message_send', + 'process_list', + 'process_register', + 'process_stop', + 'process_unregister', + 'cross_team_send', + 'cross_team_list_targets', + 'cross_team_get_outbox', +] as const + +const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [ + ...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS, + ...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS, +] as const +``` + +Update direct listTools mapping: + +```ts +return (result.tools ?? []) + .map(tool => tool.name) + .filter((name): name is string => typeof name === 'string' && name.trim().length > 0) +``` + +Compare plain names internally: + +```ts +function matchAppMcpTools(observedDirectToolNames: string[], route: string): AppMcpToolProof { + const observedDirect = new Set(observedDirectToolNames) + const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter( + tool => !observedDirect.has(tool) + ) + ... +} +``` + +But emit canonical ids for bridge readiness/evidence: + +```ts +function buildOpenCodeCanonicalMcpToolId(toolName: string): string { + return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}` +} + +function matchAppMcpTools(observedDirectToolNames: string[], route: string): AppMcpToolProof { + const observedDirect = new Set(observedDirectToolNames) + const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter( + tool => !observedDirect.has(tool) + ) + + return { + ok: missingTools.length === 0, + observedTools: uniqueSortedStrings( + observedDirectToolNames.map(buildOpenCodeCanonicalMcpToolId) + ), + observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames), + missingTools, + diagnostics: missingTools.length === 0 + ? [] + : [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`], + route, + } +} +``` + +If you do not want to widen `AppMcpToolProof`, skip `observedDirectToolNames`, but still keep `observedTools` canonical because `readiness.evidence.observedMcpTools` feeds production evidence. + +Acceptance: + +- Readiness fails before launch if OpenCode cannot see a tool that `member_briefing` may instruct it to use. +- Cache/dedupe behavior stays unchanged. +- The list intentionally excludes lead-only tools like `lead_briefing` and non-teammate groups, but includes all teammate-operational catalog groups including cross-team. +- Diagnostics can still display `agent-teams/` labels, but matching must use plain direct MCP names. +- Public readiness/evidence still exposes canonical ids like `agent-teams_message_send`, not plain direct names, so production evidence remains comparable to `REQUIRED_AGENT_TEAMS_APP_TOOL_IDS`. + +### Step 11 - Expand app-side OpenCode MCP tool availability proof + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +``` + +Current required tools only include runtime tools. Add separate lists and make every app-side place that expresses "launch-visible Agent Teams MCP tools" use the full app tool list. + +Important current-shape note: + +- Normal UI launch readiness goes through `OpenCodeTeamRuntimeAdapter -> OpenCodeReadinessBridge -> agent_teams_orchestrator`. +- `OpenCodeTeamLaunchReadinessService` and `OpenCodeMcpToolAvailabilityProbe` still have tests and policy helpers, but they are not the only production launch path. +- Therefore this step is mostly about shared app-side constants and production gate expectations. The actual live proof still happens in the orchestrator direct MCP preflight from Step 10. + +Preferred pattern: + +```ts +import * as agentTeamsControllerModule from 'agent-teams-controller'; + +const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES = + agentTeamsControllerModule.AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES; + +export const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [ + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', +] as const; + +export const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS: readonly string[] = [ + ...AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, +] as const; + +export const REQUIRED_AGENT_TEAMS_APP_TOOLS: readonly string[] = [ + ...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS, + ...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS, +] as const; + +export const REQUIRED_AGENT_TEAMS_APP_TOOL_IDS = REQUIRED_AGENT_TEAMS_APP_TOOLS.map((tool) => + buildOpenCodeCanonicalMcpToolId('agent-teams', tool) +); +``` + +Why typed as `readonly string[]`: + +- The controller catalog is a CommonJS runtime export typed by `.d.ts`, not a literal tuple inside this TS file. +- Keeping app/full lists as `readonly string[]` avoids pretending the spread catalog is a compile-time literal tuple. +- `REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS` remains a literal tuple because runtime schema contracts depend on exact names. + +Add a small import-shape test: + +```ts +it('loads teammate-operational tool names from agent-teams-controller package main', () => { + expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).toContain('message_send'); + expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).toContain('cross_team_send'); + expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).not.toContain('lead_briefing'); +}); +``` + +Acceptance: + +- App-side readiness policy and orchestrator readiness proof agree semantically, even though the orchestrator matches plain direct names and the app production gate expects canonical OpenCode ids. +- Missing app tools are classified as launch-blocking. +- Runtime schema verification still only applies to runtime tools. Operational tools can be name-proven first unless their schemas become part of the launch-critical contract. +- The app-side list follows `agent-teams-controller/src/mcpToolCatalog.js`, so adding a new teammate-operational tool updates readiness automatically. +- Existing callers that truly mean runtime schema tools should use `REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS`, not the full app list. +- Existing callers that mean launch-visible app tools should use `REQUIRED_AGENT_TEAMS_APP_TOOLS` or `REQUIRED_AGENT_TEAMS_APP_TOOL_IDS`. + +### Step 12 - Update production E2E gate to prove the same app tools + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts +/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts +/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs +``` + +Current risk: + +```ts +requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => + buildOpenCodeCanonicalMcpToolId('agent-teams', tool) +) +``` + +That is weaker than the planned app-tool readiness proof. It can create two bad states: + +- Production gate passes an artifact that proves runtime tools only, while OpenCode can still miss `message_send` or `member_briefing`. +- Production gate fails confusingly after the required list changes because old evidence was generated with the weaker runtime-only set. + +Change `OpenCodeReadinessBridge.applyProductionE2EGate()` to use the full app tool id list: + +```ts +import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../mcp/OpenCodeMcpToolAvailability'; + +// ... +requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, +``` + +Update the live evidence builder in `OpenCodeProductionGate.live.test.ts`. + +Current builder path: + +```ts +mcpTools: { + requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => + buildOpenCodeCanonicalMcpToolId('agent-teams', tool) + ), + observedTools: input.readinessObservedTools, +}, +``` + +Change it to: + +```ts +mcpTools: { + requiredTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, + observedTools: input.readinessObservedTools, +}, +``` + +Guard the shape explicitly because `input.readinessObservedTools` comes from the orchestrator bridge: + +```ts +const missingObservedAppToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS.filter( + (toolId) => !input.readinessObservedTools.includes(toolId) +); +expect(missingObservedAppToolIds).toEqual([]); +``` + +If that assertion fails after Step 10, the orchestrator is probably returning plain direct tool names in `readiness.evidence.observedMcpTools`. Fix the bridge output instead of weakening production evidence. + +Keep the evidence validator exact: + +```ts +const observedTools = new Set(evidence.mcpTools.observedTools); +const missingTools = requiredTools.filter((tool) => !observedTools.has(tool)); +``` + +Do not add alias fallback here. Evidence should prove the canonical OpenCode app ids that production readiness expects. Alias tolerance belongs in transcript/capture parsing, not in production artifact gating. + +Update live/evidence test fixtures: + +```ts +const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS; +``` + +`scripts/prove-opencode-production.mjs` should not need evidence JSON logic changes because it only launches the live test. Do update its console copy only if it refers to runtime-only proof. The real acceptance point is the artifact written by `OpenCodeProductionGate.live.test.ts`. + +Acceptance: + +- Production evidence must include canonical ids for runtime and teammate-operational tools. +- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are explicitly covered by tests. +- Old runtime-only evidence fails production mode with a clear diagnostic listing missing app MCP tools. +- Dogfood mode can still warn instead of blocking if current policy already allows degraded evidence there. +- No schema validation is added for all operational tools in this step. That would be a separate hardening pass. + +### Step 13 - Resolve secondary lane current-run evidence from lane manifest + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/RuntimeStoreManifest.ts +``` + +Important correction after code inspection: + +- `OpenCodeRuntimeLaneIndexEntry` currently has `laneId`, `state`, `updatedAt`, and `diagnostics`. +- The durable run identity is already represented by `RuntimeStoreManifest.activeRunId`. +- Do not add `activeRunId` to `lanes.json` in v1. That would create a second source of truth and a new drift path. +- Use `lanes.json` only as the active/degraded/stopped lane directory index. +- Use the lane-scoped manifest as the authoritative durable run identity. + +Current lane index shape should stay narrow: + +```ts +export interface OpenCodeRuntimeLaneIndexEntry { + laneId: string; + state: 'active' | 'stopped' | 'degraded'; + updatedAt: string; + diagnostics?: string[]; +} +``` + +Use the existing manifest reader: + +```ts +const evidence = await new OpenCodeRuntimeManifestEvidenceReader({ + teamsBasePath: getTeamsBasePath(), +}).read(teamName, laneId); + +const activeRunId = evidence.activeRunId?.trim() || null; +``` + +Add a narrow helper in `TeamProvisioningService`: + +```ts +private async resolveDurableOpenCodeRuntimeRunId( + teamName: string, + laneId: string +): Promise { + const live = this.getCurrentOpenCodeRuntimeRunId(teamName, laneId); + if (live) return live; + + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( + () => null + ); + const laneEntry = laneIndex?.lanes[laneId]; + if (laneEntry?.state !== 'active') { + return null; + } + + const manifest = await new OpenCodeRuntimeManifestEvidenceReader({ + teamsBasePath: getTeamsBasePath(), + }) + .read(teamName, laneId) + .catch(() => null); + + return manifest?.activeRunId?.trim() || null; +} +``` + +Do not let `read()` legacy fallback accidentally revive unrelated lanes. The helper must first require `lanes.json` to say the specific lane is `active`; then it may read the lane-scoped manifest. If the lane is `degraded` or missing, return `null` and let the existing stale-lane recovery path handle it. + +Then consume the durable run id in three places. + +1. Runtime evidence acceptance: + +Current risk: + +```ts +currentRunId: this.getCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId), +``` + +`getCurrentOpenCodeRuntimeRunId()` currently uses in-memory maps. After app restart, a still-running OpenCode lane can call `runtime_bootstrap_checkin`, but `RuntimeRunTombstoneStore.assertEvidenceAccepted()` rejects it with `current_run_missing`. + +Change to async durable resolution: + +```ts +const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId( + input.teamName, + input.laneId +); + +await store.assertEvidenceAccepted({ + teamName: input.teamName, + runId: input.runId, + currentRunId, + evidenceKind: input.evidenceKind, +}); +``` + +2. Message delivery recovery: + +Current risk: + +```ts +if (!trackedRunId) { + const laneIndex = await readOpenCodeRuntimeLaneIndex(...) + if (laneIndex?.lanes[laneIdentity.laneId]?.state !== 'active') { + return { delivered: false, reason: 'opencode_runtime_not_active' }; + } +} + +const result = await adapter.sendMessageToMember({ + ...(trackedRunId ? { runId: trackedRunId } : {}), + ... +}); +``` + +This checks durable active state but drops durable `activeRunId`, so `runSendMessage()` cannot prepend the identity reminder after restart. + +Use a resolved run id: + +```ts +const durableRunId = trackedRunId + ? trackedRunId + : await this.resolveDurableOpenCodeRuntimeRunId(teamName, laneIdentity.laneId); +if (!trackedRunId && !durableRunId) { + return { delivered: false, reason: 'opencode_runtime_not_active' }; +} + +const result = await adapter.sendMessageToMember({ + ...(durableRunId ? { runId: durableRunId } : {}), + ... +}); +``` + +3. Runtime delivery service current-run resolver: + +Current risk: + +```ts +getCurrentRunId: async (candidateTeamName) => + this.getCurrentOpenCodeRuntimeRunId(candidateTeamName, laneId), +``` + +This is used by `runtime_deliver_message` delivery journaling. After restart, it has the same in-memory-only weakness. Change it to: + +```ts +getCurrentRunId: async (candidateTeamName) => + this.resolveDurableOpenCodeRuntimeRunId(candidateTeamName, laneId), +``` + +Acceptance: + +- Do not add `activeRunId` to `lanes.json` in v1. +- `runtime_bootstrap_checkin` and `runtime_heartbeat` can be accepted after app restart when `lanes.json` says the lane is active and the lane-scoped manifest has `activeRunId`. +- UI/user messages to an OpenCode secondary lane after app restart pass the manifest `activeRunId` to `opencode.sendMessage`, allowing identity reminder recovery. +- `runtime_deliver_message` current-run checks use the same durable manifest fallback. +- Stale lane recovery still degrades missing lane state. +- This supports `runtime_bootstrap_checkin`; normal messages still use `message_send`. + +### Step 14 - Guard runtime delivery team-change event shape + +Files: + +```text +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts +/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts +/Users/belief/dev/projects/claude/claude_team/src/shared/types/team.ts +/Users/belief/dev/projects/claude/claude_team/src/main/index.ts +``` + +Current contracts: + +```ts +export interface RuntimeDeliveryTeamChangeEvent { + type: string; + teamName: string; + data?: Record; +} +``` + +```ts +export interface TeamChangeEvent { + type: TeamChangeEventType; + teamName: string; + runId?: string; + detail?: string; + taskId?: string; +} +``` + +Do not leak `RuntimeDeliveryTeamChangeEvent` directly to renderer or `src/main/index.ts`. + +Keep the adapter in `createOpenCodeRuntimeDeliveryService()` explicit: + +```ts +emit: (event) => { + this.teamChangeEmitter?.({ + type: event.type as TeamChangeEvent['type'], + teamName: event.teamName, + detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined, + }); +} +``` + +Reason: + +- `TeamMessageFeedService` cache invalidation currently happens on `type === "inbox"` or `type === "lead-message"`, so OpenCode replies can still become visible by type alone. +- Renderer message refresh also schedules on `event.type`, not `event.detail`. +- But app-side relay, native notification, and several filesystem-derived branches inspect top-level `event.detail`, not `event.data.detail`. +- If future code emits `data.detail` directly, the UI may still refresh sometimes, while relay/notification behavior silently diverges. + +Acceptance: + +- Runtime delivery destination ports may continue returning local `data.detail`. +- Only the app-facing `TeamChangeEvent` crosses the `TeamProvisioningService` boundary. +- App-facing runtime delivery events expose top-level `detail` for `inboxes/user.json`, `sentMessages.json`, and cross-team outbox changes. +- No frontend fake refresh is added. +- No change is required to `RuntimeDeliveryService` itself unless tests show the local event shape has already leaked outside the adapter. + +### Step 15 - Keep frontend changes bounded and truth-based + +Expected frontend changes: store action contract plus warning/display behavior only. + +Reason: + +- `TeamInboxReader` already reads `inboxes/user.json`. +- `TeamMessageFeedService` already merges inbox messages. +- `src/renderer/store/index.ts` schedules message refresh on `event.type === "inbox"` and `event.type === "lead-message"`. +- `MessagesPanel` already clears pending replies when a message has `to === "user"`. +- Step 9.7 requires the renderer store action to return `SendMessageResult` and rethrow real send failures after setting store error state. +- Step 9.7 requires `MessagesPanel`/`SendMessageDialog` to surface OpenCode runtime delivery failure as a warning, not as a fake agent reply. + +Do not add a frontend fake "agent answered" path. Frontend may show "message saved but runtime delivery failed" because that is real delivery state; it must not synthesize teammate thoughts/replies. + +## Remaining Uncertainty Register + +These are the places most likely to produce regressions if implemented casually. + +1. Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests +`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Production E2E evidence should use the canonical dash form only. Add tests for all three contexts so nobody normalizes everything to underscore by accident. + +2. Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests +The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract. + +3. Runtime provider inference - 🎯 7 🛡️ 8 🧠 4, about 60-120 LOC with tests +`runtimeProvider: "opencode"` is the most reliable signal and should be sent by orchestrator. Provider metadata inference is a fallback for controller-generated messages and manual briefing calls. Native fallback must remain default when neither explicit runtimeProvider nor OpenCode metadata is present. + +4. Production evidence freshness - 🎯 8 🛡️ 9 🧠 3, about 30-80 LOC in tests +Old evidence that proves runtime tools only must fail production gate after this change. This is intentional. The diagnostic must explain which app MCP tools are missing so regeneration is obvious. + +5. Model compliance versus protocol availability - 🎯 6 🛡️ 8 🧠 5, about 80-180 LOC with event tests +The protocol can make the correct tools visible and instruct the model correctly, but the model may still answer in plain text. The reliable app truth should be: runtime check-in proves the lane is alive, `message_send` proves visible user/team response, and tool-only assistant events still count as `latestAssistantMessageId` for launch liveness. + +6. OpenCode send-message command durability - 🎯 7 🛡️ 7 🧠 4, about 40-120 LOC if kept direct, 140-260 LOC if moved into the state-changing bridge +`OpenCodeReadinessBridge.sendOpenCodeTeamMessage()` currently executes `opencode.sendMessage` directly, while launch/reconcile/stop go through `OpenCodeStateChangingBridgeCommandService`. For this seam, do not expand scope unless needed: keep direct send, require adapter callers to pass `runId`, and use the runId only for identity reminder/recovery. Treat `promptAsync()` success as delivery acceptance; post-send reconcile failure is a warning, not a false delivery failure. If stale-send bugs continue, promote `opencode.sendMessage` into the state-changing command service as a separate reliability pass. + +7. Cross-team transport split - 🎯 8 🛡️ 9 🧠 4, about 50-120 LOC in prompt helper/tests +OpenCode needs two visible messaging transports: `message_send` for local user/lead/member messages, and `cross_team_send` for remote teams. Collapsing both into `message_send` would resurrect the exact bug existing prompts warn about: treating `cross_team_send` as a recipient. The helper should expose both phrases/examples, but implementation remains narrow because it only affects wording and tests. + +8. Direct-proof output shape - 🎯 8 🛡️ 9 🧠 4, about 40-100 LOC in orchestrator/tests +The orchestrator direct MCP proof must match plain names from `Client.listTools()`, but `readiness.evidence.observedMcpTools` should remain canonical ids because production evidence consumes it. This is a boundary-shape risk, not a model behavior risk. Add tests that plain direct names pass matching while public bridge evidence contains `agent-teams_message_send`. + +8.1 Plain tool-name false positives - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests +Alias parsing must accept plain `message_send` for OpenCode direct MCP proof/capture, but a plain name alone is not enough in arbitrary transcripts. For capture/log paths, require Agent Teams payload shape and current team match before treating a plain short name as our tool. Canonical/prefixed names remain accepted directly. + +9. Durable run id consumption - 🎯 9 🛡️ 9 🧠 5, about 90-180 LOC with tests +`activeRunId` already lives in the lane-scoped `RuntimeStoreManifest`; `lanes.json` only proves lane state. Bootstrap evidence acceptance, runtime delivery journaling, and message delivery must read the manifest when in-memory run maps are empty after app restart. Without this, OpenCode lanes can be active in `lanes.json` but still reject check-in or send messages without identity recovery. Do not add a duplicate run id field to `lanes.json` in v1. + +10. Cross-team taskRefs mismatch - 🎯 7 🛡️ 8 🧠 4, about 0-25 LOC if forbidden in v1, 70-150 LOC if wired end-to-end +Shared types already include `taskRefs` for cross-team messages, but `cross_team_send` schema/controller do not persist them. The semantic helper must not generate unsupported fields. Either explicitly forbid cross-team taskRefs in v1 helper examples or wire schema/storage/tests now. + +11. OpenCode direct-message metadata - 🎯 9 🛡️ 9 🧠 5, about 120-220 LOC with tests +OpenCode runtime delivery should not parse native `SendMessage` prompt text to discover the reply recipient. Pass `replyRecipient`, `actionMode`, and `taskRefs` as explicit adapter metadata, and build a separate OpenCode-native delivery prompt. This lowers model confusion and removes regex coupling to `buildMessageDeliveryText()`. + +12. Runtime delivery event adapter shape - 🎯 9 🛡️ 9 🧠 3, about 20-60 LOC in tests +`RuntimeDeliveryService` uses a local `data.detail` envelope, but the app uses `TeamChangeEvent.detail`. The existing adapter maps it correctly. Test this so a future refactor does not bypass the adapter and make OpenCode replies visible in some UI paths while relay/notification/detail-sensitive paths silently miss the change. + +13. User-directed `message_send` sender identity - 🎯 9 🛡️ 9 🧠 3, about 35-90 LOC with tests +The protocol cannot rely only on prompt text saying "include from". If `from` is absent for `to: "user"`, the controller currently creates a durable user-to-user row. Add a narrow guard that rejects missing/invalid sender only for user-directed MCP messages, while keeping legacy user-to-member `message_send` defaults intact. + +14. OpenCode direct-message delivery acknowledgement - 🎯 9 🛡️ 9 🧠 5, about 130-260 LOC with tests +The send-message IPC path currently treats inbox persistence as success and starts OpenCode runtime delivery asynchronously. That is okay for native teammates because they watch/read inbox files, but not for OpenCode lanes. Add an additive runtime delivery result and fix the renderer store action to return/rethrow, so UI can distinguish "message saved" from "OpenCode runtime actually received the prompt" and pending replies do not hang on hidden failures. + +15. Runtime delivery tool ambiguity - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests +`runtime_deliver_message` can write real destinations, so it is not safe to rely on the name alone and hope the model chooses `message_send`. V1 should keep it available for runtime evidence but make descriptions/prompts explicit that normal visible replies use `message_send`, while runtime delivery is a low-level idempotent path only when explicitly requested. + +16. OpenCode-targeted inbox relay - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests +The app currently has special relay for native lead inboxes and native teammate file-watch behavior, but OpenCode teammates do not watch `inboxes/.json`. Any plan that only fixes UI direct-send still leaves OpenCode-to-OpenCode and system notification routes unreliable. Add recipient-provider-aware runtime relay with at-least-once semantics, read-flag commit, duplicate-event dedupe, and explicit unsupported OpenCode lead diagnostics. Do not reuse native `relayMemberInboxMessages()`. + +17. `message_send` recipient canonicalization - 🎯 9 🛡️ 9 🧠 4, about 70-150 LOC with tests +Raw recipient names currently become inbox filenames. This is fragile because prompts and tests use lead aliases like `team-lead` while teams can have a custom lead name. Resolve `to` and `from` against configured members before persistence, with `user` as the only special local destination and cross-team tool names rejected clearly. + +18. OpenCode lead runtime session gap - 🎯 8 🛡️ 9 🧠 5, about 60-140 LOC for v1 diagnostics, 300-700 LOC if adding a real lead lane +The app-side OpenCode adapter passes `leadPrompt`, but the orchestrator launch handler currently creates sessions from `body.members` only. `relayLeadInboxMessages()` also requires native `run.child`. V1 must not pretend pure OpenCode lead inbox delivery works. Either route to an existing stored `team-lead` OpenCode session if one is later introduced, or leave rows unread with an explicit diagnostic. Creating a real OpenCode lead lane is a separate feature, not a hidden side effect of this messaging seam. + +## Tests + +### `claude_team` MCP/controller tests + +File: + +```text +/Users/belief/dev/projects/claude/claude_team/mcp-server/test/tools.test.ts +/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/test/controller.test.js +``` + +Add tests: + +```ts +it('returns OpenCode-safe member briefing when runtimeProvider is opencode', async () => { + const briefing = await getTool('member_briefing').execute({ + claudeDir, + teamName, + memberName: 'bob', + runtimeProvider: 'opencode', + }); + + const text = (briefing as { content: Array<{ text: string }> }).content[0]?.text ?? ''; + expect(text).toContain('agent-teams_message_send'); + expect(text).not.toMatch(/via SendMessage|SendMessage summary field/); +}); +``` + +```ts +it('keeps native member briefing using SendMessage by default', async () => { + const briefing = await getTool('member_briefing').execute({ + claudeDir, + teamName, + memberName: 'alice', + }); + + const text = (briefing as { content: Array<{ text: string }> }).content[0]?.text ?? ''; + expect(text).toContain('SendMessage'); + expect(text).not.toContain('runtimeProvider: "opencode"'); +}); +``` + +```ts +it('infers OpenCode-safe member briefing from provider metadata when runtimeProvider is omitted', async () => { + // Configure bob with providerId: 'opencode'. + const briefing = await getTool('member_briefing').execute({ + claudeDir, + teamName, + memberName: 'bob', + }); + + const text = (briefing as { content: Array<{ text: string }> }).content[0]?.text ?? ''; + expect(text).toContain('agent-teams_message_send'); + expect(text).not.toMatch(/via SendMessage|SendMessage summary field/); +}); +``` + +```ts +it('persists taskRefs through message_send', async () => { + await getTool('message_send').execute({ + claudeDir, + teamName, + to: 'user', + from: 'bob', + text: 'Done', + summary: '#abcd1234 done', + taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], + }); + + const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8')); + expect(rows[0].taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); +}); +``` + +```ts +it('rejects user-directed message_send without a teammate sender', async () => { + await expect( + getTool('message_send').execute({ + claudeDir, + teamName, + to: 'user', + text: 'Done', + }) + ).rejects.toThrow(/to user requires from/i); +}); +``` + +```ts +it('keeps legacy user-to-member message_send valid without from', async () => { + await getTool('message_send').execute({ + claudeDir, + teamName, + to: 'alice', + text: 'Please check this', + }); + + const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')); + expect(rows.at(-1)).toMatchObject({ + from: 'user', + to: 'alice', + text: 'Please check this', + }); +}); +``` + +```ts +it('rejects user-directed message_send when from is not a configured team member', async () => { + await expect( + getTool('message_send').execute({ + claudeDir, + teamName, + to: 'user', + from: 'unknown-agent', + text: 'Done', + }) + ).rejects.toThrow(/unknown from|configured team member/i); +}); +``` + +```ts +it('canonicalizes message_send lead aliases before writing inbox files', async () => { + // Configure lead member with name "lead", not "team-lead". + await getTool('message_send').execute({ + claudeDir, + teamName, + to: 'team-lead', + from: 'bob', + text: 'Need help', + }); + + expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'lead.json'))).toBe(true); + expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe(false); +}); +``` + +```ts +it('canonicalizes message_send sender aliases before persistence', async () => { + // Configure lead member with name "lead", not "team-lead". + await getTool('message_send').execute({ + claudeDir, + teamName, + to: 'alice', + from: 'team-lead', + text: 'Please review', + }); + + const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')); + expect(rows.at(-1)).toMatchObject({ from: 'lead', to: 'alice' }); +}); +``` + +```ts +it('rejects message_send to unknown local recipients instead of creating arbitrary inboxes', async () => { + await expect( + getTool('message_send').execute({ + claudeDir, + teamName, + to: 'unknown-agent', + from: 'bob', + text: 'Hello', + }) + ).rejects.toThrow(/unknown to|configured team member/i); +}); +``` + +```ts +it('rejects message_send to cross_team_send pseudo recipient with a clear tool hint', async () => { + await expect( + getTool('message_send').execute({ + claudeDir, + teamName, + to: 'cross_team_send', + from: 'bob', + text: 'Hello', + }) + ).rejects.toThrow(/use cross_team_send/i); +}); +``` + +```ts +it('rejects message_send to qualified external recipients after local roster lookup fails', async () => { + await expect( + getTool('message_send').execute({ + claudeDir, + teamName, + to: 'other-team.team-lead', + from: 'bob', + text: 'Hello', + }) + ).rejects.toThrow(/use cross_team_send/i); +}); +``` + +```ts +it('keeps configured dotted local members valid before applying cross-team heuristics', async () => { + // Configure a local member named "qa.bot". + await getTool('message_send').execute({ + claudeDir, + teamName, + to: 'qa.bot', + from: 'bob', + text: 'Local dotted member', + }); + + expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe(true); +}); +``` + +```ts +it('describes message_send as the normal visible reply tool for OpenCode', () => { + const tool = getRegisteredTool('message_send'); + expect(tool.description).toMatch(/visible.*message|normal replies/i); + expect(tool.description).toMatch(/to is "user".*from is required/i); +}); +``` + +```ts +it('describes runtime_deliver_message as low-level and not the normal reply path', () => { + const tool = getRegisteredTool('runtime_deliver_message'); + expect(tool.description).toMatch(/low-level|runtime delivery journal/i); + expect(tool.description).toMatch(/normal visible replies.*message_send/i); +}); +``` + +Add cross-team `taskRefs` tests only if Step 6.4 chooses Option B: + +```ts +it('persists taskRefs through cross_team_send when enabled', async () => { + await getTool('cross_team_send').execute({ + claudeDir, + teamName, + toTeam: 'review-team', + fromMember: 'bob', + text: 'Please review task #abcd1234', + summary: '#abcd1234 review request', + taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], + }); + + const targetInbox = JSON.parse( + fs.readFileSync(path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'), 'utf8') + ); + expect(targetInbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); + + const outbox = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8')); + expect(outbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]); +}); +``` + +Add controller-level tests: + +```js +it('preserves provider metadata when resolving team members', () => { + // Create team config with bob providerId opencode. + // Resolve members through controller/runtime helper path. + // Assert bob.providerId === 'opencode'. +}); +``` + +```js +it('uses OpenCode messaging protocol in assignment notifications for OpenCode owners', () => { + // Create bob as providerId opencode. + // Create task with owner bob and notifyOwner true. + // Read bob inbox. + // Assert inbox text contains agent-teams_message_send. + // Assert inbox text does not match /via SendMessage|SendMessage summary field/. +}); +``` + +```js +it('keeps cross-team replies on cross_team_send for OpenCode briefings', async () => { + const briefing = await controller.tasks.memberBriefing('bob', { + runtimeProvider: 'opencode', + }); + + expect(briefing).toContain('agent-teams_cross_team_send'); + expect(briefing).toContain('toTeam'); + expect(briefing).not.toMatch(/message_send[^\\n]+cross_team_send/); +}); +``` + +```js +it('keeps native assignment notifications using SendMessage', () => { + // Create alice without providerId. + // Create task with owner alice. + // Assert inbox text still contains SendMessage. +}); +``` + +Add alias tests for app capture/log support: + +```ts +it.each([ + 'message_send', + 'agent-teams_message_send', + 'agent_teams_message_send', + 'mcp__agent-teams__message_send', + 'mcp__agent_teams__message_send', +])('canonicalizes %s to message_send', (toolName) => { + expect(canonicalizeAgentTeamsToolName(toolName)).toBe('message_send'); +}); +``` + +```ts +it.each([ + '"name":"agent-teams_task_start"', + '"name":"agent_teams_task_start"', + '"name":"mcp__agent-teams__task_start"', + '"name":"proxy_agent-teams_task_complete"', +])('detects task boundary aliases in raw log line %s', (line) => { + expect(lineHasAgentTeamsTaskBoundaryToolName(line)).toBe(true); +}); +``` + +```ts +it('does not double-persist MCP message_send as a lead-process message', () => { + // Feed captureSendMessages a message_send tool_use to a normal local teammate. + // Assert MCP persistence path is not duplicated by pushLiveLeadProcessMessage/persistSentMessage. + // Cross-team pseudo-recipient fallback remains covered separately. +}); +``` + +```ts +it('does not classify unrelated plain message_send tool_use without Agent Teams payload shape', () => { + expect( + isAgentTeamsToolUse({ + rawName: 'message_send', + canonicalName: 'message_send', + toolInput: { channel: 'general', body: 'hello' }, + currentTeamName: 'atlas-hq', + }) + ).toBe(false); +}); +``` + +Add app-side OpenCode readiness/evidence tests: + +```ts +it('uses full app tool ids for OpenCode production E2E gate expectations', async () => { + const evidence = buildEvidence({ + observedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, + }); + + const result = await bridge.runReadiness({ + launchMode: 'production', + selectedModel: 'minimax-m2.5-free', + // ... + }); + + expect(result.supportLevel).toBe('production_supported'); + expect(productionE2eEvidence.read).toHaveBeenCalled(); +}); +``` + +```ts +it('rejects stale runtime-only OpenCode production evidence', async () => { + const runtimeOnlyToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS.map((tool) => + buildOpenCodeCanonicalMcpToolId('agent-teams', tool) + ); + const evidence = buildEvidence({ + observedTools: runtimeOnlyToolIds, + requiredTools: runtimeOnlyToolIds, + }); + + const gate = assertOpenCodeProductionE2EArtifactGate({ + evidence, + artifactPath: '/tmp/opencode-e2e.json', + expected: { + requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, + }, + }); + + expect(gate.ok).toBe(false); + expect(gate.diagnostics.join('\n')).toContain('agent-teams_message_send'); + expect(gate.diagnostics.join('\n')).toContain('agent-teams_member_briefing'); +}); +``` + +```ts +it('live production evidence builder writes full app tool ids', () => { + const evidence = buildCandidateEvidence({ + readinessObservedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, + // other required live-test fields + }); + + expect(evidence.mcpTools.requiredTools).toEqual(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS); + expect(evidence.mcpTools.observedTools).toContain('agent-teams_message_send'); + expect(evidence.mcpTools.observedTools).toContain('agent-teams_cross_team_send'); +}); +``` + +```ts +it('live production evidence builder rejects plain direct tool names for artifact output', () => { + expect(() => + buildCandidateEvidence({ + readinessObservedTools: ['message_send', 'member_briefing'], + // other required live-test fields + }) + ).toThrow(/agent-teams_message_send/); +}); +``` + +```ts +it('keeps runtime schema validation scoped to runtime proof tools', () => { + expect(APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => contract.name)).toEqual( + REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS + ); + expect(REQUIRED_AGENT_TEAMS_APP_TOOLS).toContain('message_send'); + expect(APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => contract.name)).not.toContain( + 'message_send' + ); +}); +``` + +Add OpenCode direct-message delivery tests: + +```ts +it('delivers OpenCode runtime message with explicit reply recipient instead of parsing native SendMessage text', async () => { + const bridge = createBridgeSpy(); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); + + await adapter.sendMessageToMember({ + runId: 'run-1', + teamName, + laneId: 'secondary:bob', + memberName: 'bob', + cwd, + text: 'Can you check this?', + messageId: 'msg-1', + replyRecipient: 'user', + }); + + const sentText = bridge.sentMessages[0].text; + expect(sentText).toContain('to="user"'); + expect(sentText).toContain('agent-teams_message_send'); + expect(sentText).not.toContain('CRITICAL: Reply using the SendMessage tool'); +}); +``` + +```ts +it('passes taskRefs and actionMode into OpenCode runtime message prompt', async () => { + const bridge = createBridgeSpy(); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); + + await adapter.sendMessageToMember({ + runId: 'run-1', + teamName, + laneId: 'secondary:bob', + memberName: 'bob', + cwd, + text: 'Please respond on task #abcd1234', + replyRecipient: 'alice', + actionMode: 'do', + taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], + }); + + const sentText = bridge.sentMessages[0].text; + expect(sentText).toContain('to="alice"'); + expect(sentText).toContain('Action mode for this message: do'); + expect(sentText).toContain('"displayId":"abcd1234"'); +}); +``` + +```ts +it('keeps native persisted inbox text unchanged but stores base text for OpenCode recipients', async () => { + // Exercise the IPC send-message path. + // For a native recipient, assert sendMessage() persisted memberDeliveryText containing native SendMessage guidance. + // For an OpenCode recipient, assert the persisted inbox row text is baseText and does not contain SendMessage guidance. + // Assert relayOpenCodeMemberInboxMessages() received explicit replyRecipient/actionMode/taskRefs metadata. +}); +``` + +```ts +it('returns runtimeDelivery success for live OpenCode direct messages', async () => { + // Exercise handleSendMessage() for a live non-lead OpenCode recipient. + // Mock relayOpenCodeMemberInboxMessages() to return { attempted: 1, delivered: 1, failed: 0 }. + // Assert result.runtimeDelivery is { providerId: "opencode", attempted: true, delivered: true }. + // Assert inbox persistence still happened with base text for OpenCode. +}); +``` + +```ts +it('returns runtimeDelivery failure without hiding the persisted message', async () => { + // Exercise handleSendMessage() for a live non-lead OpenCode recipient. + // Mock relayOpenCodeMemberInboxMessages() to return { attempted: 1, delivered: 0, failed: 1, diagnostics: ["opencode_runtime_not_active"] }. + // Assert result.deliveredToInbox is true. + // Assert result.runtimeDelivery.delivered is false and reason is preserved. +}); +``` + +```ts +it('does not auto-close the send dialog when OpenCode runtime delivery fails', async () => { + // Mount SendMessageDialog with lastResult.runtimeDelivery.delivered === false. + // Assert the dialog stays open and shows an actionable warning. +}); +``` + +```ts +it('sendTeamMessage rejects after setting sendMessageError when IPC send fails', async () => { + // Mock api.teams.sendMessage to reject. + // Await expect(store.getState().sendTeamMessage(...)).rejects.toThrow(). + // Assert sendMessageError is set and lastSendMessageResult is null. +}); +``` + +```ts +it('clears pending reply when OpenCode runtime delivery fails after inbox persistence', async () => { + // Mock sendTeamMessage to resolve with runtimeDelivery.delivered === false. + // Send from MessagesPanel or TeamDetailView. + // Assert the member pending-reply spinner is removed and the user-sent row remains. +}); +``` + +```ts +it('shows OpenCode message_send replies from inboxes/user.json without frontend fake state', async () => { + // Seed the message feed with a durable inboxes/user.json row: + // { from: "bob", to: "user", source: "opencode_message_send", text: "done" }. + // Assert TeamMessageFeedService includes it. + // Assert MessagesPanel renders it as bob -> user. + // Assert reconcilePendingRepliesByMember clears bob after this reply timestamp. +}); +``` + +```ts +it('relays unread OpenCode-targeted inbox messages to the live OpenCode runtime lane', async () => { + // Configure jack with providerId: "opencode" and an active lane. + // Seed inboxes/jack.json with unread { from: "bob", to: "jack", messageId: "msg-1" }. + // Call relayOpenCodeMemberInboxMessages(teamName, "jack"). + // Assert deliverOpenCodeMemberMessage() was called with memberName "jack", text, messageId, and replyRecipient "bob". + // Assert the inbox row is marked read or its messageId is recorded as delivered. +}); +``` + +```ts +it('does not runtime-relay native teammate inbox messages', async () => { + // Configure alice as Codex/native. + // Seed inboxes/alice.json with unread message. + // Call relayOpenCodeMemberInboxMessages(teamName, "alice"). + // Assert attempted/delivered are 0 and deliverOpenCodeMemberMessage() is not called. +}); +``` + +```ts +it('uses the same OpenCode recipient predicate for persisted text and runtime relay', async () => { + // Configure jack as OpenCode in members.meta and native-looking model fallback in config. + // Assert handleSendMessage() persists base text for jack. + // Assert relayOpenCodeMemberInboxMessages() attempts runtime delivery for jack. +}); +``` + +```ts +it('does not double-deliver UI direct messages after FileWatcher inbox change', async () => { + // Send UI message to OpenCode jack through handleSendMessage(). + // Simulate FileWatcher inbox event for inboxes/jack.json. + // Assert the same messageId is delivered at most once to OpenCode runtime. +}); +``` + +```ts +it('keeps failed transient OpenCode inbox relay retryable', async () => { + // Mock deliverOpenCodeMemberMessage() to fail with opencode_runtime_not_active. + // Assert the row remains unread and the diagnostic is returned. +}); +``` + +```ts +it('does not mark OpenCode inbox rows read before bridge acceptance', async () => { + // Mock deliverOpenCodeMemberMessage() to fail before prompt acceptance. + // Assert markInboxMessagesRead() is not called and the row remains unread. +}); +``` + +```ts +it('reports prompt-accepted mark-read failure as non-exactly-once diagnostic', async () => { + // Mock deliverOpenCodeMemberMessage() to accept the prompt. + // Mock markInboxMessagesRead() to throw. + // Assert diagnostics include opencode_inbox_mark_read_failed_after_delivery. + // Assert the result does not claim a clean exactly-once success. +}); +``` + +```ts +it('routes native lead inbox relay through the legacy stdin path', async () => { + // Configure a mixed team with Codex/Claude lead and OpenCode secondary teammates. + // Seed inboxes/.json with one unread message. + // Call relayInboxFileToLiveRecipient(teamName, leadName). + // Assert relayLeadInboxMessages() is called and OpenCode runtime delivery is not attempted. +}); +``` + +```ts +it('does not silently consume pure OpenCode lead inbox when no lead session exists', async () => { + // Configure a pure OpenCode runtime-adapter team where isTeamAlive() is true via runtimeAdapterRunByTeam. + // Ensure there is no stored OpenCode session record for the canonical lead name. + // Seed inboxes/.json with one unread message. + // Call relayInboxFileToLiveRecipient(teamName, leadName). + // Assert diagnostics include opencode_lead_runtime_session_missing. + // Assert the inbox row remains unread and no teammate session received the prompt. +}); +``` + +```ts +it('keeps OpenCode member relay independent from unsupported OpenCode lead relay', async () => { + // Configure a pure OpenCode team with a stored teammate session for bob but no team-lead session. + // Seed inboxes/bob.json and inboxes/.json. + // Assert bob is relayed and marked read. + // Assert lead remains unread with an unsupported-lead diagnostic. +}); +``` + +### Orchestrator tests + +File: + +```text +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.test.ts +``` + +Update helper `mockRequiredMcpTools()` to include app tools: + +```ts +tools: [ + { name: 'runtime_bootstrap_checkin' }, + { name: 'runtime_deliver_message' }, + { name: 'runtime_task_event' }, + { name: 'runtime_heartbeat' }, + { name: 'member_briefing' }, + { name: 'task_add_comment' }, + { name: 'task_attach_comment_file' }, + { name: 'task_attach_file' }, + { name: 'task_briefing' }, + { name: 'task_complete' }, + { name: 'task_create' }, + { name: 'task_create_from_message' }, + { name: 'task_get' }, + { name: 'task_get_comment' }, + { name: 'task_link' }, + { name: 'task_list' }, + { name: 'task_set_clarification' }, + { name: 'task_set_owner' }, + { name: 'task_set_status' }, + { name: 'task_start' }, + { name: 'task_unlink' }, + { name: 'review_approve' }, + { name: 'review_request' }, + { name: 'review_request_changes' }, + { name: 'review_start' }, + { name: 'message_send' }, + { name: 'process_list' }, + { name: 'process_register' }, + { name: 'process_stop' }, + { name: 'process_unregister' }, + { name: 'cross_team_send' }, + { name: 'cross_team_list_targets' }, + { name: 'cross_team_get_outbox' }, +], +``` + +Add missing tool failure test: + +```ts +test('readiness fails when app MCP message_send is missing', async () => { + // Arrange listTools without message_send. + // Assert result.ok === false or launchAllowed === false. + // Assert diagnostics mention message_send. +}); +``` + +Add direct-name proof test: + +```ts +test('direct MCP proof compares plain tool names, not OpenCode ids', async () => { + // Arrange listTools returning plain names: message_send, member_briefing, ... + // Assert readiness succeeds. + // Assert internal matching did not require listTools to return agent-teams_message_send. + // Assert public readiness evidence still contains canonical agent-teams_message_send. + // Assert public readiness evidence does not expose plain message_send as the production artifact id. +}); +``` + +Add a bridge-output shape test: + +```ts +test('readiness evidence emits canonical OpenCode ids after plain direct MCP proof', async () => { + // Arrange direct listTools with plain message_send/member_briefing/task_start/cross_team_send. + const result = await runReadiness(); + + expect(result.data.requiredToolsPresent).toBe(true); + expect(result.data.evidence.observedMcpTools).toContain('agent-teams_message_send'); + expect(result.data.evidence.observedMcpTools).toContain('agent-teams_member_briefing'); + expect(result.data.evidence.observedMcpTools).not.toContain('message_send'); +}); +``` + +Add launch prompt identity test: + +```ts +test('launch prepends OpenCode runtime identity and opencode briefing mode', async () => { + const prompts: string[] = []; + openCodeSessionBridge.promptAsync = async (_record, input) => { + prompts.push(input.text); + }; + + // Execute opencode.launchTeam. + + expect(prompts[0]).toContain('agent-teams_runtime_bootstrap_checkin'); + expect(prompts[0]).toContain('mcp__agent-teams__runtime_bootstrap_checkin'); + expect(prompts[0]).toContain('runtimeSessionId'); + expect(prompts[0]).toContain('agent-teams_member_briefing'); + expect(prompts[0]).toContain('mcp__agent-teams__member_briefing'); + expect(prompts[0]).toContain('"runtimeProvider":"opencode"'); + expect(prompts[0]).not.toMatch(/runtime_bootstrap_checkin[^<]+laneId/); +}); +``` + +Add launch settle test: + +```ts +test('launch waits briefly for OpenCode preview before final reconcile', async () => { + openCodeSessionBridge.promptAsync = async () => undefined; + openCodeSessionBridge.observePreview = async () => ({ + record, + summary: { + previewOutcome: 'observed', + latestAssistantMessageId: 'msg-tool-only', + latestAssistantPreview: 'calling agent-teams_runtime_bootstrap_checkin', + runtimeState: 'running', + diagnostics: [], + }, + }); + openCodeSessionBridge.reconcileSession = async () => confirmedAliveReconcile(); + + // Execute opencode.launchTeam. + + expect(openCodeSessionBridge.observePreview).toHaveBeenCalled(); + expect(result.data.members.bob?.launchState).toBe('confirmed_alive'); +}); +``` + +```ts +test('launch settle runs concurrently for multiple OpenCode members', async () => { + const observeStarted: string[] = []; + const observeRelease = createDeferred(); + openCodeSessionBridge.observePreview = async (record) => { + observeStarted.push(record.memberName); + await observeRelease.promise; + return previewObserved(record); + }; + + const launchPromise = runOpenCodeLaunchTeamWithMembers(['bob', 'jack', 'tom']); + await waitUntil(() => observeStarted.length === 3); + observeRelease.resolve(); + const result = await launchPromise; + + expect(observeStarted.sort()).toEqual(['bob', 'jack', 'tom']); + expect(result.data.teamLaunchState).not.toBe('failed'); +}); +``` + +```ts +test('launch settle caps concurrent preview observers', async () => { + let activeObservers = 0; + let maxActiveObservers = 0; + openCodeSessionBridge.observePreview = async (record) => { + activeObservers += 1; + maxActiveObservers = Math.max(maxActiveObservers, activeObservers); + await delay(25); + activeObservers -= 1; + return previewObserved(record); + }; + + await runOpenCodeLaunchTeamWithMembers(['a', 'b', 'c', 'd', 'e']); + + expect(maxActiveObservers).toBeLessThanOrEqual(3); +}); +``` + +```ts +test('launch preview timeout does not become a hard member failure', async () => { + openCodeSessionBridge.observePreview = async () => { + throw new Error('preview timeout'); + }; + openCodeSessionBridge.reconcileSession = async () => createdReconcile(); + + // Execute opencode.launchTeam. + + expect(result.data.members.bob?.launchState).toBe('created'); + expect(result.data.members.bob?.diagnostics.join('\n')).not.toContain('preview timeout'); +}); +``` + +Add send-message recovery test: + +```ts +test('sendMessage prepends OpenCode identity reminder only when runId is present', async () => { + const prompts: string[] = []; + openCodeSessionBridge.promptAsync = async (_record, input) => { + prompts.push(input.text); + }; + + // Execute opencode.sendMessage once with runId and once without runId. + + expect(prompts[0]).toContain('agent-teams_runtime_bootstrap_checkin'); + expect(prompts[0]).toContain('runtimeSessionId'); + expect(prompts[1]).not.toContain('agent-teams_runtime_bootstrap_checkin'); +}); +``` + +```ts +test('sendMessage treats post-accept reconcile failure as warning, not delivery failure', async () => { + openCodeSessionBridge.promptAsync = async () => undefined; + openCodeSessionBridge.reconcileSession = async () => { + throw new Error('reconcile timeout'); + }; + + const result = await runOpenCodeSendMessage({ runId: 'run-1', memberName: 'bob' }); + + expect(result.data.accepted).toBe(true); + expect(result.data.diagnostics.map((d) => d.code)).toContain( + 'opencode_send_reconcile_failed_after_prompt_accept' + ); +}); +``` + +```ts +test('sendMessage reports delivery failure only when prompt enqueue fails', async () => { + openCodeSessionBridge.promptAsync = async () => { + throw new Error('prompt rejected'); + }; + + await expect(runOpenCodeSendMessage({ runId: 'run-1', memberName: 'bob' })).rejects.toThrow( + 'prompt rejected' + ); +}); +``` + +Add app restart durable-run tests: + +```ts +test('runtime evidence acceptance falls back to lane-scoped manifest activeRunId', async () => { + // Arrange no in-memory runtimeAdapterRunByTeam/secondaryRuntimeRunByTeam entry. + // Arrange lanes.json lane active. + // Arrange lane-scoped manifest.json activeRunId. + // Call runtime_bootstrap_checkin or runtime_heartbeat with the same runId. + // Assert evidence is accepted, not rejected with current_run_missing. +}); +``` + +```ts +test('OpenCode message delivery uses lane-scoped manifest activeRunId after restart', async () => { + // Arrange recipient is an OpenCode secondary lane. + // Arrange no tracked provisioning run. + // Arrange lanes.json lane active and lane-scoped manifest activeRunId. + // Spy adapter.sendMessageToMember. + // Call deliverOpenCodeMemberMessage. + // Assert sendMessageToMember receives runId from the lane-scoped manifest. +}); +``` + +```ts +test('runtime delivery service current-run resolver uses lane-scoped manifest after restart', async () => { + // Arrange RuntimeDeliveryService with no in-memory run. + // Arrange lanes.json lane active and lane-scoped manifest activeRunId. + // Call runtime_deliver_message through the service path. + // Assert current-run validation receives the manifest run id. +}); +``` + +Add runtime delivery event-shape tests: + +```ts +it('maps runtime delivery local data.detail to app TeamChangeEvent.detail', async () => { + const emitted: TeamChangeEvent[] = []; + const service = createProvisioningService({ + teamChangeEmitter: (event) => emitted.push(event), + }); + + await deliverOpenCodeRuntimeMessageToUser(service, { + teamName, + fromMemberName: 'bob', + text: 'done', + }); + + expect(emitted).toContainEqual( + expect.objectContaining({ + type: 'lead-message', + teamName, + detail: 'opencode-runtime-delivery', + }) + ); + expect(emitted[0]).not.toHaveProperty('data.detail'); +}); +``` + +```ts +it('emits top-level inbox detail for runtime delivery to member inboxes', async () => { + const emitted: TeamChangeEvent[] = []; + const service = createProvisioningService({ + teamChangeEmitter: (event) => emitted.push(event), + }); + + await deliverOpenCodeRuntimeMessageToMember(service, { + teamName, + fromMemberName: 'bob', + toMemberName: 'alice', + text: 'please review', + }); + + expect(emitted).toContainEqual( + expect.objectContaining({ + type: 'inbox', + teamName, + detail: 'inboxes/alice.json', + }) + ); +}); +``` + +Add renderer/store smoke test if an existing test harness already covers store event subscriptions: + +```ts +it('refreshes tracked team messages on OpenCode runtime delivery team-change events by type', async () => { + // Arrange selected/tracked team. + // Emit { type: 'inbox', teamName, detail: 'inboxes/user.json' }. + // Assert refreshTeamMessagesHead(teamName) is scheduled/called. +}); +``` + +### Event translator test + +File: + +```text +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeEventTranslator.test.ts +``` + +Add a test that a tool-only assistant message still produces `latestAssistantMessageId`. + +Reason: + +Launch state currently maps to `confirmed_alive` when `summary.latestAssistantMessageId` exists. If OpenCode replies only with tool calls, we still need that to count as alive. + +Current code already appears to support this because `OpenCodeTranscriptProjector.projectMessage()` creates an assistant canonical message even when it only has `tool` parts. The test is still important because `bridgeStateFromSummary()` depends on this behavior. + +Add assertions to the existing tool lifecycle test: + +```ts +expect(summary.latestAssistantMessageId).toBe('msg-assistant-tool'); +expect(summary.latestAssistantText).toBeNull(); +expect(summary.latestAssistantPreview).toBeNull(); +``` + +### Commands + +Run targeted tests first: + +```bash +cd /Users/belief/dev/projects/claude/claude_team +pnpm --filter agent-teams-controller test -- test/controller.test.js +pnpm --filter agent-teams-mcp test -- test/tools.test.ts +pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/main/services/team/OpenCodeProductionE2EEvidence.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx +``` + +```bash +cd /Users/belief/dev/projects/claude/agent_teams_orchestrator +bun test src/services/opencode/OpenCodeBridgeCommandHandler.test.ts src/services/teamBootstrap/teamBootstrapSpec.test.ts src/hooks/useInboxPoller.test.ts +``` + +Then broader checks: + +```bash +cd /Users/belief/dev/projects/claude/claude_team +pnpm typecheck:workspace +pnpm --filter agent-teams-mcp test:e2e +``` + +```bash +cd /Users/belief/dev/projects/claude/agent_teams_orchestrator +bun run build +``` + +Avoid heavy E2E until targeted tests pass. + +## Manual Verification + +1. Launch a mixed team with one Codex lead, one Codex teammate, and two OpenCode teammates. +2. Confirm OpenCode launch prompt includes runtime identity in logs. +3. Confirm OpenCode teammates call `runtime_bootstrap_checkin`. +4. Confirm OpenCode teammates call `member_briefing` with `runtimeProvider: "opencode"`. +5. Send a message to an OpenCode teammate from the UI. +6. Confirm reply appears in Messages UI from that member with `to: "user"`. +7. Confirm the send result has no OpenCode runtime delivery warning when the bridge accepts the prompt. +8. Temporarily force an OpenCode runtime delivery failure in a dev build and confirm the message remains persisted but the dialog/composer shows `Message saved, but OpenCode runtime delivery failed`. +9. Ask one OpenCode teammate to message another OpenCode teammate and confirm the target receives a live runtime prompt, not only an inbox file row. +10. In a pure OpenCode test team without a stored lead session, send to the lead and confirm the inbox row remains unread with an explicit unsupported-lead diagnostic, not a fake success. +11. Assign a task to an OpenCode teammate. +12. Confirm owner notification says `agent-teams_message_send`, not `SendMessage`. +13. Complete a task from OpenCode and confirm task comment exists before visible summary message. + +## Rollout Order + +1. Add controller messaging protocol helper. +2. Preserve provider metadata in controller. +3. Add `runtimeProvider` to `member_briefing`. +4. Update `mcp-server/src/agent-teams-controller.d.ts` and `src/types/agent-teams-controller.d.ts`. +5. Update member briefing and task assignment wording. +6. Extend `message_send` with `taskRefs`. +7. Add the narrow `message_send(to: "user")` sender identity guard. +8. Canonicalize `message_send` local recipients/senders before persistence. +9. Disambiguate `message_send` and `runtime_deliver_message` descriptions/prompts. +10. Choose and implement/forbid cross-team `taskRefs` before helper examples can emit them. +11. Centralize Agent Teams tool-name alias matching. +12. Consolidate duplicate `OpenCodeSendMessageCommandBody` declarations. +13. Add orchestrator runtime identity prompt injection without unsupported `laneId` in tool payloads. +14. Add bounded concurrent OpenCode launch settle/preview before final launch-state mapping. +15. Add native-only prompt boundary guard tests so OpenCode does not receive generic `SendMessage` spawn prompts. +16. Make OpenCode direct-message runtime delivery explicit instead of parsing native `SendMessage` prompt text. +17. Make OpenCode direct-message runtime delivery outcome observable in `SendMessageResult` and UI. +18. Add the inbox relay selector that separates native lead, OpenCode runtime recipient, native teammate no-op, and unsupported OpenCode lead diagnostics. +19. Add OpenCode-targeted inbox runtime relay with dedupe/read marking. +20. Expand orchestrator direct MCP proof with the explicit plain-name adapter list while keeping public observed evidence as canonical OpenCode ids. +21. Expand app-side OpenCode MCP availability proof from controller catalog. +22. Update production E2E gate and evidence fixtures to require the full app tool id list. +23. Add lane-scoped manifest `activeRunId` recovery and consume it in evidence acceptance/message delivery/runtime delivery service. +24. Add runtime delivery `TeamChangeEvent.detail` adapter guard tests. +25. Add tests. +26. Run targeted tests. +27. Run broader checks. +28. Manually verify one real mixed OpenCode launch. + +## Failure Modes To Watch + +- OpenCode launch prompt contains both "first call member_briefing" and "first call runtime_bootstrap_checkin". Fix by making adapter prompt defer to orchestrator identity block. +- OpenCode identity block shows unsupported `laneId` inside `runtime_bootstrap_checkin`. Fix the example/helper, because the runtime tool schema does not accept it. +- OpenCode member prompt contains native-only "Use SendMessage" guidance. This means routing leaked through a native prompt builder; fix routing, not by global-replacing all native `SendMessage` text. +- OpenCode direct-message prompt contains native-only "CRITICAL: Reply using the SendMessage tool" guidance. This means runtime delivery is reusing `memberDeliveryText`; pass explicit metadata and build OpenCode-native delivery text. +- OpenCode uses `runtime_deliver_message` for an ordinary reply after a UI message. This means the tool descriptions/prompts are still ambiguous or the runtime-delivery path is being over-promoted in the normal reply contract. +- `message_send` creates `inboxes/team-lead.json` while the configured lead is named differently. This means local recipient canonicalization is missing or not using lead aliases. +- `message_send` creates `inboxes/unknown-agent.json` for an unconfigured local recipient. This should be a tool error, not a new durable inbox. +- UI send to an OpenCode teammate closes as success while OpenCode inbox runtime relay fails only in logs. This means delivery is still fire-and-forget or the `runtimeDelivery` result is ignored by the renderer. +- `inboxes/.json` contains native hidden `SendMessage` instructions. This makes retry unsafe because FileWatcher relay can later deliver a native prompt to OpenCode. +- `SendMessageDialog` auto-closes when `lastResult.runtimeDelivery.delivered === false`. This hides a real OpenCode delivery failure after inbox persistence and should be treated as a UI contract bug. +- OpenCode-to-OpenCode `message_send` creates `inboxes/.json` but the target never reacts. This means OpenCode-targeted inbox relay is missing or recipient provider detection failed. +- OpenCode-targeted inbox relay uses existing `relayMemberInboxMessages()`. That is wrong for this seam because it routes through native lead stdin and native `SendMessage` wording instead of direct OpenCode runtime prompt delivery. +- Pure OpenCode `message_send` to the lead creates `inboxes/.json` and then disappears as read. This is data loss: without a stored OpenCode lead session, the row must stay unread with an explicit unsupported-lead diagnostic. +- FileWatcher still calls `relayLeadInboxMessages()` directly for every lead inbox. This keeps pure OpenCode lead delivery as a silent no-op because that method requires `run.child`; route through the service selector instead. +- OpenCode relay marks an inbox row read before the bridge accepts the prompt. This can lose messages; read marking is the durable commit and must happen after accepted runtime delivery. +- OpenCode send reports delivery failure after `promptAsync()` succeeded only because post-send reconcile timed out. That creates duplicate retries. Reconcile after prompt acceptance is evidence freshness, not delivery acceptance. +- UI direct-send to OpenCode arrives twice. This means direct runtime delivery and FileWatcher inbox relay are not sharing messageId dedupe/read state. +- Pending-reply spinner stays forever after a send IPC error. This means `sendTeamMessage` is still swallowing failures instead of rethrowing after updating store state. +- Pending-reply spinner stays after `runtimeDelivery.delivered === false`. This means caller code did not consume the returned `SendMessageResult` or did not treat OpenCode runtime failure as "agent did not receive prompt". +- `member_briefing` default accidentally switches native teammates to `message_send`. Tests must prevent this. +- Cross-team prompt/helper emits `taskRefs` while `cross_team_send` schema rejects them. Either remove taskRefs from cross-team examples or wire schema/storage end-to-end. +- OpenCode owner detection fails because provider metadata is still missing from resolved members. +- Readiness passes while `message_send` is missing. This means proof list is still incomplete. +- Readiness passes while review/process/task-set tools are missing. This means proof only checked a small subset instead of all teammate-operational briefing tools. +- Direct MCP readiness fails even though `tools/list` contains `message_send`. This usually means direct stdio proof is incorrectly comparing plain names against OpenCode canonical ids. +- Production live evidence fails after direct MCP proof succeeds. This usually means the orchestrator started exposing plain names in `readiness.evidence.observedMcpTools`; keep plain names internal and expose canonical `agent-teams_*` ids there. +- Production mode passes with runtime-only evidence. This means `OpenCodeReadinessBridge` still uses `REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS` instead of the full app tool id list. +- Production mode blocks with stale evidence after this change. That is expected until the OpenCode production E2E artifact is regenerated, but the diagnostic must list the missing app tools clearly. +- App-side and orchestrator required tool lists drift. For v1, this is controlled by tests and explicit comments. If drift keeps recurring, move to a generated shared contract artifact. +- OpenCode member stays `created` even though the prompt was accepted. This usually means `promptAsync()` was reconciled too early; use the bounded launch-settle helper before final launch mapping. +- Preview observation times out and marks a teammate failed. That is wrong. Preview timeout should only fall back to reconcile and keep the member pending. +- Launch settle opens too many preview observers at once. Cap concurrency locally; do not scale observer count linearly with team size. +- Prompt tells OpenCode to use one alias, but log/capture code only recognizes another alias. Fix by using shared canonicalization helpers. +- Alias capture starts duplicating `message_send` in live messages. Keep the non-native no-double-persist guard in `captureSendMessages()`. +- OpenCode uses `message_send` for cross-team replies. That is wrong; cross-team replies must use `cross_team_send` with `toTeam`. +- OpenCode replies with `message_send({ to: "user", text: "..." })` and no `from`. This must fail clearly instead of writing `from: "user"`. +- `message_send` writes to `inboxes/user.json`, but UI does not show it. That would be a separate feed regression, not a protocol issue. +- Secondary lane check-in rejects due to missing current run id after app restart. Check `lanes.json` only for active/degraded state, then read lane-scoped `manifest.json.activeRunId`. +- Secondary lane check-in still rejects after lane-scoped manifest has `activeRunId`. This means evidence acceptance is still using only in-memory runtime maps. +- OpenCode secondary lane receives UI message after restart but does not get identity recovery. This means `deliverOpenCodeMemberMessage()` checked `lanes.json` state but did not pass manifest `activeRunId` to `sendMessageToMember()`. +- Runtime delivery emits `{ data: { detail } }` directly as a public team-change event. This can make type-based message refresh work while detail-based relay/notification branches miss the file path. Keep public events on `TeamChangeEvent.detail`. + +## Definition Of Done + +- Native Codex/Claude prompts still use `SendMessage`. +- OpenCode launch, briefing, assignment, completion, and clarification instructions consistently use `agent-teams_message_send`. +- OpenCode cross-team instructions consistently use `agent-teams_cross_team_send`, not `message_send`. +- OpenCode readiness fails if required app MCP tools are absent. +- OpenCode production E2E gate proves the same app MCP tools that readiness requires. +- Orchestrator direct proof matches plain MCP names internally and emits canonical OpenCode ids in readiness evidence. +- Runtime tool descriptions make `message_send` the normal visible reply API and keep `runtime_deliver_message` scoped to explicit low-level runtime delivery flows. +- OpenCode can prove liveness through `runtime_bootstrap_checkin`. +- OpenCode secondary lanes can accept runtime evidence and receive identity-reminder messages after app restart using lane-scoped manifest `activeRunId`. +- OpenCode runtime delivery receives explicit reply recipient/action mode/taskRefs and does not parse native `SendMessage` hidden prompt text. +- OpenCode-targeted inbox rows do not persist native-only `SendMessage` instructions; retries can safely rebuild OpenCode-native runtime prompts. +- `message_send` canonicalizes local recipients/senders before persistence, so lead aliases and unknown recipients cannot create wrong inbox files. +- UI direct sends to live OpenCode teammates either confirm runtime delivery or show a visible warning; there is no log-only post-send delivery failure. +- Persisted inbox messages addressed to OpenCode teammates are live-relayed to their runtime lanes, while native teammates keep file-watch behavior and lead keeps lead relay behavior. +- OpenCode inbox relay is direct-to-runtime and does not reuse native `relayMemberInboxMessages()` / `SendMessage` forwarding. +- Pure OpenCode lead inbox delivery is not silently consumed: without a real OpenCode lead session, rows remain unread and diagnostics say `opencode_lead_runtime_session_missing` or equivalent. +- Renderer send-message actions return `SendMessageResult` on success and reject on real send failure, so pending-reply cleanup is not dependent on dead `.catch()` paths. +- `message_send` cannot create `from: "user", to: "user"` rows; user-directed MCP replies require a configured teammate sender. +- OpenCode replies appear in Messages UI without frontend fake state. +- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, unsupported OpenCode lead diagnostics, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape. diff --git a/docs/team-management/research-worktrees.md b/docs/team-management/research-worktrees.md index 438c8809..80977caf 100644 --- a/docs/team-management/research-worktrees.md +++ b/docs/team-management/research-worktrees.md @@ -33,7 +33,7 @@ claude # Новая независимая сессия --- -## ЧАСТЬ 2: Существующая инфраструктура в Claude Agent Teams UI +## ЧАСТЬ 2: Существующая инфраструктура в Agent Teams ### Уже реализовано (можно переиспользовать) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index fc6d5aeb..f27fb60f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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'], }, diff --git a/landing/README.md b/landing/README.md index e818eb8a..0dcfcd38 100644 --- a/landing/README.md +++ b/landing/README.md @@ -1,4 +1,4 @@ -# Claude Agent Teams Landing +# Agent Teams Landing ## Quick start diff --git a/landing/components/common/AppLogo.vue b/landing/components/common/AppLogo.vue index d8ed900f..fb902dd1 100644 --- a/landing/components/common/AppLogo.vue +++ b/landing/components/common/AppLogo.vue @@ -6,12 +6,12 @@ const { baseURL } = useRuntimeConfig().app; diff --git a/landing/components/layout/AppFooter.vue b/landing/components/layout/AppFooter.vue index 4021f4d5..74cb7a7d 100644 --- a/landing/components/layout/AppFooter.vue +++ b/landing/components/layout/AppFooter.vue @@ -1,16 +1,19 @@ @@ -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 ─── */ diff --git a/landing/components/sections/TestimonialsSection.vue b/landing/components/sections/TestimonialsSection.vue index e86ab0df..132b7ac9 100644 --- a/landing/components/sections/TestimonialsSection.vue +++ b/landing/components/sections/TestimonialsSection.vue @@ -1,10 +1,11 @@ @@ -36,32 +35,21 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();

- {{ t("testimonials.sectionTitle") }} + {{ t('testimonials.sectionTitle') }}

- {{ t("testimonials.sectionSubtitle") }} + {{ t('testimonials.sectionSubtitle') }}

- -
+ +
"

{{ item.text }}

-
+
{{ getInitial(item.name) }}
@@ -75,17 +63,14 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
-
@@ -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 { diff --git a/landing/composables/useGithubRepo.ts b/landing/composables/useGithubRepo.ts new file mode 100644 index 00000000..1a6358ae --- /dev/null +++ b/landing/composables/useGithubRepo.ts @@ -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, + }; +}; diff --git a/landing/composables/usePageSeo.ts b/landing/composables/usePageSeo.ts index ad91d0c2..86c8dca6 100644 --- a/landing/composables/usePageSeo.ts +++ b/landing/composables/usePageSeo.ts @@ -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" }, diff --git a/landing/content/ar.json b/landing/content/ar.json index 22b1f44d..4d315e6e 100644 --- a/landing/content/ar.json +++ b/landing/content/ar.json @@ -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." }, { diff --git a/landing/content/de.json b/landing/content/de.json index c5bc894e..3a0ed2bd 100644 --- a/landing/content/de.json +++ b/landing/content/de.json @@ -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." }, { diff --git a/landing/content/en.json b/landing/content/en.json index f7aaa071..d7cf1ecc 100644 --- a/landing/content/en.json +++ b/landing/content/en.json @@ -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." }, { diff --git a/landing/content/es.json b/landing/content/es.json index f60a587b..ef46068f 100644 --- a/landing/content/es.json +++ b/landing/content/es.json @@ -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." }, { diff --git a/landing/content/fr.json b/landing/content/fr.json index 2febf60c..34c5a311 100644 --- a/landing/content/fr.json +++ b/landing/content/fr.json @@ -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." }, { diff --git a/landing/content/hi.json b/landing/content/hi.json index d13fc263..f31a8f1e 100644 --- a/landing/content/hi.json +++ b/landing/content/hi.json @@ -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 पर चल सकता है।" }, { diff --git a/landing/content/ja.json b/landing/content/ja.json index 7b5beac9..5c923c6d 100644 --- a/landing/content/ja.json +++ b/landing/content/ja.json @@ -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 で実行できます。" }, { diff --git a/landing/content/pt.json b/landing/content/pt.json index cee9580a..aeb6531f 100644 --- a/landing/content/pt.json +++ b/landing/content/pt.json @@ -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." }, { diff --git a/landing/content/ru.json b/landing/content/ru.json index 9f91b1ef..0620238a 100644 --- a/landing/content/ru.json +++ b/landing/content/ru.json @@ -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." }, { diff --git a/landing/content/zh.json b/landing/content/zh.json index c02dc42c..49cf58db 100644 --- a/landing/content/zh.json +++ b/landing/content/zh.json @@ -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 上。" }, { diff --git a/landing/data/downloads.ts b/landing/data/downloads.ts index d5a65d9c..3dd6f996 100644 --- a/landing/data/downloads.ts +++ b/landing/data/downloads.ts @@ -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; diff --git a/landing/locales/ar.json b/landing/locales/ar.json index 4964abaf..e75db3ba 100644 --- a/landing/locales/ar.json +++ b/landing/locales/ar.json @@ -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": { diff --git a/landing/locales/de.json b/landing/locales/de.json index 83081af4..b797ea14 100644 --- a/landing/locales/de.json +++ b/landing/locales/de.json @@ -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": { diff --git a/landing/locales/en.json b/landing/locales/en.json index 9e538cdb..2e381bf7 100644 --- a/landing/locales/en.json +++ b/landing/locales/en.json @@ -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": { diff --git a/landing/locales/es.json b/landing/locales/es.json index 75ff4f5e..52942486 100644 --- a/landing/locales/es.json +++ b/landing/locales/es.json @@ -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": { diff --git a/landing/locales/fr.json b/landing/locales/fr.json index 9dba3660..bb38045b 100644 --- a/landing/locales/fr.json +++ b/landing/locales/fr.json @@ -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": { diff --git a/landing/locales/hi.json b/landing/locales/hi.json index 085d2442..0f7e2337 100644 --- a/landing/locales/hi.json +++ b/landing/locales/hi.json @@ -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": { diff --git a/landing/locales/ja.json b/landing/locales/ja.json index fe32316e..86d4ef24 100644 --- a/landing/locales/ja.json +++ b/landing/locales/ja.json @@ -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": { diff --git a/landing/locales/pt.json b/landing/locales/pt.json index 5d03e555..de136030 100644 --- a/landing/locales/pt.json +++ b/landing/locales/pt.json @@ -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": { diff --git a/landing/locales/ru.json b/landing/locales/ru.json index cd4ad54e..0af96cd6 100644 --- a/landing/locales/ru.json +++ b/landing/locales/ru.json @@ -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": { diff --git a/landing/locales/zh.json b/landing/locales/zh.json index 1646735a..977114f2 100644 --- a/landing/locales/zh.json +++ b/landing/locales/zh.json @@ -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": { diff --git a/landing/nuxt.config.ts b/landing/nuxt.config.ts index ebb27d6e..927e5604 100644 --- a/landing/nuxt.config.ts +++ b/landing/nuxt.config.ts @@ -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: { diff --git a/landing/package-lock.json b/landing/package-lock.json index 1df6e4e9..1b556da7 100644 --- a/landing/package-lock.json +++ b/landing/package-lock.json @@ -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", diff --git a/landing/package.json b/landing/package.json index a00ce0c9..e33aa4f3 100644 --- a/landing/package.json +++ b/landing/package.json @@ -1,5 +1,5 @@ { - "name": "claude-agent-teams-landing", + "name": "agent-teams-landing", "private": true, "type": "module", "scripts": { diff --git a/landing/server/routes/robots.txt.ts b/landing/server/routes/robots.txt.ts index edc3ce65..1a5e13c8 100644 --- a/landing/server/routes/robots.txt.ts +++ b/landing/server/routes/robots.txt.ts @@ -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"); diff --git a/landing/server/routes/sitemap.xml.ts b/landing/server/routes/sitemap.xml.ts index 68a56e29..c359fe7c 100644 --- a/landing/server/routes/sitemap.xml.ts +++ b/landing/server/routes/sitemap.xml.ts @@ -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"); diff --git a/mcp-server/package.json b/mcp-server/package.json index d10602da..09d8bef5 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -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": { diff --git a/mcp-server/src/tools/kanbanTools.ts b/mcp-server/src/tools/kanbanTools.ts index a086db38..c9b53d4d 100644 --- a/mcp-server/src/tools/kanbanTools.ts +++ b/mcp-server/src/tools/kanbanTools.ts @@ -22,7 +22,8 @@ export function registerKanbanTools(server: Pick) { 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) { 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), diff --git a/mcp-server/src/tools/reviewTools.ts b/mcp-server/src/tools/reviewTools.ts index 60a8ef8b..7c0eb2ac 100644 --- a/mcp-server/src/tools/reviewTools.ts +++ b/mcp-server/src/tools/reviewTools.ts @@ -70,7 +70,7 @@ export function registerReviewTools(server: Pick) { getController(teamName, claudeDir).review.approveReview(taskId, { ...(from ? { from } : {}), ...(note ? { note } : {}), - ...(notifyOwner !== false ? { 'notify-owner': true } : {}), + ...(notifyOwner === true ? { 'notify-owner': true } : {}), ...(leadSessionId ? { leadSessionId } : {}), }) as Record ) diff --git a/mcp-server/test/stdio.e2e.test.ts b/mcp-server/test/stdio.e2e.test.ts index e9b46b32..8b99bb66 100644 --- a/mcp-server/test/stdio.e2e.test.ts +++ b/mcp-server/test/stdio.e2e.test.ts @@ -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(); + } + }); }); diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index b5bbc643..0fc075e5 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -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'; diff --git a/package.json b/package.json index fa1dd358..3d17935f 100644 --- a/package.json +++ b/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" } ] diff --git a/resources/pricing.json b/resources/pricing.json index 51bbd490..534f0611 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -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, diff --git a/runtime.lock.json b/runtime.lock.json index 491c9c94..7de2bad8 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -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" } diff --git a/scripts/prove-opencode-production.mjs b/scripts/prove-opencode-production.mjs index 020fba5d..6c3c221c 100644 --- a/scripts/prove-opencode-production.mjs +++ b/scripts/prove-opencode-production.mjs @@ -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' ); diff --git a/src/features/anthropic-runtime-profile/main/index.ts b/src/features/anthropic-runtime-profile/main/index.ts index 2ef91e4d..2304b4c5 100644 --- a/src/features/anthropic-runtime-profile/main/index.ts +++ b/src/features/anthropic-runtime-profile/main/index.ts @@ -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'; diff --git a/src/features/anthropic-runtime-profile/renderer/index.ts b/src/features/anthropic-runtime-profile/renderer/index.ts index 2ef91e4d..2304b4c5 100644 --- a/src/features/anthropic-runtime-profile/renderer/index.ts +++ b/src/features/anthropic-runtime-profile/renderer/index.ts @@ -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'; diff --git a/src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts b/src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts index 5fee0da6..ab5793da 100644 --- a/src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts +++ b/src/features/codex-model-catalog/main/composition/createCodexModelCatalogFeature.ts @@ -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'; diff --git a/src/features/codex-model-catalog/main/infrastructure/__tests__/CodexModelCatalogAppServerClient.test.ts b/src/features/codex-model-catalog/main/infrastructure/__tests__/CodexModelCatalogAppServerClient.test.ts index c8217d87..e1b4a006 100644 --- a/src/features/codex-model-catalog/main/infrastructure/__tests__/CodexModelCatalogAppServerClient.test.ts +++ b/src/features/codex-model-catalog/main/infrastructure/__tests__/CodexModelCatalogAppServerClient.test.ts @@ -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: { diff --git a/src/features/codex-runtime-profile/main/index.ts b/src/features/codex-runtime-profile/main/index.ts index 2cb5d22b..7f9adb26 100644 --- a/src/features/codex-runtime-profile/main/index.ts +++ b/src/features/codex-runtime-profile/main/index.ts @@ -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'; diff --git a/src/features/codex-runtime-profile/renderer/index.ts b/src/features/codex-runtime-profile/renderer/index.ts index 2cb5d22b..7f9adb26 100644 --- a/src/features/codex-runtime-profile/renderer/index.ts +++ b/src/features/codex-runtime-profile/renderer/index.ts @@ -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'; diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index 5710cf10..b0f36022 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -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 { diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index b24b6f97..5cf0b6df 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -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', }, diff --git a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts index 88ca234e..ebca2891 100644 --- a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts +++ b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts @@ -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'; diff --git a/src/main/bootstrapUserDataMigration.ts b/src/main/bootstrapUserDataMigration.ts new file mode 100644 index 00000000..df512046 --- /dev/null +++ b/src/main/bootstrapUserDataMigration.ts @@ -0,0 +1,5 @@ +import { app } from 'electron'; + +import { migrateElectronUserDataDirectory } from './utils/electronUserDataMigration'; + +export const earlyElectronUserDataMigrationResult = migrateElectronUserDataDirectory(app); diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 321e8102..d2835c14 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -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)) { diff --git a/src/main/http/updater.ts b/src/main/http/updater.ts index 9b1a308f..083c1202 100644 --- a/src/main/http/updater.ts +++ b/src/main/http/updater.ts @@ -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)); diff --git a/src/main/index.ts b/src/main/index.ts index 6de4da2c..fd91a899 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | null = null; +let shutdownComplete = false; +const startupTimers = new Set>(); + +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, + timeoutMs: number = SHUTDOWN_STEP_TIMEOUT_MS +): Promise { + let timeout: ReturnType | null = null; + + try { + await Promise.race([ + Promise.resolve().then(action), + new Promise((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 { // 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 { async function startHttpServer( modeSwitchHandler: (mode: 'local' | 'ssh') => Promise ): Promise { + 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 { + 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(); + }); }); diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 95a93fb5..aa18640b 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -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, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ae33938e..2f37120b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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, diff --git a/src/main/ipc/updater.ts b/src/main/ipc/updater.ts index 7c709ca0..d06b93cf 100644 --- a/src/main/ipc/updater.ts +++ b/src/main/ipc/updater.ts @@ -66,9 +66,9 @@ async function handleDownload(_event: IpcMainInvokeEvent): Promise { } } -function handleInstall(_event: IpcMainInvokeEvent): void { +async function handleInstall(_event: IpcMainInvokeEvent): Promise { try { - updaterService.quitAndInstall(); + await updaterService.quitAndInstall(); } catch (error) { logger.error('Error in updater:install:', getErrorMessage(error)); } diff --git a/src/main/ipc/window.ts b/src/main/ipc/window.ts index fa6fcbf7..d2e23682 100644 --- a/src/main/ipc/window.ts +++ b/src/main/ipc/window.ts @@ -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'); diff --git a/src/main/services/extensions/catalog/GitHubStarsService.ts b/src/main/services/extensions/catalog/GitHubStarsService.ts index 7458a76e..eed034e2 100644 --- a/src/main/services/extensions/catalog/GitHubStarsService.ts +++ b/src/main/services/extensions/catalog/GitHubStarsService.ts @@ -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) => { diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 3cf0e470..332a3616 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -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; + } } // =========================================================================== diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index ba3ce0ab..cadf289a 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -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 { + 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 { + 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 | 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 { 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); }); diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index a479024d..2485a195 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -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 { } } +async function assetExistsInAnyRepo(urls: readonly string[]): Promise { + for (const url of urls) { + if (await assetExists(url)) { + return true; + } + } + return false; +} + async function fetchText(url: string): Promise { try { const response = await net.fetch(url, { method: 'GET' }); @@ -60,6 +69,7 @@ export class UpdaterService { private mainWindow: BrowserWindow | null = null; private periodicTimer: ReturnType | null = null; private downloadedVersion: string | null = null; + private beforeQuitAndInstall: (() => Promise) | null = null; constructor() { autoUpdater.autoDownload = false; @@ -75,6 +85,10 @@ export class UpdaterService { this.mainWindow = window; } + setBeforeQuitAndInstall(handler: (() => Promise) | 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 { 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; } diff --git a/src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts b/src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts index dee886f8..8218e9a3 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexAppServerSessionFactory.ts @@ -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', }, diff --git a/src/main/services/infrastructure/updaterReleaseMetadata.ts b/src/main/services/infrastructure/updaterReleaseMetadata.ts index cdded6b7..8b13107d 100644 --- a/src/main/services/infrastructure/updaterReleaseMetadata.ts +++ b/src/main/services/infrastructure/updaterReleaseMetadata.ts @@ -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 @@ -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 { diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 0c6f572e..92240bb0 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -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'; diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 6db0be6f..3c4a2abe 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -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 { + if (!this.codexModelCatalogFeature) { + return null; + } + + try { + return await this.codexModelCatalogFeature.getCatalog(request); + } catch { + return null; + } + } + setApiKeyService(apiKeyService: ApiKeyService): void { this.apiKeyService = apiKeyService; } diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 9486b74f..896eaa13 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -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'); diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index 32732b7b..ebed1d6c 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -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'); } diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 86050daf..6e8face6 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -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( diff --git a/src/main/services/team/TaskChangeComputer.ts b/src/main/services/team/TaskChangeComputer.ts index 4d8cd308..0606657c 100644 --- a/src/main/services/team/TaskChangeComputer.ts +++ b/src/main/services/team/TaskChangeComputer.ts @@ -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, diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index e5986773..1ac752ea 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -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 = { +interface JournalReadResult { 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}`) ); } diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 4132b5cb..35ccc7f8 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -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, diff --git a/src/main/services/team/TeamLaunchStateStore.ts b/src/main/services/team/TeamLaunchStateStore.ts index fd528c10..bb67cd8b 100644 --- a/src/main/services/team/TeamLaunchStateStore.ts +++ b/src/main/services/team/TeamLaunchStateStore.ts @@ -25,7 +25,8 @@ export function getTeamLaunchSummaryPath(teamName: string): string { } async function isMissingTeamDirectoryWriteRace(teamName: string, error: unknown): Promise { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'EINVAL') { return false; } try { diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 70ce69e1..b2492695 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -34,6 +34,7 @@ const AUTH_ERROR_TOKENS = [ 'does not have access', 'please run /login', ]; +const CODEX_NATIVE_TIMEOUT_TOKENS = ['codex native exec timed out']; const NETWORK_ERROR_TOKENS = [ 'timeout', 'timed out', @@ -81,6 +82,9 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory if (includesAnyToken(normalized, AUTH_ERROR_TOKENS)) { return 'auth_error'; } + if (includesAnyToken(normalized, CODEX_NATIVE_TIMEOUT_TOKENS)) { + return 'codex_native_timeout'; + } if (includesAnyToken(normalized, NETWORK_ERROR_TOKENS)) { return 'network_error'; } @@ -400,7 +404,7 @@ export class TeamMemberRuntimeAdvisoryService { error?: string; isApiErrorMessage?: boolean; message?: { - content?: Array<{ type?: string; text?: string }>; + content?: { type?: string; text?: string }[]; }; }; @@ -435,9 +439,7 @@ export class TeamMemberRuntimeAdvisoryService { } } - private extractAssistantText( - content: Array<{ type?: string; text?: string }> | undefined - ): string { + private extractAssistantText(content: { type?: string; text?: string }[] | undefined): string { if (!Array.isArray(content)) { return ''; } diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index a7d3feba..16ef2cab 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -1,6 +1,6 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; -import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import * as fs from 'fs'; import * as path from 'path'; @@ -45,7 +45,9 @@ function normalizeOptionalBackendId(value: unknown): string | undefined { } function normalizeProviderId(value: unknown): TeamProviderId | undefined { - return value === 'anthropic' || value === 'codex' || value === 'gemini' ? value : undefined; + return value === 'anthropic' || value === 'codex' || value === 'gemini' || value === 'opencode' + ? value + : undefined; } function normalizeOptionalString(value: unknown): string | null { @@ -162,10 +164,7 @@ export class TeamMetaStore { return null; } - const providerId = - file.providerId === 'anthropic' || file.providerId === 'codex' || file.providerId === 'gemini' - ? file.providerId - : undefined; + const providerId = normalizeProviderId(file.providerId); return { version: 1, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2d17bea9..b09f1ed5 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -21,7 +21,12 @@ import { import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; -import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; +import { + execCli, + killProcessTree, + killTrackedCliProcesses, + spawnCli, +} from '@main/utils/childProcess'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { encodePath, @@ -100,6 +105,7 @@ import { resolveGeminiRuntimeAuth, } from '../runtime/geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; +import { ProviderConnectionService } from '../runtime/ProviderConnectionService'; import { buildProviderPreflightPingArgs, getProviderModelProbeExpectedOutput, @@ -237,6 +243,17 @@ interface OpenCodeRuntimeControlAck { observedAt: string; } +type BootstrapTranscriptOutcome = + | { + kind: 'success'; + observedAt: string; + } + | { + kind: 'failure'; + observedAt: string; + reason: string; + }; + import type { ActiveToolCall, CliProviderModelCatalog, @@ -1538,7 +1555,10 @@ interface MemberSpawnInboxCursor { } type LeadInboxMemberSpawnMessage = InboxMessage & { messageId: string }; -type LeadInboxLaunchReconcileMessage = Pick; +type LeadInboxLaunchReconcileMessage = Pick< + InboxMessage, + 'from' | 'text' | 'timestamp' | 'messageId' +>; function compareMemberSpawnInboxCursor( left: MemberSpawnInboxCursor, @@ -2094,6 +2114,31 @@ function isBootstrapTranscriptSuccessText( ); } +function isBootstrapTranscriptContextText( + text: string, + teamName: string, + memberName: string +): boolean { + const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); + const normalizedTeamName = teamName.trim().toLowerCase(); + const normalizedMemberName = memberName.trim().toLowerCase(); + if (!normalizedText || !normalizedTeamName || !normalizedMemberName) { + return false; + } + if ( + !normalizedText.includes(normalizedTeamName) || + !normalizedText.includes(normalizedMemberName) + ) { + return false; + } + return ( + normalizedText.includes('bootstrap') || + normalizedText.includes('bootstrapping') || + normalizedText.includes('member briefing') || + normalizedText.includes('task briefing') + ); +} + function extractTranscriptTextContent(value: unknown): string[] { if (typeof value === 'string') { const trimmed = value.trim(); @@ -2660,7 +2705,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Get task details: task_get { teamName: "${teamName}", taskId: "" }`, `- Get a single comment without loading full task: task_get_comment { teamName: "${teamName}", taskId: "", commentId: "" }`, ` When an inbox row provides structured task metadata (teamName/taskId/commentId), treat those identifiers as authoritative and use them directly. Do NOT infer alternate task ids or namespaces from visible prose.`, - `- Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "", status?: "pending|in_progress|completed|deleted", reviewState?: "none|review|needsFix|approved", kanbanColumn?: "review|approved", relatedTo?: "", blockedBy?: "", limit?: }`, + `- Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "", status?: "pending|in_progress|completed", reviewState?: "none|review|needsFix|approved", kanbanColumn?: "review|approved", relatedTo?: "", blockedBy?: "", limit?: }`, ` task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead's working queue.`, `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "", subject: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, @@ -2683,9 +2728,9 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `Review operations — use MCP tools directly (text comments do NOT change kanban state):`, `- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "", from: "${leadName}", reviewer: "" }`, `- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "", from: "" }`, - `- Approve review: review_approve { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true }`, + `- Approve review: review_approve { teamName: "${teamName}", taskId: "", from: "", note?: "", notifyOwner: true }`, ` Call review_approve EXACTLY ONCE per review. Include your review feedback in the "note" field of that single call. Do NOT call it twice (once to approve, once with a note). The tool auto-creates a comment from the note.`, - `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", comment: "" }`, + `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", from: "", comment: "" }`, `CRITICAL: Review is a state transition on the EXISTING work task. When implementation for task #X needs review, move #X through the review flow with review_request/review_start/review_approve/review_request_changes. Do NOT create a new separate task just to represent that review.`, `CRITICAL: Only send task #X into review when a concrete reviewer exists for #X. If no reviewer exists yet, keep #X completed until you assign/decide the reviewer. Do NOT use review_request just to park the task in REVIEW without an actual reviewer.`, `CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`, @@ -3439,6 +3484,7 @@ function normalizeSameTeamText(text: string): string { export class TeamProvisioningService { private readonly runtimeLaneCoordinator = createTeamRuntimeLaneCoordinator(); + private readonly providerConnectionService = ProviderConnectionService.getInstance(); private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; private static readonly BOOTSTRAP_FAILURE_TAIL_BYTES = 128 * 1024; @@ -3459,6 +3505,9 @@ export class TeamProvisioningService { string, { runId: string; providerId: TeamProviderId; cwd?: string } >(); + private readonly cancelledRuntimeAdapterRunIds = new Set(); + private stopAllTeamsGeneration = 0; + private readonly transientProbeProcesses = new Set>(); private readonly secondaryRuntimeRunByTeam = new Map< string, Map< @@ -3466,6 +3515,7 @@ export class TeamProvisioningService { { runId: string; providerId: 'opencode'; laneId: string; memberName: string; cwd?: string } > >(); + private readonly stoppingSecondaryRuntimeTeams = new Set(); private readonly retainedClaudeLogsByTeam = new Map(); private readonly persistedTranscriptClaudeLogsCache = new Map< string, @@ -3678,6 +3728,29 @@ export class TeamProvisioningService { defaultModel = modelCatalog.defaultLaunchModel?.trim() || defaultModel; } + if (params.providerId === 'codex' && runtimeCapabilities?.modelCatalog?.dynamic === true) { + const codexCatalog = await this.providerConnectionService.getCodexModelCatalog({ + cwd: params.cwd, + }); + if (codexCatalog?.providerId === 'codex' && codexCatalog.status === 'ready') { + for (const model of codexCatalog.models ?? []) { + const launchModel = model.launchModel?.trim(); + if (launchModel) { + modelIds.add(launchModel); + } + const catalogId = model.id?.trim(); + if (catalogId) { + modelIds.add(catalogId); + } + } + + if (!modelCatalog) { + modelCatalog = codexCatalog; + } + defaultModel = codexCatalog.defaultLaunchModel?.trim() || defaultModel; + } + } + return { defaultModel: params.providerId === 'anthropic' @@ -3986,6 +4059,48 @@ export class TeamProvisioningService { return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName); } + private canDeliverToTrackedRuntimeRun(teamName: string, runId: string): boolean { + const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId); + if ( + runtimeProgress && + ['disconnected', 'failed', 'cancelled'].includes(runtimeProgress.state) + ) { + return false; + } + const run = this.runs.get(runId); + if ( + run && + (run.processKilled || + run.cancelRequested || + ['disconnected', 'failed', 'cancelled'].includes(run.progress.state)) + ) { + return false; + } + return ( + this.runtimeAdapterRunByTeam.get(teamName)?.runId === runId || + this.provisioningRunByTeam.get(teamName) === runId || + this.aliveRunByTeam.get(teamName) === runId + ); + } + + private resolveDeliverableTrackedRuntimeRunId(teamName: string): string | null { + const candidates = Array.from( + new Set( + [ + this.provisioningRunByTeam.get(teamName), + this.aliveRunByTeam.get(teamName), + this.runtimeAdapterRunByTeam.get(teamName)?.runId, + ].filter((runId): runId is string => typeof runId === 'string' && runId.trim() !== '') + ) + ); + for (const runId of candidates) { + if (this.canDeliverToTrackedRuntimeRun(teamName, runId)) { + return runId; + } + } + return null; + } + private getOpenCodeRuntimeAdapter(): TeamLaunchRuntimeAdapter | null { if (!this.runtimeAdapterRegistry?.has('opencode')) { return null; @@ -4043,6 +4158,15 @@ export class TeamProvisioningService { if (providerId !== 'opencode') { return { delivered: false, reason: 'recipient_is_not_opencode' }; } + const removedAt = + metaMember != null + ? metaMember.removedAt + : (configMember as { removedAt?: unknown } | undefined)?.removedAt; + if (removedAt != null) { + return { delivered: false, reason: 'recipient_removed' }; + } + const canonicalMemberName = + metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; const leadMember = config?.members?.find((member) => isLeadMember(member)); const leadProviderId = @@ -4052,10 +4176,17 @@ export class TeamProvisioningService { const laneIdentity = buildPlannedMemberLaneIdentity({ leadProviderId, member: { - name: normalizedMemberName, + name: canonicalMemberName, providerId, }, }); + if ( + laneIdentity.laneKind === 'secondary' && + laneIdentity.laneOwnerProviderId === 'opencode' && + this.stoppingSecondaryRuntimeTeams.has(teamName) + ) { + return { delivered: false, reason: 'opencode_runtime_not_active' }; + } const cwd = config?.projectPath?.trim() || metaMember?.cwd?.trim() || @@ -4065,11 +4196,36 @@ export class TeamProvisioningService { return { delivered: false, reason: 'opencode_project_path_unavailable' }; } + const trackedRunId = this.resolveDeliverableTrackedRuntimeRunId(teamName); + const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + if ( + trackedRun && + laneIdentity.laneKind === 'secondary' && + laneIdentity.laneOwnerProviderId === 'opencode' + ) { + const liveLane = trackedRun.mixedSecondaryLanes.find( + (lane) => + lane.laneId === laneIdentity.laneId || + lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + if (!liveLane) { + return { delivered: false, reason: 'opencode_runtime_not_active' }; + } + } + if (!trackedRunId) { + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( + () => null + ); + if (laneIndex?.lanes[laneIdentity.laneId]?.state !== 'active') { + return { delivered: false, reason: 'opencode_runtime_not_active' }; + } + } + const result = await adapter.sendMessageToMember({ - runId: this.getTrackedRunId(teamName) ?? randomUUID(), + ...(trackedRunId ? { runId: trackedRunId } : {}), teamName, laneId: laneIdentity.laneId, - memberName: normalizedMemberName, + memberName: canonicalMemberName, cwd, text: input.text, messageId: input.messageId, @@ -6539,13 +6695,13 @@ export class TeamProvisioningService { } async getTeamAgentRuntimeSnapshot(teamName: string): Promise { + const runId = this.getTrackedRunId(teamName); const cached = this.agentRuntimeSnapshotCache.get(teamName); - if (cached && cached.expiresAtMs > Date.now()) { + if (cached && cached.expiresAtMs > Date.now() && cached.snapshot.runId === runId) { return cached.snapshot; } const updatedAt = nowIso(); - const runId = this.getTrackedRunId(teamName); const run = runId ? (this.runs.get(runId) ?? null) : null; const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null); @@ -6726,7 +6882,7 @@ export class TeamProvisioningService { const snapshot: TeamAgentRuntimeSnapshot = { teamName, updatedAt, - runId: run?.runId ?? null, + runId: run?.runId ?? runId, providerBackendId: migrateProviderBackendId( run?.request.providerId ?? persistedTeamMeta?.providerId, run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId @@ -8637,6 +8793,7 @@ export class TeamProvisioningService { */ private async respawnAfterAuthFailure(run: ProvisioningRun): Promise { const ctx = run.spawnContext; + const stopAllGenerationAtStart = this.stopAllTeamsGeneration; if (!ctx) { logger.error(`[${run.teamName}] Cannot respawn — no spawn context saved`); run.authRetryInProgress = false; @@ -8782,9 +8939,22 @@ export class TeamProvisioningService { ctx.claudePath, ctx.cwd, ctx.env, - ctx.args[mcpFlagIdx + 1] + ctx.args[mcpFlagIdx + 1], + { + isCancelled: () => + run.cancelRequested || + run.processKilled || + this.stopAllTeamsGeneration !== stopAllGenerationAtStart, + } ); } + if ( + run.cancelRequested || + run.processKilled || + this.stopAllTeamsGeneration !== stopAllGenerationAtStart + ) { + throw new Error('Team launch cancelled by app shutdown'); + } child = spawnCli(ctx.claudePath, ctx.args, { cwd: ctx.cwd, env: { ...ctx.env }, @@ -9013,6 +9183,7 @@ export class TeamProvisioningService { if (existingProvisioningRunId) { return { runId: existingProvisioningRunId }; } + const stopAllGenerationAtStart = this.stopAllTeamsGeneration; assertAppDeterministicBootstrapEnabled(); if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) { return this.createOpenCodeTeamThroughRuntimeAdapter(request, onProgress); @@ -9205,7 +9376,12 @@ export class TeamProvisioningService { } mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; - await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath); + await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath, { + isCancelled: () => + run.cancelRequested || + run.processKilled || + this.stopAllTeamsGeneration !== stopAllGenerationAtStart, + }); } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); @@ -9293,6 +9469,13 @@ export class TeamProvisioningService { await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { providerBackendId: request.providerBackendId, }); + if ( + run.cancelRequested || + run.processKilled || + this.stopAllTeamsGeneration !== stopAllGenerationAtStart + ) { + throw new Error('Team launch cancelled by app shutdown'); + } if (request.skipPermissions === false) { await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd); } @@ -9515,6 +9698,30 @@ export class TeamProvisioningService { throw new Error('OpenCode runtime adapter is not registered'); } + const stopAllGenerationAtStart = this.stopAllTeamsGeneration; + const previousRuntimeRun = this.runtimeAdapterRunByTeam.get(input.request.teamName); + if (previousRuntimeRun?.providerId === 'opencode') { + await this.stopOpenCodeRuntimeAdapterTeam(input.request.teamName, previousRuntimeRun.runId); + } + const previousPendingRunId = this.provisioningRunByTeam.get(input.request.teamName); + const previousRuntimeProgress = previousPendingRunId + ? this.runtimeAdapterProgressByRunId.get(previousPendingRunId) + : null; + if ( + previousPendingRunId && + previousRuntimeProgress && + this.isCancellableRuntimeAdapterProgress(previousRuntimeProgress) + ) { + await this.cancelRuntimeAdapterProvisioning(previousPendingRunId, previousRuntimeProgress); + } + if (this.stopAllTeamsGeneration !== stopAllGenerationAtStart) { + return this.recordCancelledOpenCodeRuntimeAdapterLaunch( + input.request.teamName, + input.sourceWarning, + input.onProgress + ); + } + const runId = randomUUID(); const startedAt = nowIso(); const initialProgress: TeamProvisioningProgress = { @@ -9577,6 +9784,13 @@ export class TeamProvisioningService { try { const result = await adapter.launch(launchInput); + if ( + this.cancelledRuntimeAdapterRunIds.delete(runId) || + this.provisioningRunByTeam.get(input.request.teamName) !== runId + ) { + await this.clearOpenCodeRuntimeAdapterPrimaryLaneIfOwned(input.request.teamName, runId); + return { runId }; + } await this.persistOpenCodeRuntimeAdapterLaunchResult(result, launchInput); const success = result.teamLaunchState === 'clean_success'; const pending = result.teamLaunchState === 'partial_pending'; @@ -9633,6 +9847,13 @@ export class TeamProvisioningService { }); return { runId }; } catch (error) { + if ( + this.cancelledRuntimeAdapterRunIds.delete(runId) || + this.provisioningRunByTeam.get(input.request.teamName) !== runId + ) { + await this.clearOpenCodeRuntimeAdapterPrimaryLaneIfOwned(input.request.teamName, runId); + return { runId }; + } await clearOpenCodeRuntimeLaneStorage({ teamsBasePath: getTeamsBasePath(), teamName: input.request.teamName, @@ -9766,6 +9987,7 @@ export class TeamProvisioningService { if (existingProvisioningRunId) { return { runId: existingProvisioningRunId }; } + const stopAllGenerationAtStart = this.stopAllTeamsGeneration; assertAppDeterministicBootstrapEnabled(); if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) { return this.launchOpenCodeTeamThroughRuntimeAdapter(request, onProgress); @@ -10197,7 +10419,12 @@ export class TeamProvisioningService { run.bootstrapUserPromptPath = bootstrapUserPromptPath; mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; - await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath); + await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath, { + isCancelled: () => + run.cancelRequested || + run.processKilled || + this.stopAllTeamsGeneration !== stopAllGenerationAtStart, + }); } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); @@ -10298,6 +10525,13 @@ export class TeamProvisioningService { ); try { + if ( + run.cancelRequested || + run.processKilled || + this.stopAllTeamsGeneration !== stopAllGenerationAtStart + ) { + throw new Error('Team launch cancelled by app shutdown'); + } if (request.skipPermissions === false) { await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd); } @@ -10412,6 +10646,11 @@ export class TeamProvisioningService { async cancelProvisioning(runId: string): Promise { const run = this.runs.get(runId); if (!run) { + const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId); + if (runtimeProgress) { + await this.cancelRuntimeAdapterProvisioning(runId, runtimeProgress); + return; + } throw new Error('Unknown runId'); } if ( @@ -10427,7 +10666,10 @@ export class TeamProvisioningService { // SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete // team files during cleanup. SIGKILL is uncatchable — files are preserved. killTeamProcess(run.child); - if (this.hasSecondaryRuntimeRuns(run.teamName)) { + if ( + this.getTrackedRunId(run.teamName) === run.runId && + this.hasSecondaryRuntimeRuns(run.teamName) + ) { void this.stopMixedSecondaryRuntimeLanes(run.teamName); } const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user'); @@ -10435,6 +10677,143 @@ export class TeamProvisioningService { this.cleanupRun(run); } + private isCancellableRuntimeAdapterProgress(progress: TeamProvisioningProgress): boolean { + return [ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', + ].includes(progress.state); + } + + private async cancelRuntimeAdapterProvisioning( + runId: string, + runtimeProgress: TeamProvisioningProgress + ): Promise { + if (!this.isCancellableRuntimeAdapterProgress(runtimeProgress)) { + throw new Error('Provisioning cannot be cancelled in current state'); + } + + const teamName = runtimeProgress.teamName; + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + this.cancelledRuntimeAdapterRunIds.add(runId); + this.runtimeAdapterRunByTeam.delete(teamName); + this.aliveRunByTeam.delete(teamName); + if (this.provisioningRunByTeam.get(teamName) === runId) { + this.provisioningRunByTeam.delete(teamName); + } + this.setRuntimeAdapterProgress({ + ...runtimeProgress, + state: 'cancelled', + message: 'Provisioning cancelled by user', + updatedAt: nowIso(), + }); + this.teamChangeEmitter?.({ + type: 'process', + teamName, + runId, + detail: 'cancelled', + }); + + const previousLaunchState = await this.launchStateStore.read(teamName); + const adapter = this.getOpenCodeRuntimeAdapter(); + if (adapter) { + try { + await adapter.stop({ + runId, + laneId: 'primary', + teamName, + cwd: runtimeRun?.cwd ?? this.readPersistedTeamProjectPath(teamName) ?? undefined, + providerId: 'opencode', + reason: 'user_requested', + previousLaunchState, + force: true, + }); + } catch (error) { + logger.warn( + `[${teamName}] Failed to stop OpenCode runtime adapter launch during cancel: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + }).catch(() => undefined); + } + + private getPendingRuntimeAdapterLaunchesForShutdown(): TeamProvisioningProgress[] { + return Array.from(this.runtimeAdapterProgressByRunId.values()).filter((progress) => + this.isCancellableRuntimeAdapterProgress(progress) + ); + } + + private async clearOpenCodeRuntimeAdapterPrimaryLaneIfOwned( + teamName: string, + runId: string + ): Promise { + const currentProvisioningRunId = this.provisioningRunByTeam.get(teamName); + const currentAliveRunId = this.aliveRunByTeam.get(teamName); + const currentRuntimeRun = this.runtimeAdapterRunByTeam.get(teamName); + const ownsPrimaryLane = + currentProvisioningRunId === runId || + currentAliveRunId === runId || + currentRuntimeRun?.runId === runId || + (!currentProvisioningRunId && !currentAliveRunId && !currentRuntimeRun); + if (!ownsPrimaryLane) { + return; + } + + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + }).catch(() => undefined); + if (this.runtimeAdapterRunByTeam.get(teamName)?.runId === runId) { + this.runtimeAdapterRunByTeam.delete(teamName); + } + if (this.aliveRunByTeam.get(teamName) === runId) { + this.aliveRunByTeam.delete(teamName); + } + if (this.provisioningRunByTeam.get(teamName) === runId) { + this.provisioningRunByTeam.delete(teamName); + } + } + + private recordCancelledOpenCodeRuntimeAdapterLaunch( + teamName: string, + sourceWarning: string | undefined, + onProgress: (progress: TeamProvisioningProgress) => void + ): TeamLaunchResponse { + const runId = randomUUID(); + const timestamp = nowIso(); + this.provisioningRunByTeam.delete(teamName); + this.runtimeAdapterRunByTeam.delete(teamName); + this.aliveRunByTeam.delete(teamName); + const progress: TeamProvisioningProgress = { + runId, + teamName, + state: 'cancelled', + message: 'Provisioning cancelled by user', + startedAt: timestamp, + updatedAt: timestamp, + warnings: sourceWarning ? [sourceWarning] : undefined, + }; + this.setRuntimeAdapterProgress(progress, onProgress); + this.teamChangeEmitter?.({ + type: 'process', + teamName, + runId, + detail: 'cancelled', + }); + return { runId }; + } + /** * Send a message to the team's lead process via stream-json stdin. * The lead will receive it as a new user turn and can delegate to teammates. @@ -11426,7 +11805,7 @@ export class TeamProvisioningService { const memberName = typeof inp.name === 'string' ? inp.name.trim() : ''; if (teamName && !memberName) { logger.warn( - `[captureTeamSpawnEvents] Agent call for team "${run.teamName}" is missing name — ` + + `[captureTeamSpawnEvents] Agent call for team "${run.teamName}" is missing name - ` + `runtime will spawn an ephemeral subagent instead of a persistent teammate` ); continue; @@ -11434,14 +11813,14 @@ export class TeamProvisioningService { if (!memberName) continue; if (!teamName) { logger.warn( - `[captureTeamSpawnEvents] Agent call for "${memberName}" is missing team_name — ` + + `[captureTeamSpawnEvents] Agent call for "${memberName}" is missing team_name - ` + `teammate will be an ephemeral subagent, not a persistent member of "${run.teamName}"` ); this.setMemberSpawnStatus( run, memberName, 'error', - `Agent spawn for "${memberName}" is missing team_name — spawned as ephemeral subagent instead of persistent teammate` + `Agent spawn for "${memberName}" is missing team_name - spawned as ephemeral subagent instead of persistent teammate` ); continue; } @@ -11456,7 +11835,7 @@ export class TeamProvisioningService { this.appendMemberBootstrapDiagnostic( run, memberName, - 'respawn blocked as duplicate — teammate already online' + 'respawn blocked as duplicate - teammate already online' ); continue; } @@ -12737,6 +13116,51 @@ export class TeamProvisioningService { }); } + private selectLatestLeadInboxLaunchReconcileMessage( + messages: readonly LeadInboxLaunchReconcileMessage[], + expectedMembers: readonly string[], + expected: string, + firstSpawnAcceptedAt?: string + ): LeadInboxLaunchReconcileMessage | null { + const firstAcceptedAt = firstSpawnAcceptedAt ? Date.parse(firstSpawnAcceptedAt) : NaN; + const candidates = messages.filter((message) => { + if ( + typeof message.from !== 'string' || + this.resolveExpectedLaunchMemberName(expectedMembers, message.from) !== expected + ) { + return false; + } + if (typeof message.text !== 'string' || !isMeaningfulBootstrapCheckInMessage(message.text)) { + return false; + } + const messageTs = Date.parse(message.timestamp); + if ( + Number.isFinite(firstAcceptedAt) && + Number.isFinite(messageTs) && + messageTs < firstAcceptedAt + ) { + return false; + } + return true; + }); + + return ( + candidates.sort((left, right) => { + const leftMs = Date.parse(left.timestamp); + const rightMs = Date.parse(right.timestamp); + const leftValid = Number.isFinite(leftMs); + const rightValid = Number.isFinite(rightMs); + if (leftValid && rightValid && leftMs !== rightMs) { + return rightMs - leftMs; + } + if (leftValid !== rightValid) { + return leftValid ? -1 : 1; + } + return (right.messageId ?? '').localeCompare(left.messageId ?? ''); + })[0] ?? null + ); + } + private shouldRecoverStalePersistedMixedLaunchSnapshot( snapshot: PersistedTeamLaunchSnapshot ): boolean { @@ -13346,7 +13770,14 @@ export class TeamProvisioningService { return typeof row.from === 'string' && typeof row.text === 'string' && typeof row.timestamp === 'string' - ? [{ from: row.from, text: row.text, timestamp: row.timestamp }] + ? [ + { + from: row.from, + text: row.text, + timestamp: row.timestamp, + messageId: row.messageId, + }, + ] : []; }); } catch { @@ -13354,6 +13785,29 @@ export class TeamProvisioningService { } } + private async hasBootstrapTranscriptLaunchReconcileOutcome( + snapshot: PersistedTeamLaunchSnapshot + ): Promise { + const expectedMembers = this.getPersistedLaunchMemberNames(snapshot); + for (const expected of expectedMembers) { + const current = snapshot.members[expected]; + if (!current || current.bootstrapConfirmed || current.hardFailure) { + continue; + } + const acceptedAtMs = + current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const transcriptOutcome = await this.findBootstrapTranscriptOutcome( + snapshot.teamName, + expected, + Number.isFinite(acceptedAtMs) ? acceptedAtMs : null + ); + if (transcriptOutcome) { + return true; + } + } + return false; + } + private async reconcilePersistedLaunchState(teamName: string): Promise<{ snapshot: ReturnType | null; statuses: Record; @@ -13366,22 +13820,24 @@ export class TeamProvisioningService { bootstrapSnapshot, persisted ); - if (recoveredMixedSnapshot) { - const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot( - recoveredMixedSnapshot, - metaMembers - ); + const filteredRecoveredMixedSnapshot = recoveredMixedSnapshot + ? this.filterRemovedMembersFromLaunchSnapshot(recoveredMixedSnapshot, metaMembers) + : null; + if ( + filteredRecoveredMixedSnapshot && + !(await this.hasBootstrapTranscriptLaunchReconcileOutcome(filteredRecoveredMixedSnapshot)) + ) { return { - snapshot: filteredSnapshot, - statuses: snapshotToMemberSpawnStatuses(filteredSnapshot), + snapshot: filteredRecoveredMixedSnapshot, + statuses: snapshotToMemberSpawnStatuses(filteredRecoveredMixedSnapshot), }; } const filteredBootstrapSnapshot = bootstrapSnapshot ? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers) : null; - const filteredPersisted = persisted - ? this.filterRemovedMembersFromLaunchSnapshot(persisted, metaMembers) - : null; + const filteredPersisted = + filteredRecoveredMixedSnapshot ?? + (persisted ? this.filterRemovedMembersFromLaunchSnapshot(persisted, metaMembers) : null); const preferredSnapshot = choosePreferredLaunchSnapshot( filteredBootstrapSnapshot, filteredPersisted @@ -13426,7 +13882,8 @@ export class TeamProvisioningService { if ( this.hasPrimaryOnlyLaneAwareLaunchMetadata(filteredPersisted) && - !this.hasLeadInboxLaunchReconcileHeartbeat(filteredPersisted, leadInboxMessages) + !this.hasLeadInboxLaunchReconcileHeartbeat(filteredPersisted, leadInboxMessages) && + !(await this.hasBootstrapTranscriptLaunchReconcileOutcome(filteredPersisted)) ) { return { snapshot: filteredPersisted, @@ -13470,32 +13927,12 @@ export class TeamProvisioningService { matchesObservedMemberNameForExpected(name, expected) ); const runtimeAlive = current.runtimeAlive === true || observedRuntimeAlive; - const heartbeatMessage = leadInboxMessages.find((message) => { - if ( - typeof message.from !== 'string' || - this.resolveExpectedLaunchMemberName(persistedMemberNames, message.from) !== expected - ) { - return false; - } - if ( - typeof message.text !== 'string' || - !isMeaningfulBootstrapCheckInMessage(message.text) - ) { - return false; - } - const firstAcceptedAt = current.firstSpawnAcceptedAt - ? Date.parse(current.firstSpawnAcceptedAt) - : NaN; - const messageTs = Date.parse(message.timestamp); - if ( - Number.isFinite(firstAcceptedAt) && - Number.isFinite(messageTs) && - messageTs < firstAcceptedAt - ) { - return false; - } - return true; - }); + const heartbeatMessage = this.selectLatestLeadInboxLaunchReconcileMessage( + leadInboxMessages, + persistedMemberNames, + expected, + current.firstSpawnAcceptedAt + ); const heartbeatReason = heartbeatMessage ? extractBootstrapFailureReason(heartbeatMessage.text) : null; @@ -13614,60 +14051,55 @@ export class TeamProvisioningService { teamName: string, memberName: string, sinceMs: number | null - ): Promise< - | { - kind: 'success'; - observedAt: string; - } - | { - kind: 'failure'; - observedAt: string; - reason: string; - } - | null - > { + ): Promise { let summaries: Awaited>; try { summaries = await this.memberLogsFinder.findMemberLogs(teamName, memberName, sinceMs); } catch { - return null; + summaries = []; } + const outcomes: BootstrapTranscriptOutcome[] = []; for (const summary of summaries) { if (!summary.filePath) continue; const outcome = await this.readRecentBootstrapTranscriptOutcome( summary.filePath, sinceMs, memberName, - teamName + teamName, + { allowAnonymousFailure: true } ); if (outcome) { - return outcome; + outcomes.push(outcome); } } - return this.findBootstrapTranscriptOutcomeInProjectRoot(teamName, memberName, sinceMs); + outcomes.push( + ...(await this.readBootstrapTranscriptOutcomesInProjectRoot(teamName, memberName, sinceMs)) + ); + + return this.selectLatestBootstrapTranscriptOutcome(outcomes); } private async readRecentBootstrapTranscriptOutcome( filePath: string, sinceMs: number | null, memberName: string, - teamName: string - ): Promise< - | { - kind: 'success'; - observedAt: string; - } - | { - kind: 'failure'; - observedAt: string; - reason: string; - } - | null - > { + teamName: string, + options: { + allowAnonymousFailure?: boolean; + contextMemberNames?: readonly string[]; + } = {} + ): Promise { let handle: fs.promises.FileHandle | null = null; const normalizedMemberName = memberName.trim().toLowerCase(); + const contextMemberNames = Array.from( + new Set( + [memberName, ...(options.contextMemberNames ?? [])] + .map((name) => name.trim()) + .filter(Boolean) + ) + ); try { handle = await fs.promises.open(filePath, 'r'); const stat = await handle.stat(); @@ -13684,6 +14116,43 @@ export class TeamProvisioningService { if (start > 0) { lines.shift(); } + const bootstrapContextMembers = new Set(); + for (const rawLine of lines) { + const line = rawLine?.trim(); + if (!line) continue; + let parsed: { timestamp?: unknown } | null = null; + try { + parsed = JSON.parse(line) as { timestamp?: unknown }; + } catch { + continue; + } + const timestampMs = + typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; + if (sinceMs != null && (!Number.isFinite(timestampMs) || timestampMs < sinceMs)) { + continue; + } + const parsedAgentName = + typeof (parsed as { agentName?: unknown }).agentName === 'string' + ? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null + : null; + if ( + parsedAgentName && + !matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName) + ) { + continue; + } + const text = extractTranscriptMessageText(parsed); + if (!text) { + continue; + } + for (const contextMemberName of contextMemberNames) { + if (isBootstrapTranscriptContextText(text, teamName, contextMemberName)) { + bootstrapContextMembers.add(contextMemberName.trim().toLowerCase()); + } + } + } + const hasUnambiguousMatchingBootstrapContext = + bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(normalizedMemberName); for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index]?.trim(); if (!line) continue; @@ -13695,14 +14164,19 @@ export class TeamProvisioningService { } const timestampMs = typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; - if (sinceMs != null && Number.isFinite(timestampMs) && timestampMs < sinceMs) { - continue; + if (sinceMs != null) { + if (!Number.isFinite(timestampMs) || timestampMs < sinceMs) { + continue; + } } const parsedAgentName = typeof (parsed as { agentName?: unknown }).agentName === 'string' ? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null : null; - if (parsedAgentName && parsedAgentName !== normalizedMemberName) { + if ( + parsedAgentName && + !matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName) + ) { continue; } const text = extractTranscriptMessageText(parsed); @@ -13713,6 +14187,13 @@ export class TeamProvisioningService { : new Date().toISOString(); const reason = extractBootstrapFailureReason(text); if (reason) { + if ( + !parsedAgentName && + options.allowAnonymousFailure !== true && + !hasUnambiguousMatchingBootstrapContext + ) { + continue; + } return { kind: 'failure', observedAt, reason }; } if (isBootstrapTranscriptSuccessText(text, teamName, memberName)) { @@ -13728,31 +14209,20 @@ export class TeamProvisioningService { return null; } - private async findBootstrapTranscriptOutcomeInProjectRoot( + private async readBootstrapTranscriptOutcomesInProjectRoot( teamName: string, memberName: string, sinceMs: number | null - ): Promise< - | { - kind: 'success'; - observedAt: string; - } - | { - kind: 'failure'; - observedAt: string; - reason: string; - } - | null - > { + ): Promise { let config: Awaited>; try { config = await this.configReader.getConfig(teamName); } catch { - return null; + return []; } const projectPath = config?.projectPath?.trim(); if (!projectPath) { - return null; + return []; } const projectDir = path.join(getProjectsBasePath(), extractBaseDir(encodePath(projectPath))); @@ -13760,12 +14230,19 @@ export class TeamProvisioningService { try { entries = await fs.promises.readdir(projectDir, { withFileTypes: true }); } catch { - return null; + return []; } + const outcomes: BootstrapTranscriptOutcome[] = []; const jsonlFiles = entries .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) .sort((left, right) => right.name.localeCompare(left.name)); + const contextMemberNames = [ + memberName, + ...((config?.members ?? []) + .map((member) => member.name?.trim()) + .filter((name): name is string => Boolean(name)) ?? []), + ]; for (const entry of jsonlFiles) { if (config?.leadSessionId && entry.name === `${config.leadSessionId}.jsonl`) { continue; @@ -13774,14 +14251,35 @@ export class TeamProvisioningService { path.join(projectDir, entry.name), sinceMs, memberName, - teamName + teamName, + { contextMemberNames } ); if (outcome) { - return outcome; + outcomes.push(outcome); } } - return null; + return outcomes; + } + + private selectLatestBootstrapTranscriptOutcome( + outcomes: readonly BootstrapTranscriptOutcome[] + ): BootstrapTranscriptOutcome | null { + return ( + [...outcomes].sort((left, right) => { + const leftMs = Date.parse(left.observedAt); + const rightMs = Date.parse(right.observedAt); + const leftValid = Number.isFinite(leftMs); + const rightValid = Number.isFinite(rightMs); + if (leftValid && rightValid && leftMs !== rightMs) { + return rightMs - leftMs; + } + if (leftValid !== rightValid) { + return leftValid ? -1 : 1; + } + return 0; + })[0] ?? null + ); } private captureSendMessages(run: ProvisioningRun, content: Record[]): void { @@ -14156,7 +14654,7 @@ export class TeamProvisioningService { * Stop the running process for a team. No-op if team is not running. * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. */ - stopTeam(teamName: string): void { + async stopTeam(teamName: string): Promise { this.agentRuntimeSnapshotCache.delete(teamName); this.liveTeamAgentRuntimeMetadataCache.delete(teamName); this.stopPersistentTeamMembers(teamName); @@ -14164,32 +14662,45 @@ export class TeamProvisioningService { const runId = this.getTrackedRunId(teamName); if (!runId) { if (this.hasSecondaryRuntimeRuns(teamName)) { - void this.stopMixedSecondaryRuntimeLanes(teamName); + await this.stopMixedSecondaryRuntimeLanes(teamName); } return; } const run = this.runs.get(runId); if (!run) { + const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId); + if (runtimeProgress && this.isCancellableRuntimeAdapterProgress(runtimeProgress)) { + await this.cancelRuntimeAdapterProvisioning(runId, runtimeProgress); + return; + } const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); if (runtimeRun?.runId === runId && runtimeRun.providerId === 'opencode') { - void this.stopOpenCodeRuntimeAdapterTeam(teamName, runId); + await this.withTeamLock(teamName, async () => { + const currentRuntimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (currentRuntimeRun?.runId === runId && currentRuntimeRun.providerId === 'opencode') { + await this.stopOpenCodeRuntimeAdapterTeam(teamName, runId); + } + }); return; } if (this.hasSecondaryRuntimeRuns(teamName)) { - void this.stopMixedSecondaryRuntimeLanes(teamName); + await this.stopMixedSecondaryRuntimeLanes(teamName); } this.provisioningRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); return; } if (run.processKilled || run.cancelRequested) { + if (this.hasSecondaryRuntimeRuns(teamName)) { + await this.stopMixedSecondaryRuntimeLanes(teamName); + } return; } run.processKilled = true; run.cancelRequested = true; killTeamProcess(run.child); if (this.hasSecondaryRuntimeRuns(teamName)) { - void this.stopMixedSecondaryRuntimeLanes(teamName); + await this.stopMixedSecondaryRuntimeLanes(teamName); } const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); run.onProgress(progress); @@ -14197,56 +14708,166 @@ export class TeamProvisioningService { logger.info(`[${teamName}] Process stopped (SIGKILL)`); } + private getShutdownTrackedTeamNames(): string[] { + const teamNames = new Set(); + for (const teamName of this.provisioningRunByTeam.keys()) teamNames.add(teamName); + for (const teamName of this.aliveRunByTeam.keys()) teamNames.add(teamName); + for (const teamName of this.runtimeAdapterRunByTeam.keys()) teamNames.add(teamName); + for (const teamName of this.secondaryRuntimeRunByTeam.keys()) teamNames.add(teamName); + for (const teamName of this.teamOpLocks.keys()) teamNames.add(teamName); + for (const progress of this.getPendingRuntimeAdapterLaunchesForShutdown()) { + teamNames.add(progress.teamName); + } + return Array.from(teamNames); + } + + private async stopTrackedTeamsForShutdown(label: string): Promise { + const teamNames = this.getShutdownTrackedTeamNames(); + if (teamNames.length === 0) { + return teamNames; + } + + logger.info(`${label}: stopping tracked team processes: ${teamNames.join(', ')}`); + await Promise.all( + teamNames.map((teamName) => + this.stopTeam(teamName).catch((error) => { + logger.warn( + `[${teamName}] Failed to stop team during shutdown: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }) + ) + ); + return teamNames; + } + + private async cancelPendingRuntimeAdapterLaunchesForShutdown(): Promise { + const pendingRuntimeLaunches = this.getPendingRuntimeAdapterLaunchesForShutdown(); + if (pendingRuntimeLaunches.length === 0) { + return; + } + + logger.info( + `Cancelling pending OpenCode runtime adapter launches on shutdown: ${pendingRuntimeLaunches + .map((progress) => progress.teamName) + .join(', ')}` + ); + await Promise.all( + pendingRuntimeLaunches.map((progress) => + this.cancelRuntimeAdapterProvisioning(progress.runId, progress).catch((error) => { + logger.warn( + `[${progress.teamName}] Failed to cancel pending OpenCode runtime adapter launch on shutdown: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }) + ) + ); + } + + private async waitForInFlightTeamOperationsForShutdown(timeoutMs = 2_000): Promise { + const locks = Array.from(this.teamOpLocks.values()); + if (locks.length === 0) { + return; + } + + let timedOut = false; + let timeout: ReturnType | null = null; + await Promise.race([ + Promise.allSettled(locks).then(() => undefined), + new Promise((resolve) => { + timeout = setTimeout(() => { + timedOut = true; + resolve(); + }, timeoutMs); + timeout.unref?.(); + }), + ]); + if (timeout) { + clearTimeout(timeout); + } + if (timedOut) { + logger.warn( + `Timed out after ${timeoutMs}ms waiting for in-flight team operations during shutdown` + ); + } + } + + private killTransientProbeProcessesForShutdown(): void { + for (const child of Array.from(this.transientProbeProcesses)) { + try { + killProcessTree(child); + } catch (error) { + logger.debug( + `Failed to kill transient probe process during shutdown: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + } + private async stopMixedSecondaryRuntimeLanes(teamName: string): Promise { const secondaryRuns = this.getSecondaryRuntimeRuns(teamName); if (secondaryRuns.length === 0) { return; } - const adapter = this.getOpenCodeRuntimeAdapter(); - const previousLaunchState = await this.launchStateStore.read(teamName); - if (!adapter) { - await Promise.all( - secondaryRuns.map((secondaryRun) => - clearOpenCodeRuntimeLaneStorage({ - teamsBasePath: getTeamsBasePath(), - teamName, - laneId: secondaryRun.laneId, - }).catch(() => undefined) - ) - ); - this.clearSecondaryRuntimeRuns(teamName); - return; - } + this.stoppingSecondaryRuntimeTeams.add(teamName); try { - for (const secondaryRun of secondaryRuns) { - try { - await adapter.stop({ - runId: secondaryRun.runId, - laneId: secondaryRun.laneId, - teamName, - cwd: secondaryRun.cwd ?? this.readPersistedTeamProjectPath(teamName) ?? undefined, - providerId: 'opencode', - reason: 'user_requested', - previousLaunchState, - force: true, - }); - } catch (error) { - logger.warn( - `[${teamName}] Failed to stop mixed OpenCode secondary lane ${secondaryRun.laneId}: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } finally { + const adapter = this.getOpenCodeRuntimeAdapter(); + const previousLaunchState = await this.launchStateStore.read(teamName); + if (!adapter) { + await Promise.all( + secondaryRuns.map((secondaryRun) => + clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: secondaryRun.laneId, + }).catch(() => undefined) + ) + ); + this.clearSecondaryRuntimeRuns(teamName); + return; + } + try { + for (const secondaryRun of secondaryRuns) { await clearOpenCodeRuntimeLaneStorage({ teamsBasePath: getTeamsBasePath(), teamName, laneId: secondaryRun.laneId, }).catch(() => undefined); - this.deleteSecondaryRuntimeRun(teamName, secondaryRun.laneId); + try { + await adapter.stop({ + runId: secondaryRun.runId, + laneId: secondaryRun.laneId, + teamName, + cwd: secondaryRun.cwd ?? this.readPersistedTeamProjectPath(teamName) ?? undefined, + providerId: 'opencode', + reason: 'user_requested', + previousLaunchState, + force: true, + }); + } catch (error) { + logger.warn( + `[${teamName}] Failed to stop mixed OpenCode secondary lane ${secondaryRun.laneId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } finally { + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: secondaryRun.laneId, + }).catch(() => undefined); + this.deleteSecondaryRuntimeRun(teamName, secondaryRun.laneId); + } } + } finally { + this.clearSecondaryRuntimeRuns(teamName); } } finally { - this.clearSecondaryRuntimeRuns(teamName); + this.stoppingSecondaryRuntimeTeams.delete(teamName); } } @@ -14266,6 +14887,7 @@ export class TeamProvisioningService { } const startedAt = nowIso(); const previousProgress = this.runtimeAdapterProgressByRunId.get(runId); + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); this.setRuntimeAdapterProgress({ runId, teamName, @@ -14274,8 +14896,17 @@ export class TeamProvisioningService { startedAt: previousProgress?.startedAt ?? startedAt, updatedAt: startedAt, }); + this.runtimeAdapterRunByTeam.delete(teamName); + this.aliveRunByTeam.delete(teamName); + if (this.provisioningRunByTeam.get(teamName) === runId) { + this.provisioningRunByTeam.delete(teamName); + } try { - const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'primary', + }).catch(() => undefined); const result = await adapter.stop({ runId, laneId: 'primary', @@ -14461,17 +15092,24 @@ export class TeamProvisioningService { * Uses killTeamProcess() (SIGKILL) to guarantee instant death * without CLI cleanup that would delete team files. */ - stopAllTeams(): void { - const alive = this.getAliveTeams(); - if (alive.length > 0) { - logger.info(`Killing all team processes on shutdown (SIGKILL): ${alive.join(', ')}`); - for (const teamName of alive) { - this.stopTeam(teamName); - } - } + async stopAllTeams(): Promise { + this.stopAllTeamsGeneration += 1; + killTrackedCliProcesses('SIGKILL'); + this.killTransientProbeProcessesForShutdown(); + + await this.cancelPendingRuntimeAdapterLaunchesForShutdown(); + const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown'); + + // A create/launch may have been inside a per-team lock before it exposed a + // run in provisioningRunByTeam. Wait briefly, then rescan to catch anything + // that became visible while shutdown was already in progress. + await this.waitForInFlightTeamOperationsForShutdown(); + await this.cancelPendingRuntimeAdapterLaunchesForShutdown(); + await this.stopTrackedTeamsForShutdown('Shutdown follow-up'); const persistedTeamNames = this.listPersistedTeamNames(); - const orphanOnly = persistedTeamNames.filter((teamName) => !alive.includes(teamName)); + const tracked = new Set([...initialTracked, ...this.getShutdownTrackedTeamNames()]); + const orphanOnly = persistedTeamNames.filter((teamName) => !tracked.has(teamName)); if (orphanOnly.length > 0) { logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`); for (const teamName of orphanOnly) { @@ -19137,7 +19775,10 @@ export class TeamProvisioningService { _claudePath: string, cwd: string, env: NodeJS.ProcessEnv, - mcpConfigPath: string + mcpConfigPath: string, + options: { + isCancelled?: () => boolean; + } = {} ): Promise { const launchSpec = await this.readAgentTeamsMcpLaunchSpec(mcpConfigPath); const fixture = await this.createAgentTeamsMcpValidationFixture(cwd); @@ -19145,6 +19786,9 @@ export class TeamProvisioningService { let stdoutBuffer = ''; let stderrBuffer = ''; let nextRequestId = 1; + let cancellationTriggered = false; + let cancellationTimer: ReturnType | null = null; + const cancellationMessage = 'agent-teams MCP preflight cancelled by app shutdown'; const pending = new Map< number, { @@ -19162,13 +19806,46 @@ export class TeamProvisioningService { } }; + const getCancellationError = (): Error => new Error(cancellationMessage); + const cancelPreflightIfNeeded = (): boolean => { + if (cancellationTriggered) { + return true; + } + if (!options.isCancelled?.()) { + return false; + } + cancellationTriggered = true; + const error = getCancellationError(); + rejectAll(error); + if (child?.pid) { + killProcessTree(child); + } + return true; + }; + const throwIfCancelled = (): void => { + if (cancelPreflightIfNeeded()) { + throw getCancellationError(); + } + }; + try { + throwIfCancelled(); child = spawnCli(launchSpec.command, launchSpec.args, { cwd: launchSpec.cwd ?? cwd, env: { ...env, ...launchSpec.env }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); + this.transientProbeProcesses.add(child); + if (options.isCancelled) { + cancellationTimer = setInterval(() => { + if (cancelPreflightIfNeeded() && cancellationTimer) { + clearInterval(cancellationTimer); + cancellationTimer = null; + } + }, 100); + cancellationTimer.unref?.(); + } const parseStdoutLine = (line: string): void => { let message: McpJsonRpcResponse; @@ -19250,6 +19927,10 @@ export class TeamProvisioningService { timeoutMs: number = VERIFY_TIMEOUT_MS ): Promise => new Promise((resolve, reject) => { + if (cancelPreflightIfNeeded()) { + reject(getCancellationError()); + return; + } if (!child?.stdin) { reject(new Error('agent-teams MCP stdin is not available')); return; @@ -19267,6 +19948,13 @@ export class TeamProvisioningService { timeoutHandle, }); + if (cancelPreflightIfNeeded()) { + clearTimeout(timeoutHandle); + pending.delete(id); + reject(getCancellationError()); + return; + } + child.stdin.write( `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, (error) => { @@ -19305,13 +19993,15 @@ export class TeamProvisioningService { { protocolVersion: '2024-11-05', capabilities: {}, - clientInfo: { name: 'claude-agent-teams-ui', version: '1.0.0' }, + clientInfo: { name: 'agent-teams-ai', version: '1.0.0' }, }, MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS ); + throwIfCancelled(); await notify('notifications/initialized'); const toolsList = await request('tools/list', {}); + throwIfCancelled(); const memberBriefingTool = (toolsList.tools ?? []).find( (tool) => tool.name === 'member_briefing' ); @@ -19333,6 +20023,7 @@ export class TeamProvisioningService { memberName: fixture.memberName, }, }); + throwIfCancelled(); if (memberBriefing.isError) { throw new Error( @@ -19353,6 +20044,7 @@ export class TeamProvisioningService { teamName: fixture.teamName, }, }); + throwIfCancelled(); if (leadBriefing.isError) { throw new Error( @@ -19367,6 +20059,9 @@ export class TeamProvisioningService { throw new Error('agent-teams MCP returned empty content for lead_briefing'); } } catch (error) { + if (error instanceof Error && error.message === cancellationMessage) { + throw error; + } const detail = buildCombinedLogs('', stderrBuffer).trim(); const errorText = error instanceof Error && detail.length > 0 @@ -19374,7 +20069,14 @@ export class TeamProvisioningService { : detail || String(error); throw new Error(this.buildAgentTeamsMcpValidationError(errorText)); } finally { + if (cancellationTimer) { + clearInterval(cancellationTimer); + cancellationTimer = null; + } rejectAll(new Error('agent-teams MCP preflight session closed')); + if (child) { + this.transientProbeProcesses.delete(child); + } if (child?.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) { const stdin = child.stdin; await new Promise((resolve) => { @@ -19421,15 +20123,21 @@ export class TeamProvisioningService { env, stdio: ['ignore', 'pipe', 'pipe'], }); + this.transientProbeProcesses.add(child); + const cleanupProbe = (): void => { + this.transientProbeProcesses.delete(child); + }; let stdoutText = ''; let stderrText = ''; let settled = false; const timeoutHandle = setTimeout(() => { settled = true; + cleanupProbe(); killProcessTree(child); reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`)); }, timeoutMs); + timeoutHandle.unref?.(); const maybeResolveEarly = (): void => { if (settled) return; @@ -19439,6 +20147,7 @@ export class TeamProvisioningService { settled = true; clearTimeout(timeoutHandle); + cleanupProbe(); // If the process printed the match but hangs during teardown, don't // block the UI; terminate best-effort and resolve. killProcessTree(child); @@ -19457,12 +20166,14 @@ export class TeamProvisioningService { if (settled) return; settled = true; clearTimeout(timeoutHandle); + cleanupProbe(); reject(error); }); child.once('close', (exitCode) => { if (settled) return; settled = true; clearTimeout(timeoutHandle); + cleanupProbe(); resolve({ exitCode, stdout: stdoutText.trim(), diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 5db62fbc..8c6d8b04 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -14,15 +14,13 @@ export { FileContentResolver } from './FileContentResolver'; export { GitDiffFallback } from './GitDiffFallback'; export { HunkSnippetMatcher } from './HunkSnippetMatcher'; export { MemberStatsComputer } from './MemberStatsComputer'; -export { ReviewApplierService } from './ReviewApplierService'; -export { TaskBoundaryParser } from './TaskBoundaryParser'; -export { - isTeamRuntimeProviderId, - OpenCodeTeamRuntimeAdapter, - TeamRuntimeAdapterRegistry, - TEAM_RUNTIME_PROVIDER_IDS, -} from './runtime'; +export type { + OpenCodeReadinessBridgeCommandBody, + OpenCodeReadinessBridgeCommandExecutor, + OpenCodeReadinessBridgeOptions, +} from './opencode/bridge/OpenCodeReadinessBridge'; export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge'; +export { ReviewApplierService } from './ReviewApplierService'; export type { OpenCodeTeamLaunchMode, OpenCodeTeamRuntimeAdapterOptions, @@ -44,18 +42,28 @@ export type { TeamRuntimeStopReason, TeamRuntimeStopResult, } from './runtime'; -export type { - OpenCodeReadinessBridgeCommandBody, - OpenCodeReadinessBridgeCommandExecutor, - OpenCodeReadinessBridgeOptions, -} from './opencode/bridge/OpenCodeReadinessBridge'; +export { + isTeamRuntimeProviderId, + OpenCodeTeamRuntimeAdapter, + TEAM_RUNTIME_PROVIDER_IDS, + TeamRuntimeAdapterRegistry, +} from './runtime'; +export { ActiveTeamRegistry } from './stallMonitor/ActiveTeamRegistry'; +export { BoardTaskActivityBatchIndexer } from './stallMonitor/BoardTaskActivityBatchIndexer'; +export { TeamTaskLogFreshnessReader } from './stallMonitor/TeamTaskLogFreshnessReader'; +export { TeamTaskStallExactRowReader } from './stallMonitor/TeamTaskStallExactRowReader'; +export { TeamTaskStallJournal } from './stallMonitor/TeamTaskStallJournal'; +export { TeamTaskStallMonitor } from './stallMonitor/TeamTaskStallMonitor'; +export { TeamTaskStallNotifier } from './stallMonitor/TeamTaskStallNotifier'; +export { TeamTaskStallPolicy } from './stallMonitor/TeamTaskStallPolicy'; +export { TeamTaskStallSnapshotSource } from './stallMonitor/TeamTaskStallSnapshotSource'; +export { TaskBoundaryParser } from './TaskBoundaryParser'; export { BoardTaskActivityDetailService } from './taskLogs/activity/BoardTaskActivityDetailService'; export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; -export { OpenCodeTaskLogAttributionService } from './taskLogs/stream/OpenCodeTaskLogAttributionService'; export type { OpenCodeTaskLogAttributionBulkWriteOutcome, OpenCodeTaskLogAttributionMemberWindowInput, @@ -66,10 +74,7 @@ export type { OpenCodeTaskLogAttributionTaskSessionInput, OpenCodeTaskLogAttributionWriter, } from './taskLogs/stream/OpenCodeTaskLogAttributionService'; -export { - OpenCodeTaskLogAttributionStore, - getOpenCodeTaskLogAttributionPath, -} from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; +export { OpenCodeTaskLogAttributionService } from './taskLogs/stream/OpenCodeTaskLogAttributionService'; export type { OpenCodeTaskLogAttributionReader, OpenCodeTaskLogAttributionRecord, @@ -77,6 +82,10 @@ export type { OpenCodeTaskLogAttributionSource, OpenCodeTaskLogAttributionWriteResult, } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; +export { + getOpenCodeTaskLogAttributionPath, + OpenCodeTaskLogAttributionStore, +} from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamBackupService } from './TeamBackupService'; export { TeamConfigReader } from './TeamConfigReader'; @@ -94,12 +103,3 @@ export { TeamSentMessagesStore } from './TeamSentMessagesStore'; export { TeamTaskReader } from './TeamTaskReader'; export { TeamTaskWriter } from './TeamTaskWriter'; export { countLineChanges } from './UnifiedLineCounter'; -export { ActiveTeamRegistry } from './stallMonitor/ActiveTeamRegistry'; -export { BoardTaskActivityBatchIndexer } from './stallMonitor/BoardTaskActivityBatchIndexer'; -export { TeamTaskLogFreshnessReader } from './stallMonitor/TeamTaskLogFreshnessReader'; -export { TeamTaskStallExactRowReader } from './stallMonitor/TeamTaskStallExactRowReader'; -export { TeamTaskStallJournal } from './stallMonitor/TeamTaskStallJournal'; -export { TeamTaskStallMonitor } from './stallMonitor/TeamTaskStallMonitor'; -export { TeamTaskStallNotifier } from './stallMonitor/TeamTaskStallNotifier'; -export { TeamTaskStallPolicy } from './stallMonitor/TeamTaskStallPolicy'; -export { TeamTaskStallSnapshotSource } from './stallMonitor/TeamTaskStallSnapshotSource'; diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 7d5c42f6..39a0b95d 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -68,7 +68,7 @@ export interface OpenCodeTeamMemberLaunchCommandData { diagnostics?: string[]; model: string; runtimePid?: number; - evidence: Array<{ kind: string; observedAt: string }>; + evidence: { kind: string; observedAt: string }[]; } export interface OpenCodeLaunchTeamCommandData { @@ -80,7 +80,7 @@ export interface OpenCodeLaunchTeamCommandData { idempotencyKey?: string; manifestHighWatermark?: number | null; runtimeStoreManifestHighWatermark?: number | null; - durableCheckpoints?: Array<{ name: string; memberName?: string | null; observedAt: string }>; + durableCheckpoints?: { name: string; memberName?: string | null; observedAt: string }[]; } export interface OpenCodeReconcileTeamCommandBody { @@ -92,7 +92,7 @@ export interface OpenCodeReconcileTeamCommandBody { expectedCapabilitySnapshotId?: string | null; manifestHighWatermark?: number | null; reconcileAttemptId?: string; - expectedMembers: Array<{ name: string; model: string | null }>; + expectedMembers: { name: string; model: string | null }[]; reason: string; } diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts index d2cc80db..10a0beb5 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore.ts @@ -1,10 +1,11 @@ +import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; + import { createOpenCodeBridgeIdempotencyKey, isOpenCodeBridgeCommandName, - stableHash, type OpenCodeBridgeCommandName, + stableHash, } from './OpenCodeBridgeCommandContract'; -import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; export const OPEN_CODE_BRIDGE_COMMAND_LEDGER_SCHEMA_VERSION = 1; export const OPEN_CODE_BRIDGE_COMMAND_LEASE_SCHEMA_VERSION = 1; diff --git a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts index 6501e07b..9c1ae0e3 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts @@ -4,8 +4,6 @@ import { assertBridgeEvidenceCanCommitToRuntimeStores, createOpenCodeBridgeIdempotencyKey, extractRunId, - stableHash, - validateOpenCodeBridgeHandshake, type OpenCodeBridgeCommandName, type OpenCodeBridgeCommandPreconditions, type OpenCodeBridgeDiagnosticEvent, @@ -13,10 +11,13 @@ import { type OpenCodeBridgePeerIdentity, type OpenCodeBridgeResult, type RuntimeStoreManifestEvidence, + stableHash, + validateOpenCodeBridgeHandshake, } from './OpenCodeBridgeCommandContract'; -import { - OpenCodeBridgeCommandLedger, + +import type { OpenCodeBridgeCommandLeaseStore, + OpenCodeBridgeCommandLedger, } from './OpenCodeBridgeCommandLedgerStore'; export interface OpenCodeBridgeCommandExecutor { diff --git a/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts b/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts index 9315aa3f..2480db21 100644 --- a/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts +++ b/src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities.ts @@ -370,9 +370,10 @@ async function runDirectSafeProbes(input: { evidence: Record; diagnostics: string[]; }): Promise { - for (const [key, probe] of Object.entries(DIRECT_SAFE_PROBES) as Array< - [OpenCodeApiEndpointKey, DirectSafeProbe] - >) { + for (const [key, probe] of Object.entries(DIRECT_SAFE_PROBES) as [ + OpenCodeApiEndpointKey, + DirectSafeProbe, + ][]) { if (input.endpoints[key]) { continue; } diff --git a/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts b/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts index 583241da..f0b25850 100644 --- a/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts +++ b/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts @@ -7,10 +7,11 @@ import { type RuntimeDeliveryDestinationRef, type RuntimeDeliveryEnvelope, type RuntimeDeliveryJournalRecord, - RuntimeDeliveryJournalStore, type RuntimeDeliveryLocation, } from './RuntimeDeliveryJournal'; +import type { RuntimeDeliveryJournalStore } from './RuntimeDeliveryJournal'; + export interface RuntimeDeliveryVerifyResult { found: boolean; location: RuntimeDeliveryLocation | null; diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts index d844f5af..03b934e8 100644 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore.ts @@ -249,5 +249,5 @@ function pickNewestEvidence( return entry; } return latest; - }, entries[0]!); + }, entries[0]); } diff --git a/src/main/services/team/opencode/permissions/RuntimePermission.ts b/src/main/services/team/opencode/permissions/RuntimePermission.ts index 1186101b..a46ac23d 100644 --- a/src/main/services/team/opencode/permissions/RuntimePermission.ts +++ b/src/main/services/team/opencode/permissions/RuntimePermission.ts @@ -4,7 +4,7 @@ export const RUNTIME_PERMISSION_REQUEST_SCHEMA_VERSION = 1; export type OpenCodePermissionDecision = 'once' | 'always' | 'reject'; -export type OpenCodeRawPermissionRequest = { +export interface OpenCodeRawPermissionRequest { id?: unknown; requestID?: unknown; sessionID?: unknown; @@ -15,7 +15,7 @@ export type OpenCodeRawPermissionRequest = { tool?: unknown; title?: unknown; kind?: unknown; -}; +} export interface OpenCodeNormalizedPermissionRequest { requestId: string; @@ -375,8 +375,8 @@ export class RuntimePermissionRequestStore { teamName: string; visibleProviderRequestIds: Set; now: string; - }): Promise>> { - const expired: Array> = []; + }): Promise[]> { + const expired: Pick[] = []; await this.store.updateLocked((records) => records.map((record) => { if ( @@ -592,7 +592,7 @@ export class RuntimePermissionReconciler { for (const permission of pending) { visibleProviderRequestIds.add(permission.requestId); const session = input.sessionsByOpenCodeId.get(permission.sessionId); - if (!session || session.runId !== input.runId) { + if (session?.runId !== input.runId) { await this.diagnostics.append({ type: 'opencode_permission_unmatched_session', providerId: 'opencode', diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index 8428c813..a7f8ebb9 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -1,14 +1,15 @@ -import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities'; -import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; -import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability'; import { evaluateOpenCodeSupport, OPENCODE_TEAM_LAUNCH_VERSION_POLICY, type OpenCodeInstallMethod, type OpenCodeProductionE2EEvidence, - type OpenCodeSupportLevel, type OpenCodeSupportedVersionPolicy, + type OpenCodeSupportLevel, } from '../version/OpenCodeVersionPolicy'; + +import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract'; +import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities'; +import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability'; import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest'; export type OpenCodeTeamLaunchReadinessState = diff --git a/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts b/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts index 44e7783e..4817d627 100644 --- a/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts +++ b/src/main/services/team/opencode/store/OpenCodeLaunchTransactionStore.ts @@ -1,6 +1,7 @@ import { createHash } from 'crypto'; import { stableJsonStringify } from '../bridge/OpenCodeBridgeCommandContract'; + import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; export const OPENCODE_LAUNCH_TRANSACTION_SCHEMA_VERSION = 1; @@ -44,7 +45,7 @@ export interface OpenCodeLaunchTransaction { } export interface OpenCodeRunReadyInput { - members: Array<{ name: string; launchState?: string }>; + members: { name: string; launchState?: string }[]; transaction: OpenCodeLaunchTransaction; toolProof: { ok: boolean }; deliveryReady: boolean; diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index b393ed1b..7e096176 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -1,13 +1,15 @@ -import { mkdir, readFile, readdir, rename, rm, stat } from 'node:fs/promises'; -import * as path from 'path'; +import { mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises'; import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { createLogger } from '@shared/utils/logger'; +import * as path from 'path'; + +import { withFileLock } from '../../fileLock'; + +import { createRuntimeStoreManifestStore } from './RuntimeStoreManifest'; import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract'; import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService'; -import { withFileLock } from '../../fileLock'; -import { createRuntimeStoreManifestStore } from './RuntimeStoreManifest'; const logger = createLogger('OpenCodeRuntimeManifestEvidenceReader'); @@ -66,12 +68,12 @@ function normalizeOpenCodeRuntimeLaneIndex( if ( !value || typeof value !== 'object' || - typeof (value as OpenCodeRuntimeLaneIndexEntry).laneId !== 'string' || - typeof (value as OpenCodeRuntimeLaneIndexEntry).updatedAt !== 'string' + typeof value.laneId !== 'string' || + typeof value.updatedAt !== 'string' ) { return []; } - const entry = value as OpenCodeRuntimeLaneIndexEntry; + const entry = value; return [ [ key, @@ -398,7 +400,7 @@ export async function recoverStaleOpenCodeRuntimeLaneIndexEntry(params: { }> { const index = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName); const entry = index.lanes[params.laneId]; - if (!entry || entry.state !== 'active') { + if (entry?.state !== 'active') { return { stale: false, degraded: false, diff --git a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts index 40559b45..57c1589f 100644 --- a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts +++ b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts @@ -394,10 +394,10 @@ export class RuntimeStoreBatchWriter { capabilitySnapshotId: string | null; behaviorFingerprint: string | null; reason: RuntimeStoreWriteBatchReason; - writes: Array<{ + writes: { descriptor: RuntimeStoreDescriptor; data: unknown; - }>; + }[]; }): Promise { const clock = this.options.clock ?? (() => new Date()); const batch: RuntimeStoreWriteBatch = { @@ -787,17 +787,17 @@ export interface RuntimeStoreCrossStoreInvariantInput { runId: string | null; capabilitySnapshotId: string | null; aggregateState?: string; - members?: Array<{ name: string; launchState?: string }>; + members?: { name: string; launchState?: string }[]; }; sessionStore: { - sessions?: Array<{ teamName: string; memberName: string; runId: string | null }>; + sessions?: { teamName: string; memberName: string; runId: string | null }[]; }; transaction: { status?: string } | null; deliveryJournal: { - records?: Array<{ idempotencyKey: string; runId: string | null; status: string }>; + records?: { idempotencyKey: string; runId: string | null; status: string }[]; }; permissionStore: { - requests?: Array<{ appRequestId: string; runId: string | null; status: string }>; + requests?: { appRequestId: string; runId: string | null; status: string }[]; }; compatibilitySnapshot: { snapshotId: string | null }; manifest: RuntimeStoreManifest; diff --git a/src/main/services/team/opencode/store/VersionedJsonStore.ts b/src/main/services/team/opencode/store/VersionedJsonStore.ts index 425d1bf4..9da4a5e1 100644 --- a/src/main/services/team/opencode/store/VersionedJsonStore.ts +++ b/src/main/services/team/opencode/store/VersionedJsonStore.ts @@ -1,8 +1,7 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { atomicWriteAsync } from '@main/utils/atomicWrite'; - import { withFileLock } from '../../fileLock'; export interface VersionedJsonStoreEnvelope { diff --git a/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts index 2f7545a0..3f9882c1 100644 --- a/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts +++ b/src/main/services/team/opencode/version/OpenCodeVersionPolicy.ts @@ -1,15 +1,16 @@ import { createHash } from 'crypto'; import { promises as fs } from 'fs'; +import { + assertOpenCodeProductionE2EEvidenceBasics, + type OpenCodeProductionE2EEvidence, +} from '../e2e/OpenCodeProductionE2EEvidence'; + import type { OpenCodeApiCapabilities, OpenCodeApiEndpointKey, OpenCodeEndpointEvidence, } from '../capabilities/OpenCodeApiCapabilities'; -import { - assertOpenCodeProductionE2EEvidenceBasics, - type OpenCodeProductionE2EEvidence, -} from '../e2e/OpenCodeProductionE2EEvidence'; export interface OpenCodeSupportedVersionPolicy { minimumVersion: string; @@ -111,8 +112,7 @@ export function shouldReuseCompatibilitySnapshot(input: { version: string; }): boolean { return Boolean( - input.cached && - input.cached.binaryPath === input.binaryPath && + input.cached?.binaryPath === input.binaryPath && input.cached.binaryFingerprint === input.binaryFingerprint && input.cached.version === input.version ); @@ -213,7 +213,7 @@ export function selectPermissionReplyRouteFromCache( } export function parseOpenCodeSemver(version: string): OpenCodeSemver | null { - const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/); + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(version.trim()); if (!match) { return null; } diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index ac3d2f95..208ba184 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -1,10 +1,9 @@ import { randomUUID } from 'crypto'; -import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { + OpenCodeBridgeRuntimeSnapshot, OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandData, - OpenCodeBridgeRuntimeSnapshot, OpenCodeReconcileTeamCommandBody, OpenCodeSendMessageCommandBody, OpenCodeSendMessageCommandData, @@ -13,6 +12,7 @@ import type { OpenCodeTeamLaunchMode, OpenCodeTeamMemberLaunchBridgeState, } from '../opencode/bridge/OpenCodeBridgeCommandContract'; +import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { TeamLaunchRuntimeAdapter, TeamRuntimeLaunchInput, diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts index 37102a84..6d8909a2 100644 --- a/src/main/services/team/runtime/index.ts +++ b/src/main/services/team/runtime/index.ts @@ -1,16 +1,11 @@ -export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter'; export type { OpenCodeTeamLaunchMode, - OpenCodeTeamRuntimeMessageInput, - OpenCodeTeamRuntimeMessageResult, OpenCodeTeamRuntimeAdapterOptions, OpenCodeTeamRuntimeBridgePort, + OpenCodeTeamRuntimeMessageInput, + OpenCodeTeamRuntimeMessageResult, } from './OpenCodeTeamRuntimeAdapter'; -export { - isTeamRuntimeProviderId, - TeamRuntimeAdapterRegistry, - TEAM_RUNTIME_PROVIDER_IDS, -} from './TeamRuntimeAdapter'; +export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter'; export type { TeamLaunchRuntimeAdapter, TeamRuntimeLaunchInput, @@ -29,3 +24,8 @@ export type { TeamRuntimeStopReason, TeamRuntimeStopResult, } from './TeamRuntimeAdapter'; +export { + isTeamRuntimeProviderId, + TEAM_RUNTIME_PROVIDER_IDS, + TeamRuntimeAdapterRegistry, +} from './TeamRuntimeAdapter'; diff --git a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts index 85555063..c5d50966 100644 --- a/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts +++ b/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts @@ -74,7 +74,7 @@ export class TeamTaskLogFreshnessReader { uniqueTaskIds.map(async (taskId, index) => { const candidates = signalFilePathCandidates[index] ?? []; const result = await this.readFirstSignal(candidates); - if (!result || result.parsed.taskId !== taskId) { + if (result?.parsed.taskId !== taskId) { return null; } const parsed = result.parsed; diff --git a/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts b/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts index 316796e6..5667929b 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts @@ -27,7 +27,7 @@ export class TeamTaskStallJournal { now: string; }): Promise { const filePath = this.getFilePath(args.teamName); - let readyEvaluations: TaskStallEvaluation[] = []; + const readyEvaluations: TaskStallEvaluation[] = []; await withFileLock(filePath, async () => { const entries = await this.readUnlocked(filePath); diff --git a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts index c5cfbe66..a1b12321 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts @@ -1,7 +1,6 @@ import { createLogger } from '@shared/utils/logger'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; -import { ActiveTeamRegistry } from './ActiveTeamRegistry'; import { getTeamTaskStallActivationGraceMs, getTeamTaskStallScanIntervalMs, @@ -10,10 +9,11 @@ import { isTeamTaskStallMonitorEnabled, } from './featureGates'; -import type { TeamTaskStallSnapshotSource } from './TeamTaskStallSnapshotSource'; -import type { TeamTaskStallPolicy } from './TeamTaskStallPolicy'; +import type { ActiveTeamRegistry } from './ActiveTeamRegistry'; import type { TeamTaskStallJournal } from './TeamTaskStallJournal'; import type { TeamTaskStallNotifier } from './TeamTaskStallNotifier'; +import type { TeamTaskStallPolicy } from './TeamTaskStallPolicy'; +import type { TeamTaskStallSnapshotSource } from './TeamTaskStallSnapshotSource'; import type { TaskStallAlert, TaskStallEvaluation } from './TeamTaskStallTypes'; import type { TeamChangeEvent } from '@shared/types'; diff --git a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts index 0f00b766..c86dc6a6 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts @@ -1,7 +1,7 @@ import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; -import type { TaskStallAlert } from './TeamTaskStallTypes'; import type { TeamDataService } from '../TeamDataService'; +import type { TaskStallAlert } from './TeamTaskStallTypes'; function buildLeadAlertText(alerts: TaskStallAlert[]): string { return alerts diff --git a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts index 1d339dec..c09a494d 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts @@ -1,3 +1,4 @@ +import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; import type { ReviewTaskContext, TaskStallBranch, @@ -7,8 +8,7 @@ import type { TeamTaskStallSnapshot, WorkTaskContext, } from './TeamTaskStallTypes'; -import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; -import type { TeamTask, TaskWorkInterval, TaskHistoryEvent } from '@shared/types'; +import type { TaskHistoryEvent, TaskWorkInterval, TeamTask } from '@shared/types'; const WORK_TOUCH_TOOLS = new Set(['task_start', 'task_add_comment', 'task_set_status']); const REVIEW_TOUCH_TOOLS = new Set(['review_start', 'task_add_comment']); @@ -347,12 +347,12 @@ export class TeamTaskStallPolicy { } const workContext: WorkTaskContext | null = (() => { - const touch = findLastMeaningfulWorkTouch(records, task.owner!, openWorkInterval.startedAt); + const touch = findLastMeaningfulWorkTouch(records, task.owner, openWorkInterval.startedAt); if (!touch) { return null; } return { - owner: task.owner!, + owner: task.owner, intervalStartedAt: openWorkInterval.startedAt, lastMeaningfulTouch: touch, lastMeaningfulTouchAt: touch.timestamp, @@ -452,7 +452,7 @@ export class TeamTaskStallPolicy { const reviewContext: ReviewTaskContext | null = (() => { const touch = findLastMeaningfulReviewTouch( records, - resolvedReviewer.reviewer!, + resolvedReviewer.reviewer, reviewWindowStartedAt, explicitReviewStarted ); diff --git a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts index b6118f28..810f9637 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts @@ -1,14 +1,14 @@ -import { TeamTaskReader } from '../TeamTaskReader'; -import { TeamKanbanManager } from '../TeamKanbanManager'; -import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscriptSourceLocator'; import { BoardTaskActivityTranscriptReader } from '../taskLogs/activity/BoardTaskActivityTranscriptReader'; import { isBoardTaskActivityReadEnabled } from '../taskLogs/activity/featureGates'; +import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscriptSourceLocator'; import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'; +import { TeamKanbanManager } from '../TeamKanbanManager'; +import { TeamTaskReader } from '../TeamTaskReader'; import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer'; +import { buildResolvedReviewerIndex } from './reviewerResolution'; import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader'; import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader'; -import { buildResolvedReviewerIndex } from './reviewerResolution'; import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; import type { TeamTaskStallSnapshot } from './TeamTaskStallTypes'; diff --git a/src/main/services/team/stallMonitor/reviewerResolution.ts b/src/main/services/team/stallMonitor/reviewerResolution.ts index 962f4f84..639dd43e 100644 --- a/src/main/services/team/stallMonitor/reviewerResolution.ts +++ b/src/main/services/team/stallMonitor/reviewerResolution.ts @@ -1,5 +1,4 @@ -import { TeamKanbanManager } from '../TeamKanbanManager'; - +import type { TeamKanbanManager } from '../TeamKanbanManager'; import type { ResolvedReviewer } from './TeamTaskStallTypes'; import type { TeamTask } from '@shared/types'; diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 7a70410c..4f698b1d 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -3,7 +3,6 @@ import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import { TeamTaskReader } from '../../TeamTaskReader'; -import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; @@ -12,8 +11,10 @@ import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictP import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector'; import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates'; import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions'; + import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource'; +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; import type { @@ -175,7 +176,12 @@ function valueReferencesTask(value: unknown, taskRefs: Set, depth = 0): function normalizeStatusDetail( value: unknown ): 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined { - if (value !== 'pending' && value !== 'in_progress' && value !== 'completed' && value !== 'deleted') { + if ( + value !== 'pending' && + value !== 'in_progress' && + value !== 'completed' && + value !== 'deleted' + ) { return undefined; } return value; @@ -215,9 +221,7 @@ function normalizeRelationshipDetail( return value; } -function inferHistoricalLinkKind( - canonicalToolName: string -): 'lifecycle' | 'board_action' | null { +function inferHistoricalLinkKind(canonicalToolName: string): 'lifecycle' | 'board_action' | null { if (HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonicalToolName)) { return 'lifecycle'; } @@ -227,9 +231,7 @@ function inferHistoricalLinkKind( return null; } -function inferHistoricalActionCategory( - canonicalToolName: string -): BoardTaskActivityCategory { +function inferHistoricalActionCategory(canonicalToolName: string): BoardTaskActivityCategory { switch (canonicalToolName) { case 'task_start': case 'task_complete': @@ -324,7 +326,10 @@ function buildHistoricalActionDetails(args: { } } - if (canonicalToolName === 'task_set_owner' && Object.prototype.hasOwnProperty.call(input, 'owner')) { + if ( + canonicalToolName === 'task_set_owner' && + Object.prototype.hasOwnProperty.call(input, 'owner') + ) { const owner = normalizeOwnerDetail(input.owner); if (owner !== undefined) { details.owner = owner; @@ -368,7 +373,10 @@ function buildHistoricalActionDetails(args: { } } - if (canonicalToolName === 'task_attach_file' || canonicalToolName === 'task_attach_comment_file') { + if ( + canonicalToolName === 'task_attach_file' || + canonicalToolName === 'task_attach_comment_file' + ) { const attachmentId = typeof resultRecord?.id === 'string' && resultRecord.id.trim().length > 0 ? resultRecord.id.trim() @@ -1480,7 +1488,7 @@ export class BoardTaskLogStreamService { } const actor = buildInferredActor(message, leadName); - if (!actor || !actor.memberName) { + if (!actor?.memberName) { continue; } @@ -1537,7 +1545,8 @@ export class BoardTaskLogStreamService { this.transcriptSourceLocator.getContext(teamName), ]); - const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId) ?? null; + const task = + [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId) ?? null; const transcriptFiles = transcriptContext?.transcriptFiles ?? []; if (!task || transcriptFiles.length === 0) { return { @@ -1623,8 +1632,9 @@ export class BoardTaskLogStreamService { continue; } - const overriddenActorName = - !baseActor.memberName ? readHistoricalActorName(toolCall.input) : undefined; + const overriddenActorName = !baseActor.memberName + ? readHistoricalActorName(toolCall.input) + : undefined; const actor: BoardTaskLogActor = overriddenActorName ? { ...baseActor, diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts index e51c1fa8..b6539e66 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionService.ts @@ -1,5 +1,6 @@ -import { - OpenCodeTaskLogAttributionStore, +import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore'; + +import type { OpenCodeTaskLogAttributionRecord, OpenCodeTaskLogAttributionScope, OpenCodeTaskLogAttributionSource, diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index b69a9109..6bbe70c5 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -1,10 +1,11 @@ import { createLogger } from '@shared/utils/logger'; import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService'; +import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver'; import { TeamTaskReader } from '../../TeamTaskReader'; -import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; + import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore'; import type { @@ -15,6 +16,7 @@ import type { OpenCodeTaskLogAttributionReader, OpenCodeTaskLogAttributionRecord, } from './OpenCodeTaskLogAttributionStore'; +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; import type { BoardTaskLogActor, BoardTaskLogParticipant, @@ -22,7 +24,6 @@ import type { BoardTaskLogStreamResponse, TeamTask, } from '@shared/types'; -import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; const logger = createLogger('OpenCodeTaskLogStreamSource'); diff --git a/src/main/standalone.ts b/src/main/standalone.ts index d1186be3..eda481d9 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -56,7 +56,7 @@ if (!process.env.CORS_ORIGIN) { const updaterServiceStub = { checkForUpdates: async () => {}, downloadUpdate: async () => {}, - quitAndInstall: () => {}, + quitAndInstall: async () => {}, setMainWindow: () => {}, } as unknown as UpdaterService; diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 82ca5118..384731d6 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -20,13 +20,23 @@ function execFileAsync( options: ExecFileOptions = {} ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { - execFile(cmd, args, options, (err, stdout, stderr) => { + let child: ChildProcess | null = null; + let settled = false; + const cleanup = (): void => { + untrackCliProcess(child); + }; + child = execFile(cmd, args, options, (err, stdout, stderr) => { + settled = true; + cleanup(); if (err) reject( err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error') ); else resolve({ stdout: String(stdout), stderr: String(stderr) }); }); + if (!settled) { + trackCliProcess(child); + } }); } @@ -40,14 +50,24 @@ function execShellAsync( options: ExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { + let child: ChildProcess | null = null; + let settled = false; + const cleanup = (): void => { + untrackCliProcess(child); + }; // eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - exec(cmd, options, (err, stdout, stderr) => { + child = exec(cmd, options, (err, stdout, stderr) => { + settled = true; + cleanup(); if (err) reject( err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error') ); else resolve({ stdout: String(stdout), stderr: String(stderr) }); }); + if (!settled) { + trackCliProcess(child); + } }); } @@ -94,6 +114,35 @@ const CLI_ENV_DEFAULTS: Record = { CLAUDE_HOOK_JUDGE_MODE: 'true', }; +const activeCliProcesses = new Set(); + +function untrackCliProcess(child: ChildProcess | null): void { + if (child) { + activeCliProcesses.delete(child); + } +} + +function trackCliProcess(child: T): T { + activeCliProcesses.add(child); + const cleanup = (): void => { + activeCliProcesses.delete(child); + }; + child.once?.('exit', cleanup); + child.once?.('close', cleanup); + child.once?.('error', cleanup); + return child; +} + +export function killTrackedCliProcesses(signal: NodeJS.Signals = 'SIGKILL'): void { + for (const child of Array.from(activeCliProcesses)) { + try { + killProcessTree(child, signal); + } catch { + // Best effort during shutdown. + } + } +} + /** Merge CLI_ENV_DEFAULTS into spawn/exec options.env (or process.env if absent). */ function withCliEnv }>( options: T @@ -164,18 +213,18 @@ export function spawnCli( if (process.platform === 'win32' && needsShell(binaryPath)) { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - return spawn(cmd, { ...opts, shell: true }); + return trackCliProcess(spawn(cmd, { ...opts, shell: true })); } try { - return spawn(binaryPath, args, opts); + return trackCliProcess(spawn(binaryPath, args, opts)); } catch (err: unknown) { const code = err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined; if (process.platform === 'win32' && code === 'EINVAL') { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - return spawn(cmd, { ...opts, shell: true }); + return trackCliProcess(spawn(cmd, { ...opts, shell: true })); } throw err; } diff --git a/src/main/utils/electronUserDataMigration.ts b/src/main/utils/electronUserDataMigration.ts new file mode 100644 index 00000000..1e29fabe --- /dev/null +++ b/src/main/utils/electronUserDataMigration.ts @@ -0,0 +1,245 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const LEGACY_USER_DATA_DIR_NAMES = [ + 'Claude Agent Teams UI', + 'claude-agent-teams-ui', + 'claude-devtools', + 'claude-code-context', +] as const; + +export interface ElectronUserDataMigrationApp { + getPath(name: string): string; + setPath?(name: string, value: string): void; +} + +export interface ElectronUserDataMigrationResult { + currentPath: string | null; + legacyPath: string | null; + migrated: boolean; + fallbackToLegacy: boolean; + reason: + | 'migrated' + | 'current-populated' + | 'current-path-exists' + | 'legacy-missing' + | 'legacy-fallback' + | 'error'; +} + +interface LoggerLike { + info(message: string): void; + warn(message: string): void; +} + +interface ElectronUserDataMigrationOptions { + logger?: LoggerLike; + copyDirectory?: (sourcePath: string, targetPath: string) => void; +} + +export function getLegacyElectronUserDataCandidates(currentPath: string): string[] { + const parent = path.dirname(currentPath); + const normalizedCurrent = path.resolve(currentPath); + + return LEGACY_USER_DATA_DIR_NAMES.map((dirName) => path.join(parent, dirName)).filter( + (legacyPath) => path.resolve(legacyPath) !== normalizedCurrent + ); +} + +export function migrateElectronUserDataDirectory( + app: ElectronUserDataMigrationApp, + options: ElectronUserDataMigrationOptions = {} +): ElectronUserDataMigrationResult { + const logger = options.logger; + let currentPath: string; + + try { + currentPath = app.getPath('userData'); + } catch (error) { + logger?.warn(`Electron userData migration skipped: ${stringifyError(error)}`); + return { + currentPath: null, + legacyPath: null, + migrated: false, + fallbackToLegacy: false, + reason: 'error', + }; + } + + if (directoryExists(currentPath) && directoryHasEntries(currentPath)) { + return { + currentPath, + legacyPath: null, + migrated: false, + fallbackToLegacy: false, + reason: 'current-populated', + }; + } + + if (pathExists(currentPath) && !directoryExists(currentPath)) { + logger?.warn(`Electron userData migration skipped: current path is not a directory`); + return { + currentPath, + legacyPath: null, + migrated: false, + fallbackToLegacy: false, + reason: 'current-path-exists', + }; + } + + const legacyPath = selectLegacyElectronUserDataPath(currentPath); + if (!legacyPath) { + return { + currentPath, + legacyPath: null, + migrated: false, + fallbackToLegacy: false, + reason: 'legacy-missing', + }; + } + + const migrated = copyLegacyUserDataDirectory( + legacyPath, + currentPath, + logger, + options.copyDirectory + ); + if (migrated) { + logger?.info(`Migrated Electron userData from ${legacyPath} to ${currentPath}`); + return { + currentPath, + legacyPath, + migrated: true, + fallbackToLegacy: false, + reason: 'migrated', + }; + } + + if (directoryExists(currentPath) && directoryHasEntries(currentPath)) { + return { + currentPath, + legacyPath: null, + migrated: false, + fallbackToLegacy: false, + reason: 'current-populated', + }; + } + + try { + setLegacyElectronPaths(app, legacyPath, logger); + logger?.warn(`Electron userData migration failed, using legacy path for this run`); + return { + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: true, + reason: 'legacy-fallback', + }; + } catch (error) { + logger?.warn(`Electron userData legacy fallback failed: ${stringifyError(error)}`); + return { + currentPath, + legacyPath, + migrated: false, + fallbackToLegacy: false, + reason: 'error', + }; + } +} + +function selectLegacyElectronUserDataPath(currentPath: string): string | null { + const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists); + return ( + candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ?? candidates[0] ?? null + ); +} + +function setLegacyElectronPaths( + app: ElectronUserDataMigrationApp, + legacyPath: string, + logger?: LoggerLike +): void { + app.setPath?.('userData', legacyPath); + try { + app.setPath?.('sessionData', legacyPath); + } catch (error) { + logger?.warn(`Electron sessionData legacy fallback failed: ${stringifyError(error)}`); + } +} + +function copyLegacyUserDataDirectory( + legacyPath: string, + currentPath: string, + logger?: LoggerLike, + copyDirectory: (sourcePath: string, targetPath: string) => void = copyDirectorySync +): boolean { + const parent = path.dirname(currentPath); + const tempPath = path.join( + parent, + `${path.basename(currentPath)}.migrating-${process.pid}-${Date.now()}` + ); + + try { + fs.mkdirSync(parent, { recursive: true }); + + if (pathExists(tempPath)) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + + copyDirectory(legacyPath, tempPath); + + if (directoryExists(currentPath) && !directoryHasEntries(currentPath)) { + fs.rmdirSync(currentPath); + } + + fs.renameSync(tempPath, currentPath); + return true; + } catch (error) { + logger?.warn(`Electron userData migration copy failed: ${stringifyError(error)}`); + try { + if (pathExists(tempPath)) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + } catch { + // Best effort cleanup only. + } + return false; + } +} + +function copyDirectorySync(sourcePath: string, targetPath: string): void { + fs.cpSync(sourcePath, targetPath, { + recursive: true, + errorOnExist: false, + force: false, + }); +} + +function pathExists(targetPath: string): boolean { + try { + fs.accessSync(targetPath); + return true; + } catch { + return false; + } +} + +function directoryExists(targetPath: string): boolean { + try { + return fs.statSync(targetPath).isDirectory(); + } catch { + return false; + } +} + +function directoryHasEntries(targetPath: string): boolean { + try { + return fs.readdirSync(targetPath).length > 0; + } catch { + return false; + } +} + +function stringifyError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 6c221a13..9467d667 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -382,11 +383,84 @@ export function getToolsBasePath(): string { return path.join(getClaudeBasePath(), 'tools'); } +type CopyDirectoryForMigration = (sourcePath: string, targetPath: string) => void; + +function copyDirectoryForMigrationSync(sourcePath: string, targetPath: string): void { + fs.cpSync(sourcePath, targetPath, { + recursive: true, + errorOnExist: false, + force: false, + }); +} + +let copyDirectoryForMigration: CopyDirectoryForMigration = copyDirectoryForMigrationSync; + +export function __setPathDecoderCopyDirectoryForTests( + copyDirectory: CopyDirectoryForMigration | null +): void { + copyDirectoryForMigration = copyDirectory ?? copyDirectoryForMigrationSync; +} + /** - * Get the schedules directory path (~/.claude/claude-devtools-schedules). + * Get the schedules directory path (~/.claude/agent-teams-schedules). */ export function getSchedulesBasePath(): string { - return path.join(getClaudeBasePath(), 'claude-devtools-schedules'); + const basePath = getClaudeBasePath(); + return migrateLegacyDirectoryPath( + path.join(basePath, 'agent-teams-schedules'), + path.join(basePath, 'claude-devtools-schedules') + ); +} + +function migrateLegacyDirectoryPath(currentPath: string, legacyPath: string): string { + if (!directoryExists(legacyPath)) { + return currentPath; + } + + if (directoryExists(currentPath)) { + if (directoryHasEntries(currentPath)) { + return currentPath; + } + return copyLegacyDirectoryPath(currentPath, legacyPath); + } + + if (pathExists(currentPath)) { + return currentPath; + } + + return copyLegacyDirectoryPath(currentPath, legacyPath); +} + +function copyLegacyDirectoryPath(currentPath: string, legacyPath: string): string { + const tempPath = `${currentPath}.migrating-${process.pid}`; + + try { + fs.mkdirSync(path.dirname(currentPath), { recursive: true }); + if (pathExists(tempPath)) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + + copyDirectoryForMigration(legacyPath, tempPath); + + if (directoryExists(currentPath) && !directoryHasEntries(currentPath)) { + fs.rmdirSync(currentPath); + } + + fs.renameSync(tempPath, currentPath); + return currentPath; + } catch { + try { + if (pathExists(tempPath)) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + } catch { + // Best effort cleanup only. + } + + return directoryExists(currentPath) && directoryHasEntries(currentPath) + ? currentPath + : legacyPath; + } } export function getTaskChangeSummariesBasePath(): string { @@ -414,6 +488,9 @@ export function getAppDataPath(): string { // ── App data root (Electron userData) ── +const APP_DATA_FALLBACK_DIR_NAME = '.agent-teams-ai'; +const LEGACY_APP_DATA_FALLBACK_DIR_NAME = '.claude-agent-teams-ui'; + let appDataBasePathOverride: string | null = null; export function setAppDataBasePath(p: string | null | undefined): void { @@ -428,8 +505,87 @@ function getAppDataBasePath(): string { const { app } = require('electron') as typeof import('electron'); return app.getPath('userData'); } catch { - // Outside Electron (tests, CLI) — fall back to home dir - return path.join(getHomeDir(), '.claude-agent-teams-ui'); + // Outside Electron (tests, CLI): use the new fallback path and migrate legacy data once. + return getFallbackAppDataBasePath(); + } +} + +function getFallbackAppDataBasePath(): string { + const home = getHomeDir(); + const currentPath = path.join(home, APP_DATA_FALLBACK_DIR_NAME); + const legacyPath = path.join(home, LEGACY_APP_DATA_FALLBACK_DIR_NAME); + + if (!directoryExists(legacyPath)) { + return currentPath; + } + + if (directoryExists(currentPath)) { + if (directoryHasEntries(currentPath)) { + return currentPath; + } + return migrateFallbackAppDataBasePath(currentPath, legacyPath); + } + + if (pathExists(currentPath)) { + return currentPath; + } + + return migrateFallbackAppDataBasePath(currentPath, legacyPath); +} + +function migrateFallbackAppDataBasePath(currentPath: string, legacyPath: string): string { + const tempPath = `${currentPath}.migrating-${process.pid}`; + + try { + if (pathExists(tempPath)) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + + copyDirectoryForMigration(legacyPath, tempPath); + + if (directoryExists(currentPath) && !directoryHasEntries(currentPath)) { + fs.rmdirSync(currentPath); + } + + fs.renameSync(tempPath, currentPath); + return currentPath; + } catch { + try { + if (pathExists(tempPath)) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + } catch { + // Best effort cleanup only. + } + + return directoryExists(currentPath) && directoryHasEntries(currentPath) + ? currentPath + : legacyPath; + } +} + +function pathExists(targetPath: string): boolean { + try { + fs.accessSync(targetPath); + return true; + } catch { + return false; + } +} + +function directoryExists(targetPath: string): boolean { + try { + return fs.statSync(targetPath).isDirectory(); + } catch { + return false; + } +} + +function directoryHasEntries(targetPath: string): boolean { + try { + return fs.readdirSync(targetPath).length > 0; + } catch { + return false; } } diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 79f0e01f..f6661a7a 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -544,11 +544,11 @@ async function listTeams( const removedKeys = new Set(); const expectedTeammateNames = new Set(); const confirmedArtifactNames = new Set(); - const metaRuntimeMembers: Array<{ + const metaRuntimeMembers: { name: string; providerId?: 'anthropic' | 'codex' | 'gemini' | 'opencode'; removedAt?: unknown; - }> = []; + }[] = []; let leadProviderId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined; try { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9a564a02..d4673748 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -26,6 +26,8 @@ const SPLASH_FADE_MS = 480; const SPLASH_REDUCED_MIN_DURATION_MS = 320; const SPLASH_REDUCED_HOLD_MS = 120; const SPLASH_REDUCED_FADE_MS = 180; +const SPLASH_AVATAR_READY_MAX_WAIT_MS = 900; +const SPLASH_REDUCED_AVATAR_READY_MAX_WAIT_MS = 160; export const App = (): React.JSX.Element => { // Initialize theme on app load @@ -44,10 +46,17 @@ export const App = (): React.JSX.Element => { const minDuration = reducedMotion ? SPLASH_REDUCED_MIN_DURATION_MS : SPLASH_MIN_DURATION_MS; const enhancedHold = reducedMotion ? SPLASH_REDUCED_HOLD_MS : SPLASH_ENHANCED_HOLD_MS; const fadeDuration = reducedMotion ? SPLASH_REDUCED_FADE_MS : SPLASH_FADE_MS; + const avatarReadyMaxWait = reducedMotion + ? SPLASH_REDUCED_AVATAR_READY_MAX_WAIT_MS + : SPLASH_AVATAR_READY_MAX_WAIT_MS; const exitDelay = Math.max(minDuration - elapsed, enhancedHold - enhancedElapsed, 0); let removeTimer: number | undefined; + let avatarReadyTimer: number | undefined; + let dismissed = false; - const exitTimer = window.setTimeout(() => { + const dismissSplash = (): void => { + if (dismissed) return; + dismissed = true; splash.classList.add('splash-exiting'); removeTimer = window.setTimeout(() => { scene.stop(); @@ -55,10 +64,19 @@ export const App = (): React.JSX.Element => { window.__claudeTeamsSplashEnhancedStartedAt = undefined; splash.remove(); }, fadeDuration); + }; + + const exitTimer = window.setTimeout(() => { + avatarReadyTimer = window.setTimeout(dismissSplash, avatarReadyMaxWait); + void (scene.ready ?? Promise.resolve()).then(dismissSplash, dismissSplash); }, exitDelay); return () => { + dismissed = true; window.clearTimeout(exitTimer); + if (avatarReadyTimer !== undefined) { + window.clearTimeout(avatarReadyTimer); + } if (removeTimer !== undefined) { window.clearTimeout(removeTimer); } diff --git a/src/renderer/components/common/GlobalProviderStatusHeader.tsx b/src/renderer/components/common/GlobalProviderStatusHeader.tsx index a8e764a2..7478fd12 100644 --- a/src/renderer/components/common/GlobalProviderStatusHeader.tsx +++ b/src/renderer/components/common/GlobalProviderStatusHeader.tsx @@ -228,8 +228,7 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => { !isElectron || isDashboardFocused || !multimodelEnabled || - !effectiveCliStatus || - effectiveCliStatus.flavor !== 'agent_teams_orchestrator' || + effectiveCliStatus?.flavor !== 'agent_teams_orchestrator' || !effectiveCliStatus.installed || displayProviderIds.length === 0 ) { diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 406ea0a1..0aa434d3 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -44,9 +44,9 @@ import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; +import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility'; import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName'; -import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility'; import { AlertTriangle, CheckCircle, @@ -373,6 +373,21 @@ const ProviderDetailSkeleton = (): React.JSX.Element => { ); }; +const OpenCodeBetaBadge = (): React.JSX.Element => { + return ( + + beta + + ); +}; + function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean { return ( providerLoading || @@ -769,6 +784,7 @@ const InstalledBanner = ({ > {provider.displayName} + {provider.providerId === 'opencode' ? : null} ): React.JSX.Element { +}>): React.JSX.Element => { const accentStyles = accent === 'primary' ? { @@ -405,7 +405,7 @@ function CodexRateLimitWindowCard({
); -} +}; function getConnectionMethodCardOptions( provider: CliProviderStatus diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index 19b17799..6735d371 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -221,8 +221,7 @@ export function formatProviderStatusText(provider: CliProviderStatus): string { if ( isCodexNativeLane(provider) && - selectedBackendOption && - selectedBackendOption.state && + selectedBackendOption?.state && selectedBackendOption.state !== 'ready' ) { return ( diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 22201aa3..9b246b8e 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -376,7 +376,7 @@ export function useSettingsHandlers({ const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = 'claude-devtools-config.json'; + link.download = 'agent-teams-config.json'; document.body.appendChild(link); link.click(); document.body.removeChild(link); diff --git a/src/renderer/components/splash/splashScene.ts b/src/renderer/components/splash/splashScene.ts index fa419d35..9e936757 100644 --- a/src/renderer/components/splash/splashScene.ts +++ b/src/renderer/components/splash/splashScene.ts @@ -1,5 +1,8 @@ +import { PARTICIPANT_AVATAR_URLS } from '@renderer/utils/memberAvatarCatalog'; + export interface SplashSceneHandle { stop: () => void; + ready?: Promise; } export interface SplashSceneOptions { @@ -24,6 +27,8 @@ interface RobotNode extends Point { color: string; size: number; bob: number; + receivePulse: number; + avatarUrl: string; } interface TeamNode { @@ -57,7 +62,10 @@ interface Palette { const TAU = Math.PI * 2; const TEAM_MEMBER_COUNTS = [4, 3, 5] as const; +const TEAM_MEMBER_OFFSETS = [0, 4, 7] as const; const MAX_DPR = 2; +const avatarCache = new Map(); +const avatarLoading = new Map>(); export function startSplashScene( splash: HTMLElement, @@ -68,6 +76,7 @@ export function startSplashScene( return existingScene; } + const ready = preloadAvatarImages(); const previousCanvas = splash.querySelector('#splash-enhanced-canvas'); previousCanvas?.remove(); @@ -82,6 +91,7 @@ export function startSplashScene( stop: () => { canvas.remove(); }, + ready, }; return emptyHandle; } @@ -147,6 +157,7 @@ export function startSplashScene( window.__claudeTeamsSplashEnhancedStartedAt = undefined; } }, + ready, }; window.__claudeTeamsSplashScene = handle; window.__claudeTeamsSplashEnhancedStartedAt = performance.now(); @@ -171,13 +182,13 @@ function drawScene( drawAmbientField(ctx, width, height, sceneTime, particles, palette, mobile); drawCenterAura(ctx, center, sceneTime, palette, mobile); - drawCrossTeamGuides(ctx, teams, center, sceneTime, palette, mobile); + drawCrossTeamGuides(ctx, teams, sceneTime, palette); for (const team of teams) { drawTeamHalo(ctx, team, sceneTime, palette); } - drawMessages(ctx, teams, center, sceneTime, palette, mobile); + drawMessages(ctx, teams, sceneTime, palette, mobile); for (const team of teams) { drawTeamLinks(ctx, team, palette); @@ -188,8 +199,6 @@ function drawScene( drawRobot(ctx, robot, sceneTime, palette); } } - - clearCentralContentReserve(ctx, center, mobile); } function resolvePalette(): Palette { @@ -198,24 +207,24 @@ function resolvePalette(): Palette { ? { isLight, centerGlow: '#4f46e5', - teamColors: ['#0284c7', '#059669', '#d97706'], - teamLineAlpha: 0.34, + teamColors: ['#0369a1', '#047857', '#b45309'], + teamLineAlpha: 0.26, robotBody: '#eef2ff', - robotShade: '#c7d2fe', + robotShade: '#dbe4ff', robotEye: '#ffffff', - messageAccent: '#db2777', + messageAccent: '#7c3aed', particle: '#312e81', } : { isLight, - centerGlow: '#818cf8', - teamColors: ['#38bdf8', '#34d399', '#f59e0b'], - teamLineAlpha: 0.42, - robotBody: '#111827', - robotShade: '#27324a', - robotEye: '#e0f2fe', - messageAccent: '#f472b6', - particle: '#c4b5fd', + centerGlow: '#7c83f7', + teamColors: ['#24a8d8', '#23b488', '#d58a19'], + teamLineAlpha: 0.28, + robotBody: '#0f1724', + robotShade: '#1a2438', + robotEye: '#d8f3ff', + messageAccent: '#8b5cf6', + particle: '#a6a4d6', }; } @@ -234,31 +243,31 @@ function buildTeams( palette: Palette ): TeamNode[] { const center = getCenter(width, height, mobile); - const spreadX = mobile ? Math.min(width * 0.3, 126) : Math.min(width * 0.26, 320); - const spreadY = mobile ? Math.min(height * 0.17, 132) : Math.min(height * 0.19, 190); + const spreadX = mobile ? Math.min(width * 0.36, 148) : Math.min(width * 0.34, 380); + const spreadY = mobile ? Math.min(height * 0.22, 154) : Math.min(height * 0.22, 220); const teamRadius = mobile - ? clamp(Math.min(width, height) * 0.09, 30, 40) - : clamp(Math.min(width, height) * 0.075, 44, 62); - const robotSize = mobile ? 11 : 14; + ? clamp(Math.min(width, height) * 0.092, 31, 42) + : clamp(Math.min(width, height) * 0.072, 42, 62); + const robotSize = mobile ? 9.8 : 11.8; const centers: Point[] = [ { x: center.x - spreadX, - y: center.y - spreadY * (mobile ? 0.6 : 0.45), + y: center.y - spreadY * (mobile ? 0.66 : 0.58), }, { x: center.x + spreadX, - y: center.y - spreadY * (mobile ? 0.6 : 0.45), + y: center.y - spreadY * (mobile ? 0.66 : 0.58), }, { x: center.x, - y: center.y + spreadY * (mobile ? 1.22 : 0.95), + y: center.y + spreadY * (mobile ? 1.34 : 1.18), }, ]; return centers.map((teamCenter, teamIndex) => { - const drift = Math.sin(time * 0.75 + teamIndex * 1.7) * (mobile ? 3 : 6); + const drift = Math.sin(time * 0.75 + teamIndex * 1.7) * (mobile ? 2.2 : 4.2); const centerWithDrift = { - x: teamCenter.x + Math.cos(teamIndex * 2.1 + time * 0.35) * (mobile ? 2 : 4), + x: teamCenter.x + Math.cos(teamIndex * 2.1 + time * 0.35) * (mobile ? 1.4 : 2.8), y: teamCenter.y + drift, }; const color = palette.teamColors[teamIndex % palette.teamColors.length] ?? palette.centerGlow; @@ -266,15 +275,19 @@ function buildTeams( const robots = Array.from({ length: memberCount }, (_, robotIndex) => { const baseAngle = -Math.PI / 2 + robotIndex * (TAU / memberCount) + (teamIndex === 2 ? TAU / 20 : 0); - const orbit = baseAngle + Math.sin(time * 0.55 + teamIndex + robotIndex) * 0.1; + const orbit = baseAngle + Math.sin(time * 0.55 + teamIndex + robotIndex) * 0.07; const orbitRadius = - teamRadius * (0.88 + (memberCount > 4 ? 0.08 : 0) + 0.05 * Math.sin(time + robotIndex)); + teamRadius * (0.94 + (memberCount > 4 ? 0.07 : 0) + 0.03 * Math.sin(time + robotIndex)); return { teamIndex, robotIndex, color, size: memberCount > 4 ? robotSize * 0.88 : robotSize, bob: Math.sin(time * 2.2 + teamIndex * 0.8 + robotIndex * 1.1), + receivePulse: 0, + avatarUrl: + PARTICIPANT_AVATAR_URLS[(TEAM_MEMBER_OFFSETS[teamIndex] ?? 0) + robotIndex] ?? + PARTICIPANT_AVATAR_URLS[0], x: centerWithDrift.x + Math.cos(orbit) * orbitRadius, y: centerWithDrift.y + Math.sin(orbit) * orbitRadius, }; @@ -322,8 +335,8 @@ function drawCenterAura( ): void { const radius = mobile ? 86 : 128; const glow = ctx.createRadialGradient(center.x, center.y, 20, center.x, center.y, radius); - glow.addColorStop(0, withAlpha(palette.centerGlow, palette.isLight ? 0.13 : 0.2)); - glow.addColorStop(0.48, withAlpha(palette.messageAccent, palette.isLight ? 0.07 : 0.11)); + glow.addColorStop(0, withAlpha(palette.centerGlow, palette.isLight ? 0.1 : 0.14)); + glow.addColorStop(0.48, withAlpha(palette.messageAccent, palette.isLight ? 0.04 : 0.07)); glow.addColorStop(1, withAlpha(palette.centerGlow, 0)); ctx.fillStyle = glow; ctx.beginPath(); @@ -333,7 +346,7 @@ function drawCenterAura( for (let i = 0; i < 3; i++) { const ringRadius = radius * (0.42 + i * 0.18) + Math.sin(time * 1.1 + i) * 3; ctx.beginPath(); - ctx.strokeStyle = withAlpha(palette.centerGlow, 0.1 - i * 0.018); + ctx.strokeStyle = withAlpha(palette.centerGlow, 0.07 - i * 0.014); ctx.lineWidth = 1; ctx.setLineDash([8 + i * 2, 12 + i * 3]); ctx.lineDashOffset = -time * (18 + i * 8); @@ -346,25 +359,20 @@ function drawCenterAura( function drawCrossTeamGuides( ctx: CanvasRenderingContext2D, teams: TeamNode[], - center: Point, time: number, - palette: Palette, - mobile: boolean + palette: Palette ): void { for (let i = 0; i < teams.length; i++) { const from = teams[i]; const to = teams[(i + 1) % teams.length]; if (!from || !to) continue; - const anchor = getCrossTeamAnchor(center, i, mobile); - const cp1 = mix(from.center, anchor, 0.62); - const cp2 = mix(to.center, anchor, 0.62); ctx.beginPath(); ctx.moveTo(from.center.x, from.center.y); - ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, to.center.x, to.center.y); - ctx.strokeStyle = withAlpha(palette.messageAccent, palette.isLight ? 0.16 : 0.2); - ctx.lineWidth = 1.2; - ctx.setLineDash([2, 13]); - ctx.lineDashOffset = -time * 28; + ctx.lineTo(to.center.x, to.center.y); + ctx.strokeStyle = withAlpha(palette.messageAccent, palette.isLight ? 0.14 : 0.18); + ctx.lineWidth = 1.05; + ctx.setLineDash([7, 12]); + ctx.lineDashOffset = -time * 34; ctx.stroke(); } ctx.setLineDash([]); @@ -387,18 +395,18 @@ function drawTeamHalo( team.center.y, team.radius * 2 ); - glow.addColorStop(0, withAlpha(team.color, palette.isLight ? 0.08 : 0.12)); + glow.addColorStop(0, withAlpha(team.color, palette.isLight ? 0.045 : 0.065)); glow.addColorStop(1, withAlpha(team.color, 0)); ctx.fillStyle = glow; ctx.beginPath(); - ctx.ellipse(team.center.x, team.center.y, team.radius * 2, team.radius * 1.56, 0, 0, TAU); + ctx.ellipse(team.center.x, team.center.y, team.radius * 1.82, team.radius * 1.36, 0, 0, TAU); ctx.fill(); ctx.beginPath(); ctx.ellipse(team.center.x, team.center.y, radiusX, radiusY, time * 0.08, 0, TAU); - ctx.strokeStyle = withAlpha(team.color, palette.isLight ? 0.28 : 0.34); - ctx.lineWidth = 1.25; - ctx.setLineDash([10, 8]); + ctx.strokeStyle = withAlpha(team.color, palette.isLight ? 0.2 : 0.24); + ctx.lineWidth = 1; + ctx.setLineDash([12, 10]); ctx.lineDashOffset = -time * (22 + team.index * 4); ctx.stroke(); ctx.setLineDash([]); @@ -423,7 +431,6 @@ function drawTeamLinks(ctx: CanvasRenderingContext2D, team: TeamNode, palette: P function drawMessages( ctx: CanvasRenderingContext2D, teams: TeamNode[], - center: Point, time: number, palette: Palette, mobile: boolean @@ -431,7 +438,7 @@ function drawMessages( for (const team of teams) { drawLocalMessages(ctx, team, time, palette, mobile); } - drawCrossTeamMessages(ctx, teams, center, time, palette, mobile); + drawCrossTeamMessages(ctx, teams, time, palette, mobile); } function drawLocalMessages( @@ -451,17 +458,17 @@ function drawLocalMessages( const to = team.robots[toIndex]; if (!from || !to) continue; const raw = positiveModulo(time + team.index * 0.7 + pairIndex * 0.36, period) / period; + applyReceivePulse(to, getReceivePulse(raw, activeWindow)); if (raw > activeWindow) continue; const progress = easeInOutCubic(raw / activeWindow); const curve = makeLocalCurve(from, to, team.center, team.radius * 0.42); - drawMessageFlight(ctx, curve, progress, team.color, time, mobile ? 5.5 : 7, palette); + drawMessageFlight(ctx, curve, progress, team.color, time, mobile ? 4.6 : 5.8, palette); } } function drawCrossTeamMessages( ctx: CanvasRenderingContext2D, teams: TeamNode[], - center: Point, time: number, palette: Palette, mobile: boolean @@ -469,10 +476,9 @@ function drawCrossTeamMessages( const activeWindow = 0.64; const period = 4.25; const routes = [ - { fromTeam: 0, fromRobot: 3, toTeam: 1, toRobot: 1, delay: 0, anchor: 0 }, - { fromTeam: 2, fromRobot: 4, toTeam: 0, toRobot: 1, delay: 0.82, anchor: 2 }, - { fromTeam: 1, fromRobot: 2, toTeam: 2, toRobot: 0, delay: 1.68, anchor: 1, accent: true }, - { fromTeam: 0, fromRobot: 0, toTeam: 2, toRobot: 3, delay: 2.54, anchor: 2 }, + { fromTeam: 0, fromRobot: 3, toTeam: 1, toRobot: 1, delay: 0 }, + { fromTeam: 1, fromRobot: 2, toTeam: 2, toRobot: 0, delay: 1.34, accent: true }, + { fromTeam: 2, fromRobot: 4, toTeam: 0, toRobot: 1, delay: 2.68 }, ]; for (const route of routes) { @@ -480,20 +486,21 @@ function drawCrossTeamMessages( const toTeam = teams[route.toTeam]; if (!fromTeam || !toTeam) continue; const raw = positiveModulo(time + route.delay, period) / period; - if (raw > activeWindow) continue; const from = fromTeam.robots[route.fromRobot % fromTeam.robots.length]; const to = toTeam.robots[route.toRobot % toTeam.robots.length]; if (!from || !to) continue; + applyReceivePulse(to, getReceivePulse(raw, activeWindow) * 0.88); + if (raw > activeWindow) continue; const progress = easeInOutCubic(raw / activeWindow); - const curve = makeCrossCurve(from, to, center, route.anchor, mobile); + const curve = makeStraightCurve(fromTeam.center, toTeam.center); drawMessageFlight( ctx, curve, progress, route.accent ? palette.messageAccent : fromTeam.color, time, - mobile ? 6 : 8.5, + mobile ? 5.2 : 6.8, palette, true ); @@ -512,21 +519,23 @@ function drawMessageFlight( ): void { const [p0, p1, p2, p3] = curve; ctx.save(); - ctx.beginPath(); - ctx.moveTo(p0.x, p0.y); - ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y); - ctx.strokeStyle = withAlpha(color, crossTeam ? 0.24 : 0.18); - ctx.lineWidth = crossTeam ? 1.25 : 1; - ctx.setLineDash(crossTeam ? [8, 10] : [4, 8]); - ctx.lineDashOffset = -time * (crossTeam ? 52 : 34); - ctx.stroke(); - ctx.setLineDash([]); + if (!crossTeam) { + ctx.beginPath(); + ctx.moveTo(p0.x, p0.y); + ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y); + ctx.strokeStyle = withAlpha(color, 0.12); + ctx.lineWidth = 0.85; + ctx.setLineDash([4, 8]); + ctx.lineDashOffset = -time * 34; + ctx.stroke(); + ctx.setLineDash([]); + } for (let i = 7; i >= 1; i--) { const t = progress - i * 0.036; if (t <= 0) continue; const point = cubicPoint(p0, p1, p2, p3, t); - const alpha = (1 - i / 8) * (palette.isLight ? 0.22 : 0.32); + const alpha = (1 - i / 8) * (palette.isLight ? 0.14 : 0.2); ctx.fillStyle = withAlpha(color, alpha); ctx.beginPath(); ctx.arc(point.x, point.y, size * (0.18 + i * 0.025), 0, TAU); @@ -540,6 +549,19 @@ function drawMessageFlight( ctx.restore(); } +function applyReceivePulse(robot: RobotNode, pulse: number): void { + robot.receivePulse = Math.max(robot.receivePulse, pulse); +} + +function getReceivePulse(raw: number, activeWindow: number): number { + const start = activeWindow * 0.78; + const end = Math.min(0.96, activeWindow + 0.11); + if (raw < start || raw > end) return 0; + + const phase = (raw - start) / (end - start); + return Math.sin(phase * Math.PI) * (1 - phase * 0.28); +} + function drawMessageBubble( ctx: CanvasRenderingContext2D, position: Point, @@ -551,20 +573,20 @@ function drawMessageBubble( ): void { ctx.save(); ctx.translate(position.x, position.y); - ctx.rotate(angle * 0.14); - ctx.shadowColor = withAlpha(color, palette.isLight ? 0.22 : 0.5); - ctx.shadowBlur = crossTeam ? 18 : 12; + ctx.rotate(angle * 0.08); + ctx.shadowColor = withAlpha(color, palette.isLight ? 0.16 : 0.3); + ctx.shadowBlur = crossTeam ? 12 : 8; - const width = size * (crossTeam ? 2.5 : 2.25); - const height = size * 1.62; - roundRectPath(ctx, -width / 2, -height / 2, width, height, size * 0.45); - ctx.fillStyle = color; + const width = size * (crossTeam ? 2.28 : 2.06); + const height = size * 1.42; + roundRectPath(ctx, -width / 2, -height / 2, width, height, size * 0.28); + ctx.fillStyle = withAlpha(color, palette.isLight ? 0.82 : 0.9); ctx.fill(); ctx.beginPath(); ctx.moveTo(-width * 0.24, height * 0.42); - ctx.lineTo(-width * 0.36, height * 0.78); - ctx.lineTo(-width * 0.05, height * 0.44); + ctx.lineTo(-width * 0.32, height * 0.68); + ctx.lineTo(-width * 0.03, height * 0.42); ctx.closePath(); ctx.fill(); @@ -572,7 +594,7 @@ function drawMessageBubble( ctx.fillStyle = palette.robotEye; for (let i = -1; i <= 1; i++) { ctx.beginPath(); - ctx.arc(i * size * 0.43, -size * 0.02, size * 0.12, 0, TAU); + ctx.arc(i * size * 0.4, -size * 0.02, size * 0.095, 0, TAU); ctx.fill(); } ctx.restore(); @@ -586,61 +608,101 @@ function drawRobot( ): void { const size = robot.size; const x = robot.x; - const y = robot.y + robot.bob * 1.6; - const tilt = Math.sin(time * 1.5 + robot.teamIndex + robot.robotIndex * 0.8) * 0.08; + const y = robot.y + robot.bob * 0.9 - robot.receivePulse * size * 0.24; + const tilt = Math.sin(time * 1.5 + robot.teamIndex + robot.robotIndex * 0.8) * 0.045; + const img = getAvatarImage(robot.avatarUrl); + const avatarSize = size * 2.65; ctx.save(); ctx.translate(x, y); ctx.rotate(tilt); - ctx.shadowColor = withAlpha(robot.color, palette.isLight ? 0.2 : 0.42); - ctx.shadowBlur = size * 1.6; + ctx.scale(1 + robot.receivePulse * 0.065, 1 + robot.receivePulse * 0.065); + ctx.shadowColor = withAlpha(robot.color, palette.isLight ? 0.2 : 0.34); + ctx.shadowBlur = size * (1.25 + robot.receivePulse * 0.72); - ctx.strokeStyle = withAlpha(robot.color, palette.isLight ? 0.64 : 0.82); - ctx.lineWidth = Math.max(1, size * 0.11); + if (img) { + ctx.globalAlpha = palette.isLight ? 0.92 : 0.86; + ctx.drawImage(img, -avatarSize / 2, -avatarSize / 2, avatarSize, avatarSize); + ctx.globalAlpha = 1; + } else { + drawAvatarFallback(ctx, size, robot.color, palette); + } + ctx.restore(); +} + +function getAvatarImage(url: string): HTMLImageElement | null { + const cached = avatarCache.get(url); + if (cached) { + avatarCache.delete(url); + avatarCache.set(url, cached); + return cached; + } + + void loadAvatarImage(url); + return null; +} + +function preloadAvatarImages(): Promise { + return Promise.allSettled(PARTICIPANT_AVATAR_URLS.map((url) => loadAvatarImage(url))).then( + () => undefined + ); +} + +function loadAvatarImage(url: string): Promise { + const cached = avatarCache.get(url); + if (cached) return Promise.resolve(cached); + + const loading = avatarLoading.get(url); + if (loading) return loading; + + const promise = new Promise((resolve) => { + const img = new Image(); + img.decoding = 'async'; + img.onload = () => { + const finish = (): void => { + avatarCache.set(url, img); + avatarLoading.delete(url); + resolve(img); + }; + + if (typeof img.decode === 'function') { + void img.decode().then(finish, finish); + } else { + finish(); + } + }; + img.onerror = () => { + avatarLoading.delete(url); + resolve(null); + }; + img.src = url; + }); + + avatarLoading.set(url, promise); + return promise; +} + +function drawAvatarFallback( + ctx: CanvasRenderingContext2D, + size: number, + color: string, + palette: Palette +): void { + ctx.strokeStyle = withAlpha(color, palette.isLight ? 0.44 : 0.56); + ctx.lineWidth = Math.max(1, size * 0.08); ctx.beginPath(); - ctx.moveTo(-size * 0.78, size * 0.22); - ctx.lineTo(-size * 1.12, size * 0.55); - ctx.moveTo(size * 0.78, size * 0.22); - ctx.lineTo(size * 1.12, size * 0.55); + ctx.moveTo(0, -size * 0.72); + ctx.lineTo(0, -size * 1.0); ctx.stroke(); - - const bodyGradient = ctx.createLinearGradient(0, -size, 0, size); - bodyGradient.addColorStop(0, mixColor(robot.color, palette.robotBody, 0.28)); - bodyGradient.addColorStop(1, mixColor(robot.color, palette.robotShade, 0.62)); - roundRectPath(ctx, -size * 0.78, -size * 0.74, size * 1.56, size * 1.48, size * 0.42); - ctx.fillStyle = bodyGradient; + ctx.fillStyle = withAlpha(color, palette.isLight ? 0.64 : 0.78); + ctx.beginPath(); + ctx.arc(0, -size * 1.08, size * 0.13, 0, TAU); ctx.fill(); - ctx.strokeStyle = withAlpha(robot.color, palette.isLight ? 0.74 : 0.9); - ctx.stroke(); - - ctx.shadowBlur = 0; - ctx.strokeStyle = withAlpha(robot.color, 0.75); - ctx.beginPath(); - ctx.moveTo(0, -size * 0.76); - ctx.lineTo(0, -size * 1.18); - ctx.stroke(); - ctx.fillStyle = robot.color; - ctx.beginPath(); - ctx.arc(0, -size * 1.25, size * 0.16, 0, TAU); - ctx.fill(); - ctx.fillStyle = palette.robotEye; ctx.beginPath(); - ctx.arc(-size * 0.3, -size * 0.2, size * 0.16, 0, TAU); - ctx.arc(size * 0.3, -size * 0.2, size * 0.16, 0, TAU); + ctx.arc(-size * 0.24, -size * 0.13, size * 0.095, 0, TAU); + ctx.arc(size * 0.24, -size * 0.13, size * 0.095, 0, TAU); ctx.fill(); - - ctx.strokeStyle = withAlpha(palette.robotEye, 0.72); - ctx.lineWidth = Math.max(1, size * 0.09); - ctx.beginPath(); - ctx.moveTo(-size * 0.36, size * 0.24); - ctx.quadraticCurveTo(0, size * 0.5, size * 0.36, size * 0.24); - ctx.stroke(); - - ctx.fillStyle = withAlpha(robot.color, palette.isLight ? 0.58 : 0.82); - ctx.fillRect(-size * 0.42, size * 0.82, size * 0.28, size * 0.22); - ctx.fillRect(size * 0.14, size * 0.82, size * 0.28, size * 0.22); - ctx.restore(); } function getTeamConnectionPairs(memberCount: number): [number, number][] { @@ -700,71 +762,8 @@ function makeLocalCurve( return [from, mix(from, control, 0.72), mix(to, control, 0.72), to]; } -function makeCrossCurve( - from: Point, - to: Point, - center: Point, - index: number, - mobile: boolean -): [Point, Point, Point, Point] { - const anchor = getCrossTeamAnchor(center, index, mobile); - const curveLift = 0.32 + index * 0.06; - const cp1 = mix(from, anchor, curveLift); - const cp2 = mix(to, anchor, curveLift); - const normal = normalize({ x: to.y - from.y, y: from.x - to.x }); - const offset = mobile ? 22 + index * 6 : 42 + index * 12; - return [ - from, - { x: cp1.x + normal.x * offset, y: cp1.y + normal.y * offset }, - { x: cp2.x + normal.x * offset, y: cp2.y + normal.y * offset }, - to, - ]; -} - -function getCrossTeamAnchor(center: Point, index: number, mobile: boolean): Point { - const horizontalOffset = mobile ? 108 : 178; - const topOffset = mobile ? 94 : 138; - const lowerOffset = mobile ? 106 : 112; - if (index === 0) { - return { - x: center.x, - y: center.y - topOffset, - }; - } - if (index === 1) { - return { - x: center.x + horizontalOffset, - y: center.y + lowerOffset, - }; - } - return { - x: center.x - horizontalOffset, - y: center.y + lowerOffset, - }; -} - -function clearCentralContentReserve( - ctx: CanvasRenderingContext2D, - center: Point, - mobile: boolean -): void { - const width = mobile ? 260 : 330; - const height = mobile ? 166 : 184; - const y = center.y + (mobile ? 12 : 10); - ctx.save(); - ctx.globalCompositeOperation = 'destination-out'; - roundRectPath(ctx, center.x - width / 2, y - height / 2, width, height, mobile ? 32 : 40); - ctx.fillStyle = 'rgba(0, 0, 0, 0.98)'; - ctx.fill(); - - const glow = ctx.createRadialGradient(center.x, y, 8, center.x, y, width * 0.62); - glow.addColorStop(0, 'rgba(0, 0, 0, 0.96)'); - glow.addColorStop(0.68, 'rgba(0, 0, 0, 0.9)'); - glow.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = glow; - roundRectPath(ctx, center.x - width / 2, y - height / 2, width, height, mobile ? 32 : 40); - ctx.fill(); - ctx.restore(); +function makeStraightCurve(from: Point, to: Point): [Point, Point, Point, Point] { + return [from, mix(from, to, 0.33), mix(from, to, 0.66), to]; } function createDepthParticles(width: number, height: number): DepthParticle[] { @@ -871,15 +870,6 @@ function withAlpha(hex: string, alpha: number): string { return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`; } -function mixColor(hexA: string, hexB: string, amount: number): string { - const a = hexToRgb(normalizeHex(hexA)); - const b = hexToRgb(normalizeHex(hexB)); - const t = clamp(amount, 0, 1); - return `rgb(${Math.round(a.r + (b.r - a.r) * t)}, ${Math.round( - a.g + (b.g - a.g) * t - )}, ${Math.round(a.b + (b.b - a.b) * t)})`; -} - function normalizeHex(hex: string): string { if (/^#[0-9a-fA-F]{6}$/.test(hex)) return hex; if (/^#[0-9a-fA-F]{3}$/.test(hex)) { @@ -887,11 +877,3 @@ function normalizeHex(hex: string): string { } return '#ffffff'; } - -function hexToRgb(hex: string): { r: number; g: number; b: number } { - return { - r: Number.parseInt(hex.slice(1, 3), 16), - g: Number.parseInt(hex.slice(3, 5), 16), - b: Number.parseInt(hex.slice(5, 7), 16), - }; -} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index c9b81bbf..35185172 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -45,7 +45,6 @@ import { MEMBER_SPAWN_STATUS_REFRESH_MS, } from '@renderer/utils/memberSpawnStatusPolling'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; -import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; @@ -53,6 +52,7 @@ import { buildTaskChangeRequestOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; +import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { deriveContextMetrics } from '@shared/utils/contextMetrics'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index 2c6ebf9c..828b7982 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -1,15 +1,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; +import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow'; import { buildMembersFromDrafts, + createMemberDraft, createMemberDraftsFromInputs, filterEditableMemberInputs, - createMemberDraft, MembersEditorSection, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; -import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -33,8 +33,8 @@ import { Loader2 } from 'lucide-react'; import { buildEditTeamSourceSnapshot, - getMemberRuntimeContractKey, getLiveRosterIdentityChanges, + getMemberRuntimeContractKey, getMembersRequiringRuntimeRestart, } from './editTeamRuntimeChanges'; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index ba760d20..a73d8b7e 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -46,12 +46,12 @@ import { TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; +import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; import { buildTaskChangeRequestOptions, buildTaskChangeSignature, deriveTaskSince, } from '@renderer/utils/taskChangeRequest'; -import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; @@ -158,7 +158,7 @@ export const TaskDetailDialog = ({ const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesError, setTaskChangesError] = useState(null); const loadedTaskChangeSummaryKeyRef = useRef(null); - const taskChangesLoadInFlightRef = useRef(false); + const taskChangesLoadInFlightKeysRef = useRef>(new Set()); const currentTaskChangeSummaryKeyRef = useRef(null); // Inline editing: subject @@ -384,12 +384,7 @@ export const TaskDetailDialog = ({ setTaskChangesFiles(data?.files ?? null); const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; if (currentTask && taskChangeRequestOptions) { - recordTaskChangePresence( - teamName, - currentTask.id, - taskChangeRequestOptions, - nextPresence - ); + recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence); } if (currentTask) { setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence ?? 'unknown'); @@ -415,7 +410,6 @@ export const TaskDetailDialog = ({ preserveFilesOnError?: boolean; } = {}): Promise => { const requestKey = currentTaskChangeSummaryKeyRef.current; - if (taskChangesLoadInFlightRef.current) return; if ( !requestKey || !currentTask || @@ -424,8 +418,9 @@ export const TaskDetailDialog = ({ !onViewChanges ) return; + if (taskChangesLoadInFlightKeysRef.current.has(requestKey)) return; - taskChangesLoadInFlightRef.current = true; + taskChangesLoadInFlightKeysRef.current.add(requestKey); if (showSpinner) { setTaskChangesLoading(true); } @@ -448,8 +443,8 @@ export const TaskDetailDialog = ({ error instanceof Error ? error.message : 'Failed to load task changes summary' ); } finally { - taskChangesLoadInFlightRef.current = false; - if (showSpinner) { + taskChangesLoadInFlightKeysRef.current.delete(requestKey); + if (showSpinner && currentTaskChangeSummaryKeyRef.current === requestKey) { setTaskChangesLoading(false); } } @@ -1250,7 +1245,7 @@ export const TaskDetailDialog = ({ ))}
) : changesSectionOpen ? ( -

No file changes detected

+

No file changes recorded

) : null} ) : null} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index d66d1d95..9e26b7b1 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -19,8 +19,8 @@ import { } from '@renderer/utils/geminiUiFreeze'; import { getAvailableTeamProviderModelOptions, - isTeamProviderModelVerificationPending, getTeamModelUiDisabledReason, + isTeamProviderModelVerificationPending, normalizeTeamModelForUi, TEAM_MODEL_UI_DISABLED_BADGE_LABEL, } from '@renderer/utils/teamModelAvailability'; diff --git a/src/renderer/components/team/dialogs/providerPrepareShortLivedCache.ts b/src/renderer/components/team/dialogs/providerPrepareShortLivedCache.ts index 37ef2032..64ec571b 100644 --- a/src/renderer/components/team/dialogs/providerPrepareShortLivedCache.ts +++ b/src/renderer/components/team/dialogs/providerPrepareShortLivedCache.ts @@ -1,13 +1,12 @@ -import type { TeamProviderId } from '@shared/types'; - import type { ProviderPrepareDiagnosticsModelResult } from './providerPrepareDiagnostics'; +import type { TeamProviderId } from '@shared/types'; const OPENCODE_DEEP_VERIFY_SUCCESS_CACHE_TTL_MS = 45_000; -type ShortLivedProviderPrepareCacheEntry = { +interface ShortLivedProviderPrepareCacheEntry { expiresAt: number; modelResultsById: Record; -}; +} const shortLivedProviderPrepareCache = new Map(); diff --git a/src/renderer/components/team/members/LeadModelRow.test.tsx b/src/renderer/components/team/members/LeadModelRow.test.tsx index 18557d00..24a40296 100644 --- a/src/renderer/components/team/members/LeadModelRow.test.tsx +++ b/src/renderer/components/team/members/LeadModelRow.test.tsx @@ -1,10 +1,9 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - import { getTeamColorSet } from '@renderer/constants/teamColors'; import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({ ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }), diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index 67cd094a..bee30ba9 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector'; import { CodexFastModeSelector } from '@renderer/components/team/dialogs/CodexFastModeSelector'; -import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; import { diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 552e59b2..1d251ed8 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -190,7 +190,7 @@ export const MemberCard = ({
({ useTheme: () => ({ isLight: false }), })); -import { createMemberDraft } from './membersEditorUtils'; import { MemberDraftRow } from './MemberDraftRow'; +import { createMemberDraft } from './membersEditorUtils'; function renderMemberDraftRow(props: Partial> = {}): { host: HTMLDivElement; diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 6633748d..6dfacad7 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -3,10 +3,10 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Label } from '@renderer/components/ui/label'; -import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; -import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; +import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { GitBranch, Plus } from 'lucide-react'; import { MembersJsonEditor } from '../dialogs/MembersJsonEditor'; diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 2678550b..004cdcec 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -14,8 +14,8 @@ import { buildSelectionAction } from '@renderer/utils/buildSelectionAction'; import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo'; import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder'; import { displayMemberName } from '@renderer/utils/memberHelpers'; -import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey'; import { buildReviewDecisionScopeToken } from '@renderer/utils/reviewDecisionScope'; +import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey'; import { buildTaskChangeSignature, type TaskChangeRequestOptions, @@ -27,7 +27,10 @@ import { ChangesLoadingAnimation } from './ChangesLoadingAnimation'; import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils'; import { ContinuousScrollView } from './ContinuousScrollView'; import { FileEditTimeline } from './FileEditTimeline'; +import { buildInitialReviewFileScrollKey } from './initialReviewFileScroll'; import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp'; +import { buildPathChangeLabels } from './pathChangeLabels'; +import { resolveReviewFilePath } from './reviewFilePathResolution'; import { ReviewFileTree } from './ReviewFileTree'; import { ReviewToolbar } from './ReviewToolbar'; import { ScopeWarningBanner } from './ScopeWarningBanner'; @@ -259,7 +262,7 @@ export const ChangeReviewDialog = ({ }, [activeFilePath]); // One-shot scroll-to-file ref (for initialFilePath) - const initialScrollDoneRef = useRef(false); + const initialScrollDoneKeyRef = useRef(null); // Continuous scroll navigation const { scrollToFile, isProgrammaticScroll } = useContinuousScrollNav({ @@ -300,83 +303,7 @@ export const ChangeReviewDialog = ({ const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]); const pathChangeLabels = useMemo(() => { - if (!activeChangeSet) - return {} as Record< - string, - | { kind: 'deleted' } - | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string } - >; - - const normalize = (s: string): string => - s.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd(); - const hashFull = (s: string): string => { - // DJB2 (full string) — good enough for heuristic rename/move pairing - let h = 5381; - for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; - return (h >>> 0).toString(36); - }; - const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p; - - type Label = - | { kind: 'deleted' } - | { kind: 'moved' | 'renamed'; direction: 'from' | 'to'; otherPath: string }; - - const out: Record = {}; - - const deletedCandidates: { file: FileChangeSummary; hash: string }[] = []; - const newCandidates: { file: FileChangeSummary; hash: string }[] = []; - - for (const f of activeChangeSet.files) { - const content = fileContents[f.filePath]; - if (!content) continue; - - const modified = content.modifiedFullContent; - const original = content.originalFullContent; - - if (!f.isNewFile && modified == null) { - if (original != null) { - deletedCandidates.push({ file: f, hash: hashFull(normalize(original)) }); - } else { - out[f.filePath] = { kind: 'deleted' }; - } - } - - if (f.isNewFile && modified != null) { - newCandidates.push({ file: f, hash: hashFull(normalize(modified)) }); - } - } - - const deletedByHash = new Map(); - for (const d of deletedCandidates) { - const prev = deletedByHash.get(d.hash); - deletedByHash.set(d.hash, { file: d.file, count: (prev?.count ?? 0) + 1 }); - } - - const usedDeleted = new Set(); - for (const n of newCandidates) { - const entry = deletedByHash.get(n.hash); - if (!entry) continue; - if (entry.count !== 1) continue; // ambiguous - const oldFile = entry.file; - if (usedDeleted.has(oldFile.filePath)) continue; - usedDeleted.add(oldFile.filePath); - - const oldName = baseName(oldFile.relativePath); - const newName = baseName(n.file.relativePath); - const kind: 'moved' | 'renamed' = - oldName === newName && oldFile.relativePath !== n.file.relativePath ? 'moved' : 'renamed'; - - out[n.file.filePath] = { kind, direction: 'from', otherPath: oldFile.relativePath }; - out[oldFile.filePath] = { kind, direction: 'to', otherPath: n.file.relativePath }; - } - - for (const d of deletedCandidates) { - if (!usedDeleted.has(d.file.filePath) && !(d.file.filePath in out)) { - out[d.file.filePath] = { kind: 'deleted' }; - } - } - - return out; + return buildPathChangeLabels(activeChangeSet?.files ?? [], fileContents); }, [activeChangeSet, fileContents]); const { @@ -934,19 +861,16 @@ export const ChangeReviewDialog = ({ clearDecisionsFromDisk, ]); - // Reset initial scroll flag when initialFilePath changes - useEffect(() => { - initialScrollDoneRef.current = false; - }, [initialFilePath]); - // Scroll to initialFilePath once data is loaded useEffect(() => { - if (!activeChangeSet || !initialFilePath || initialScrollDoneRef.current) return; - const hasFile = activeChangeSet.files.some((f) => f.filePath === initialFilePath); - if (!hasFile) return; - initialScrollDoneRef.current = true; + const scrollKey = buildInitialReviewFileScrollKey(activeChangeSet, initialFilePath); + if (!activeChangeSet || !initialFilePath || !scrollKey) return; + if (initialScrollDoneKeyRef.current === scrollKey) return; + const targetFilePath = resolveReviewFilePath(activeChangeSet.files, initialFilePath); + if (!targetFilePath) return; + initialScrollDoneKeyRef.current = scrollKey; requestAnimationFrame(() => { - requestAnimationFrame(() => scrollToFile(initialFilePath)); + requestAnimationFrame(() => scrollToFile(targetFilePath)); }); }, [activeChangeSet, initialFilePath, scrollToFile]); diff --git a/src/renderer/components/team/review/ChangesLoadingAnimation.tsx b/src/renderer/components/team/review/ChangesLoadingAnimation.tsx index e4ab9d35..b28185d8 100644 --- a/src/renderer/components/team/review/ChangesLoadingAnimation.tsx +++ b/src/renderer/components/team/review/ChangesLoadingAnimation.tsx @@ -2,35 +2,33 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Check, FileCode, FileDiff, FileText, GitBranch, GitCommit, Search } from 'lucide-react'; -/* ── Fake diff lines for the mini-terminal ─────────────────────────── */ +/* Fake diff lines for the mini-terminal */ const diffLines = [ - { type: '+', text: 'export function resolveHunk(ctx)' }, - { type: '-', text: 'const legacy = parseDiff(raw)' }, - { type: ' ', text: ' const chunks = split(input)' }, - { type: '+', text: ' return mergeResults(a, b)' }, - { type: '-', text: ' if (old) return fallback()' }, - { type: '+', text: 'interface DiffRange { start: number }' }, - { type: ' ', text: ' for (const h of hunks) {' }, - { type: '+', text: ' yield computeDelta(h)' }, - { type: '-', text: ' emit("change", raw)' }, - { type: ' ', text: ' const meta = getFileInfo(p)' }, - { type: '+', text: ' await writePatched(output)' }, - { type: '-', text: ' delete cache[staleKey]' }, - { type: '+', text: 'type HunkMeta = { offset: number }' }, - { type: ' ', text: ' return ctx.finalize()' }, + { type: '+', text: 'const bundle = readTaskLedger(taskId)' }, + { type: ' ', text: ' const files = bundle.files' }, + { type: '+', text: ' const state = resolveFileState(file)' }, + { type: ' ', text: ' if (state.textAvailable) {' }, + { type: '+', text: ' renderExactDiff(state.before, state.after)' }, + { type: ' ', text: ' }' }, + { type: '+', text: ' markManualOnly(metadataOnly)' }, + { type: '+', text: 'interface LedgerState { sha256: string }' }, + { type: ' ', text: ' for (const event of journal) {' }, + { type: '+', text: ' attachWorktreeMeta(event)' }, + { type: ' ', text: ' const relation = detectRename(file)' }, + { type: '+', text: ' verifyExpectedHash(file)' }, + { type: ' ', text: ' return reviewModel' }, { type: '+', text: ' const diff = computeLineDiff(a, b)' }, - { type: '-', text: ' throw new Error("parse failed")' }, ]; -/* ── Phases ─────────────────────────────────────────────────────────── */ +/* Phases */ const phases = [ - { icon: Search, label: 'Scanning repository…', accent: 'rgba(147,197,253,0.7)' }, - { icon: FileDiff, label: 'Computing diffs…', accent: 'rgba(253,186,116,0.7)' }, - { icon: GitBranch, label: 'Resolving branches…', accent: 'rgba(167,139,250,0.7)' }, - { icon: FileCode, label: 'Analyzing hunks…', accent: 'rgba(110,231,183,0.7)' }, + { icon: Search, label: 'Reading task ledger...', accent: 'rgba(147,197,253,0.7)' }, + { icon: FileDiff, label: 'Resolving file states...', accent: 'rgba(253,186,116,0.7)' }, + { icon: GitBranch, label: 'Checking worktree context...', accent: 'rgba(167,139,250,0.7)' }, + { icon: FileCode, label: 'Preparing review diffs...', accent: 'rgba(110,231,183,0.7)' }, ]; -/* ── Orbiting icons ─────────────────────────────────────────────────── */ +/* Orbiting icons */ const orbitItems = [ { Icon: FileText, angle: 0, r: 76, size: 13, speed: 18 }, { Icon: FileDiff, angle: 60, r: 76, size: 14, speed: 18 }, @@ -40,7 +38,7 @@ const orbitItems = [ { Icon: Check, angle: 300, r: 76, size: 12, speed: 18 }, ]; -/* ── Spark particles ────────────────────────────────────────────────── */ +/* Spark particles */ const SPARK_COUNT = 12; const useSparks = () => { @@ -70,7 +68,7 @@ const useSparks = () => { return sparks; }; -/* ── Fake file counter ──────────────────────────────────────────────── */ +/* Fake file counter */ const useFileCounter = () => { const [count, setCount] = useState(0); useEffect(() => { @@ -88,7 +86,7 @@ const useFileCounter = () => { return count; }; -/* ── Component ──────────────────────────────────────────────────────── */ +/* Component */ export const ChangesLoadingAnimation = (): React.JSX.Element => { const [phaseIdx, setPhaseIdx] = useState(0); const [phaseFading, setPhaseFading] = useState(false); @@ -128,7 +126,7 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => { return (
- {/* ── Main scene ─────────────────────────────────────────── */} + {/* Main scene */}
{/* Faint radial grid */} @@ -220,7 +218,7 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => { style={{ background: phase.accent, opacity: 0.05 }} /> - {/* ── Center card: mini diff terminal ──────────────────── */} + {/* Center card: mini diff terminal */}
{/* Title bar */}
@@ -274,7 +272,7 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => {
- {/* ── Phase indicator ────────────────────────────────────── */} + {/* Phase indicator */}
{phases.map((p, i) => (
{ ))}
- {/* ── Bottom text ────────────────────────────────────────── */} + {/* Bottom text */}

{ {phase.label}

- {fileCount} objects processed + {fileCount} ledger objects processed

- {/* ── Keyframes ──────────────────────────────────────────── */} + {/* Keyframes */}