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/ci.yml b/.github/workflows/ci.yml
index 312e897c..8bede37e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -71,7 +71,7 @@ jobs:
run: npx eslint src/ --fix --no-cache || true
- name: Validate workspace truth gate
- run: pnpm check
+ run: pnpm check:ci
test:
strategy:
@@ -97,7 +97,7 @@ jobs:
run: pnpm install --no-frozen-lockfile
- name: Test
- run: pnpm test:workspace
+ run: pnpm test:workspace:ci
task-change-ledger-windows:
name: Task change ledger Windows smoke
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..669e9c28 100644
--- a/README.md
+++ b/README.md
@@ -3,14 +3,14 @@
-
+
-
+
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) |
@@ -320,6 +320,7 @@ pnpm dist # macOS + Windows + Linux
- [ ] Run terminal commands
- [ ] Monitor agents processes/stats
- [ ] Reusable agents with SOUL.md
+- [ ] Сommunicate via messenger
---
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/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js
index e2fb3a12..9b9a3834 100644
--- a/agent-teams-controller/src/internal/crossTeam.js
+++ b/agent-teams-controller/src/internal/crossTeam.js
@@ -48,25 +48,19 @@ function normalizeMetaMembers(rawMembers) {
}
function resolveTargetLead(paths, config) {
- // 1. config.members — agentType check
+ // 1. config.members - canonical lead detection shared with queue routing
if (config && config.members && config.members.length) {
- const lead = config.members.find((m) => m && m.agentType === 'team-lead');
+ const lead = config.members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
if (lead && lead.name) return String(lead.name).trim();
-
- // 2. config.members — name check
- const namedLead = config.members.find((m) => m && m.name === 'team-lead');
- if (namedLead && namedLead.name) return String(namedLead.name).trim();
}
- // 3. members.meta.json — WITH normalization (trim + dedup)
+ // 2. members.meta.json - WITH normalization (trim + dedup)
const metaPath = path.join(paths.teamDir, 'members.meta.json');
try {
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const members = normalizeMetaMembers(raw && raw.members);
if (members.length > 0) {
- const metaLead = members.find(
- (m) => m.agentType === 'team-lead' || m.name === 'team-lead'
- );
+ const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
if (metaLead && metaLead.name) return metaLead.name;
return members[0].name;
}
@@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) {
/* ENOENT or parse error */
}
- // 4. role-based (legacy compat)
+ // 3. First configured member
if (config && config.members && config.members.length) {
- const roleLead = config.members.find(
- (m) => m && m.role && String(m.role).toLowerCase().includes('lead')
- );
- if (roleLead && roleLead.name) return String(roleLead.name).trim();
- // 5. First member
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
}
@@ -101,16 +90,6 @@ function normalizeForDedupe(value) {
.toLowerCase();
}
-function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary) {
- return [
- normalizeForDedupe(fromTeam),
- normalizeForDedupe(fromMember),
- normalizeForDedupe(toTeam),
- normalizeForDedupe(summary),
- normalizeForDedupe(text),
- ].join('||');
-}
-
function getCrossTeamMessageDedupeKey(message) {
if (!message || typeof message !== 'object') return '';
return buildCrossTeamDedupeKey(
@@ -118,10 +97,44 @@ function getCrossTeamMessageDedupeKey(message) {
message.fromMember,
message.toTeam,
message.text,
- message.summary
+ message.summary,
+ message.taskRefs
);
}
+function normalizeTaskRefs(taskRefs) {
+ if (!Array.isArray(taskRefs) || taskRefs.length === 0) {
+ return undefined;
+ }
+
+ const normalized = taskRefs
+ .filter((item) => item && typeof item === 'object')
+ .map((item) => ({
+ taskId: String(item.taskId || '').trim(),
+ displayId: String(item.displayId || '').trim(),
+ teamName: String(item.teamName || '').trim(),
+ }))
+ .filter((item) => item.taskId && item.displayId && item.teamName);
+
+ return normalized.length > 0 ? normalized : undefined;
+}
+
+function normalizeTaskRefsForDedupe(taskRefs) {
+ const normalized = normalizeTaskRefs(taskRefs);
+ return normalized ? JSON.stringify(normalized) : '';
+}
+
+function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary, taskRefs) {
+ return [
+ normalizeForDedupe(fromTeam),
+ normalizeForDedupe(fromMember),
+ normalizeForDedupe(toTeam),
+ normalizeForDedupe(summary),
+ normalizeForDedupe(text),
+ normalizeTaskRefsForDedupe(taskRefs),
+ ].join('||');
+}
+
function findRecentDuplicate(outboxList, dedupeKey) {
if (!Array.isArray(outboxList) || !dedupeKey) return null;
const cutoff = Date.now() - CROSS_TEAM_DEDUPE_WINDOW_MS;
@@ -141,7 +154,7 @@ function findRecentDuplicate(outboxList, dedupeKey) {
function sendCrossTeamMessage(context, flags) {
const fromTeam = context.teamName;
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
- const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
+ const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : '';
const replyToConversationId =
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
const conversationId =
@@ -151,11 +164,16 @@ function sendCrossTeamMessage(context, flags) {
const text = typeof flags.text === 'string' ? flags.text : '';
const summary = typeof flags.summary === 'string' ? flags.summary.trim() : undefined;
const chainDepth = typeof flags.chainDepth === 'number' ? flags.chainDepth : 0;
+ const taskRefs = normalizeTaskRefs(flags.taskRefs);
// Validate
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
throw new Error(`Invalid fromTeam: ${fromTeam}`);
}
+ const sourceConfig = runtimeHelpers.readTeamConfig(context.paths);
+ if (!sourceConfig || sourceConfig.deletedAt) {
+ throw new Error(`Source team not found: ${fromTeam}`);
+ }
if (!TEAM_NAME_PATTERN.test(toTeam)) {
throw new Error(`Invalid toTeam: ${toTeam}`);
}
@@ -165,6 +183,11 @@ function sendCrossTeamMessage(context, flags) {
if (!text || text.trim().length === 0) {
throw new Error('Message text is required');
}
+ const fromMember = rawFromMember
+ ? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', {
+ allowLeadAliases: true,
+ })
+ : runtimeHelpers.inferLeadName(context.paths);
// Target context + config
const targetContext = createTargetContext(context, toTeam);
@@ -186,7 +209,7 @@ function sendCrossTeamMessage(context, flags) {
});
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
const timestamp = new Date().toISOString();
- const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
+ const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary, taskRefs);
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
@@ -219,6 +242,7 @@ function sendCrossTeamMessage(context, flags) {
source: CROSS_TEAM_SOURCE,
conversationId: resolvedConversationId,
replyToConversationId: replyToConversationId || undefined,
+ ...(taskRefs ? { taskRefs } : {}),
});
writeJson(inboxPath, list);
});
@@ -240,6 +264,7 @@ function sendCrossTeamMessage(context, flags) {
source: CROSS_TEAM_SENT_SOURCE,
conversationId: resolvedConversationId,
replyToConversationId: replyToConversationId || undefined,
+ ...(taskRefs ? { taskRefs } : {}),
});
outList.push({
@@ -250,6 +275,7 @@ function sendCrossTeamMessage(context, flags) {
conversationId: resolvedConversationId,
replyToConversationId: replyToConversationId || undefined,
text,
+ ...(taskRefs ? { taskRefs } : {}),
summary,
chainDepth,
timestamp,
diff --git a/agent-teams-controller/src/internal/kanban.js b/agent-teams-controller/src/internal/kanban.js
index 46d49d6d..d91912c5 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,9 +101,12 @@ function listReviewers(context) {
function addReviewer(context, reviewer) {
return withTeamBoardLock(context.paths, () => {
+ const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
+ allowLeadAliases: true,
+ });
const state = getKanbanState(context);
const next = new Set(state.reviewers);
- next.add(String(reviewer));
+ next.add(String(resolvedReviewer));
kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state,
reviewers: [...next],
@@ -42,7 +118,13 @@ function addReviewer(context, reviewer) {
function removeReviewer(context, reviewer) {
return withTeamBoardLock(context.paths, () => {
const state = getKanbanState(context);
- const next = state.reviewers.filter((entry) => entry !== reviewer);
+ const resolvedReviewer = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, reviewer, {
+ allowLeadAliases: true,
+ });
+ const reviewerNames = new Set(
+ [reviewer, resolvedReviewer].filter((entry) => typeof entry === 'string' && entry.trim())
+ );
+ const next = state.reviewers.filter((entry) => !reviewerNames.has(entry));
kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state,
reviewers: next,
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/memberMessagingProtocol.js b/agent-teams-controller/src/internal/memberMessagingProtocol.js
new file mode 100644
index 00000000..2d8da105
--- /dev/null
+++ b/agent-teams-controller/src/internal/memberMessagingProtocol.js
@@ -0,0 +1,59 @@
+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: '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: '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 && (member.providerId || member.provider)) || '')
+ .trim()
+ .toLowerCase();
+ if (provider) return provider === 'opencode';
+ const model = String((member && member.model) || '').trim().toLowerCase();
+ return model.startsWith('opencode/');
+}
+
+module.exports = {
+ createMemberMessagingProtocol,
+ isOpenCodeMember,
+ normalizeRuntimeProvider,
+};
diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js
index 9a88f8fe..ff724899 100644
--- a/agent-teams-controller/src/internal/messageStore.js
+++ b/agent-teams-controller/src/internal/messageStore.js
@@ -71,6 +71,48 @@ function normalizeTaskRefs(taskRefs) {
return normalized.length > 0 ? normalized : undefined;
}
+function normalizeMessageKind(messageKind) {
+ return messageKind === 'default' ||
+ messageKind === 'slash_command' ||
+ messageKind === 'slash_command_result' ||
+ messageKind === 'task_comment_notification'
+ ? messageKind
+ : undefined;
+}
+
+function normalizeSlashCommand(slashCommand) {
+ if (!slashCommand || typeof slashCommand !== 'object') {
+ return undefined;
+ }
+ const name = String(slashCommand.name || '').trim();
+ const command = String(slashCommand.command || '').trim();
+ if (!name || !command) {
+ return undefined;
+ }
+ return {
+ name,
+ command,
+ ...(typeof slashCommand.args === 'string' ? { args: slashCommand.args } : {}),
+ ...(typeof slashCommand.knownDescription === 'string'
+ ? { knownDescription: slashCommand.knownDescription }
+ : {}),
+ };
+}
+
+function normalizeCommandOutput(commandOutput) {
+ if (!commandOutput || typeof commandOutput !== 'object') {
+ return undefined;
+ }
+ const stream = commandOutput.stream === 'stdout' || commandOutput.stream === 'stderr'
+ ? commandOutput.stream
+ : undefined;
+ const commandLabel = String(commandOutput.commandLabel || '').trim();
+ if (!stream || !commandLabel) {
+ return undefined;
+ }
+ return { stream, commandLabel };
+}
+
function buildMessage(flags, defaults) {
const timestamp =
typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
@@ -80,6 +122,9 @@ function buildMessage(flags, defaults) {
: crypto.randomUUID();
const attachments = normalizeAttachments(flags.attachments);
const taskRefs = normalizeTaskRefs(flags.taskRefs);
+ const messageKind = normalizeMessageKind(flags.messageKind);
+ const slashCommand = normalizeSlashCommand(flags.slashCommand);
+ const commandOutput = normalizeCommandOutput(flags.commandOutput);
return {
from:
@@ -91,9 +136,15 @@ function buildMessage(flags, defaults) {
timestamp,
read: defaults.read,
...(taskRefs ? { taskRefs } : {}),
+ ...(flags.actionMode === 'do' || flags.actionMode === 'ask' || flags.actionMode === 'delegate'
+ ? { actionMode: flags.actionMode }
+ : {}),
...(typeof flags.summary === 'string' && flags.summary.trim()
? { summary: flags.summary.trim() }
: {}),
+ ...(typeof flags.commentId === 'string' && flags.commentId.trim()
+ ? { commentId: flags.commentId.trim() }
+ : {}),
...(typeof flags.relayOfMessageId === 'string' && flags.relayOfMessageId.trim()
? { relayOfMessageId: flags.relayOfMessageId.trim() }
: {}),
@@ -121,6 +172,9 @@ function buildMessage(flags, defaults) {
})),
}
: {}),
+ ...(messageKind ? { messageKind } : {}),
+ ...(slashCommand ? { slashCommand } : {}),
+ ...(commandOutput ? { commandOutput } : {}),
...(attachments ? { attachments } : {}),
messageId,
};
@@ -235,4 +289,3 @@ module.exports = {
lookupMessage,
sendInboxMessage,
};
-
diff --git a/agent-teams-controller/src/internal/messages.js b/agent-teams-controller/src/internal/messages.js
index 9aab43f4..a35e10fe 100644
--- a/agent-teams-controller/src/internal/messages.js
+++ b/agent-teams-controller/src/internal/messages.js
@@ -1,7 +1,90 @@
const messageStore = require('./messageStore.js');
+const runtimeHelpers = require('./runtimeHelpers.js');
+
+const PLACEHOLDER_TASK_REF_PREFIX = /^\s*#0{8}\b\s*(?:[:.-]\s*)?/i;
+
+function stripPlaceholderTaskRefPrefix(value) {
+ if (typeof value !== 'string' || !PLACEHOLDER_TASK_REF_PREFIX.test(value)) {
+ return value;
+ }
+ return value.replace(PLACEHOLDER_TASK_REF_PREFIX, '').trimStart();
+}
+
+function normalizePlaceholderTaskRefPrefixes(flags) {
+ const next = { ...(flags || {}) };
+ if (typeof next.text === 'string') {
+ const strippedText = stripPlaceholderTaskRefPrefix(next.text);
+ next.text = strippedText.trim() ? strippedText : next.text;
+ }
+ if (typeof next.summary === 'string') {
+ next.summary = stripPlaceholderTaskRefPrefix(next.summary);
+ }
+ return next;
+}
+
+function normalizeMessageSendFlags(context, flags) {
+ const next = { ...(flags || {}) };
+ const rawTo =
+ (typeof next.member === 'string' && next.member.trim()) ||
+ (typeof next.to === 'string' && next.to.trim()) ||
+ '';
+
+ if (!rawTo) {
+ throw new Error('message_send requires to');
+ }
+
+ if (rawTo.toLowerCase() === 'user') {
+ next.to = 'user';
+ delete next.member;
+ } else {
+ const resolvedTo = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, rawTo, {
+ allowLeadAliases: true,
+ });
+ if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient(rawTo)) {
+ throw new Error('message_send cannot target cross_team_send. Use cross_team_send with toTeam.');
+ }
+ if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamRecipient(rawTo)) {
+ throw new Error('message_send cannot target another team. 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 = resolvedTo;
+ }
+
+ 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,
+ });
+ } else {
+ next.from = 'user';
+ }
+ }
+
+ return next;
+}
+
+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,
+ });
+}
function sendMessage(context, flags) {
- return messageStore.sendInboxMessage(context.paths, flags);
+ const normalized = normalizeMessageSendFlags(context, normalizePlaceholderTaskRefPrefixes(flags));
+ assertUserDirectedMessageHasSender(context, normalized);
+ return messageStore.sendInboxMessage(context.paths, normalized);
}
function appendSentMessage(context, flags) {
diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js
index d66c40b9..1add991d 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,163 @@ 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;
+ return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
+ allowLeadAliases: true,
+ });
+}
+
+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 +198,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 +218,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 +283,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 +300,17 @@ 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') ||
+ resolveKnownActorName(context, 'team-lead', 'review requester');
+ const rawReviewer = getReviewer(context, flags);
+ const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
+ const prevReviewState = getEffectiveReviewState(context, currentTask);
+ if (prevReviewState === 'approved') {
+ throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
+ }
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..0eb5465e
--- /dev/null
+++ b/agent-teams-controller/src/internal/reviewState.js
@@ -0,0 +1,139 @@
+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 status = typeof task?.status === 'string' ? task.status.trim() : '';
+ const normalizeFallback = (state, source) => {
+ const normalized = normalizeReviewState(state);
+ if (normalized === 'none') {
+ return null;
+ }
+
+ if (status === 'in_progress' || status === 'deleted') {
+ return {
+ state: 'none',
+ source: `${source}_status_reset`,
+ };
+ }
+
+ if (status === 'pending') {
+ return normalized === 'needsFix'
+ ? { state: 'needsFix', source: `${source}_pending_needs_fix` }
+ : { state: 'none', source: `${source}_pending_reset` };
+ }
+
+ if (status === 'completed') {
+ return normalized === 'review' || normalized === 'approved'
+ ? { state: normalized, source }
+ : { state: 'none', source: `${source}_completed_reset` };
+ }
+
+ return { state: normalized, source };
+ };
+
+ const persisted = normalizeReviewState(task && task.reviewState);
+ const persistedFallback = normalizeFallback(persisted, 'task_review_state');
+ if (persistedFallback) {
+ return persistedFallback;
+ }
+
+ if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
+ const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column');
+ if (kanbanFallback) {
+ return kanbanFallback;
+ }
+ }
+
+ 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..91003302 100644
--- a/agent-teams-controller/src/internal/runtimeHelpers.js
+++ b/agent-teams-controller/src/internal/runtimeHelpers.js
@@ -5,6 +5,7 @@ const crypto = require('crypto');
const TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
+const LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']);
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_send',
'cross_team_list_targets',
@@ -85,6 +86,10 @@ function looksLikeCrossTeamToolRecipient(name) {
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(String(name || '').trim());
}
+function looksLikeCrossTeamRecipient(name) {
+ return looksLikeQualifiedExternalRecipient(name) || looksLikeCrossTeamPseudoRecipient(name);
+}
+
function getHomeDir() {
if (process.env.HOME) return process.env.HOME;
if (process.env.USERPROFILE) return process.env.USERPROFILE;
@@ -124,15 +129,68 @@ 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 (
+ LEAD_AGENT_TYPES.has(agentType) ||
+ 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) => {
+ const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
+ return LEAD_AGENT_TYPES.has(agentType);
+ }) ||
+ 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 +201,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);
}
@@ -182,6 +273,10 @@ function normalizeMemberRecord(member) {
if (!member || typeof member !== 'object') return null;
const name = typeof member.name === 'string' ? member.name.trim() : '';
if (!name) return null;
+ const copyTrimmedString = (key) =>
+ typeof member[key] === 'string' && member[key].trim()
+ ? { [key]: member[key].trim() }
+ : {};
return {
name,
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
@@ -193,6 +288,12 @@ function normalizeMemberRecord(member) {
: {}),
...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}),
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
+ ...copyTrimmedString('providerId'),
+ ...copyTrimmedString('providerBackendId'),
+ ...copyTrimmedString('provider'),
+ ...copyTrimmedString('model'),
+ ...copyTrimmedString('effort'),
+ ...copyTrimmedString('fastMode'),
...(typeof member.removedAt === 'number' ? { removedAt: member.removedAt } : {}),
};
}
@@ -207,6 +308,12 @@ function mergeResolvedMember(target, source) {
...(source.agentType ? { agentType: source.agentType } : {}),
...(source.color ? { color: source.color } : {}),
...(source.cwd ? { cwd: source.cwd } : {}),
+ ...(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 } : {}),
...(source.removedAt != null ? { removedAt: source.removedAt } : {}),
};
}
@@ -507,12 +614,18 @@ function saveTaskAttachmentFile(paths, taskId, flags) {
}
module.exports = {
+ assertExplicitTeamMemberName,
+ collectExplicitTeamMembers,
getPaths,
inferLeadName,
+ isCanonicalLeadMember,
+ looksLikeCrossTeamRecipient,
+ looksLikeCrossTeamToolRecipient,
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..2ed89b1a 100644
--- a/agent-teams-controller/src/internal/tasks.js
+++ b/agent-teams-controller/src/internal/tasks.js
@@ -6,11 +6,31 @@ const kanbanStore = require('./kanbanStore.js');
const agenda = require('./agenda.js');
const { withTeamBoardLock } = require('./boardLock.js');
const { wrapAgentBlock } = require('./agentBlocks.js');
+const {
+ createMemberMessagingProtocol,
+ isOpenCodeMember,
+} = require('./memberMessagingProtocol.js');
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();
}
@@ -53,6 +73,7 @@ function warnNonCritical(message, error) {
}
function buildAssignmentMessage(context, task, options = {}) {
+ const messagingProtocol = options.messagingProtocol || createMemberMessagingProtocol('native');
const description =
typeof options.description === 'string' && options.description.trim() ?
options.description.trim() :
@@ -76,6 +97,18 @@ function buildAssignmentMessage(context, task, options = {}) {
lines.push(``, `Instructions:`, prompt);
}
+ const notifyLeadExample = messagingProtocol.buildLeadMessageExample({
+ teamName: context.teamName,
+ leadName: '',
+ fromName: '',
+ text: `#${task.displayId || task.id} done. <2-4 sentence summary>. Full details in task comment . Moving to next task.`,
+ summary: `#${task.displayId || task.id} done`,
+ });
+ const openCodeVisibleMessageRule =
+ messagingProtocol.runtimeProvider === 'opencode'
+ ? '\n For normal visible replies, use agent-teams_message_send. Do not use SendMessage or runtime_deliver_message for ordinary replies.'
+ : '';
+
lines.push(
``,
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
@@ -89,8 +122,8 @@ function buildAssignmentMessage(context, task, options = {}) {
task_add_comment { teamName: "${context.teamName}", taskId: "${task.id}", text: "", from: "" }
The response contains comment.id (UUID). Take its first 8 characters as the short commentId.
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }
-5. After task_complete, notify your lead via SendMessage with a brief summary and a pointer to the full comment (use the short commentId from step 4).
- Example: "#${task.displayId || task.id} done. <2-4 sentence summary>. For full details: task_get_comment { taskId: \\"${task.displayId || task.id}\\", commentId: \\"\\" }. Moving to next task."`)
+5. After task_complete, notify your lead via ${messagingProtocol.sendLeadPhrase} with a brief summary and a pointer to the full comment (use the short commentId from step 4).
+ Example: ${notifyLeadExample}${openCodeVisibleMessageRule}`)
);
return lines.join('\n');
@@ -121,12 +154,23 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
return;
}
+ const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
+ const ownerMember = (resolved.members || []).find(
+ (member) => isSameMember(member && member.name, owner)
+ );
+ const messagingProtocol = createMemberMessagingProtocol(
+ isOpenCodeMember(ownerMember) ? 'opencode' : 'native'
+ );
+
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
try {
messages.sendMessage(context, {
member: owner,
from: sender,
- text: buildAssignmentMessage(context, task, options),
+ text: buildAssignmentMessage(context, task, {
+ ...options,
+ messagingProtocol,
+ }),
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
summary,
source: 'system_notification',
@@ -171,16 +215,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
}
function createTask(context, input) {
- const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
- if (input && input.notifyOwner !== false) {
+ let taskInput = input;
+ if (input && typeof input.owner === 'string' && input.owner.trim()) {
+ taskInput = {
+ ...input,
+ owner: assertKnownTaskActor(context, input.owner, 'task owner'),
+ };
+ }
+ const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput));
+ if (taskInput && taskInput.notifyOwner !== false) {
maybeNotifyAssignedOwner(context, task, {
- description: input.description,
- prompt: input.prompt,
+ description: taskInput.description,
+ prompt: taskInput.prompt,
taskRefs: [
- ...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
- ...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
+ ...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []),
+ ...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []),
],
- from: input.from,
+ from: taskInput.from,
});
}
return task;
@@ -233,18 +284,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 +401,46 @@ 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, () => {
+ const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
+ if (before.status !== 'deleted') {
+ throw new Error(`Task #${before.displayId || before.id} is not deleted; task_restore only restores deleted tasks`);
+ }
+ 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');
+ const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
return {
previousTask: before,
updatedTask: after,
@@ -520,7 +628,18 @@ function buildMemberActionModeProtocol() {
return buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION);
}
-function buildMemberTaskProtocol(teamName) {
+function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessagingProtocol('native')) {
+ const notifyLeadExample = messagingProtocol.buildLeadMessageExample({
+ teamName,
+ leadName: '',
+ fromName: '',
+ text: '#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. Full details in task comment e5f6a7b8. Moving to #efgh5678 next.',
+ summary: '#abcd1234 done',
+ });
+ const openCodeVisibleMessageRule =
+ messagingProtocol.runtimeProvider === 'opencode'
+ ? '\n - For normal visible replies, use agent-teams_message_send. Always include teamName, to, from, text, and summary. Always set from to your teammate name. Do not use SendMessage or runtime_deliver_message for ordinary replies.'
+ : '';
return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
0. IMPORTANT ID RULE:
- If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls.
@@ -537,13 +656,13 @@ function buildMemberTaskProtocol(teamName) {
- Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work.
3. Use MCP tool task_complete BEFORE sending your final reply:
{ teamName: "${teamName}", taskId: "" }
- - CRITICAL: Before calling task_complete, you MUST post a task comment with your results via task_add_comment. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.
+ - CRITICAL: Before calling task_complete, you MUST post a task comment with your results via task_add_comment. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A direct message to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only send a direct message without a task comment, the user will never see your work.
- If a new task comment means you must do more real work on that same task, FIRST add a short task comment saying what you are going to do, THEN run task_start again before doing the follow-up work.
- After that follow-up work finishes, add a short task comment with the result, what changed, or what you verified.
- After that, run task_complete again before your reply.
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
- - After task_complete, send a notification to your team lead via SendMessage. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results.
- Example: "#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. For full details: task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }. Moving to #efgh5678 next."
+ - After task_complete, send a notification to your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results.
+ Example: ${notifyLeadExample}${openCodeVisibleMessageRule}
- After task_complete, call review_request ONLY when review is explicitly expected for THIS task and a concrete reviewer is already known.
Example:
{ teamName: "${teamName}", taskId: "", from: "", reviewer: "" }
@@ -553,10 +672,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:
@@ -564,7 +683,9 @@ function buildMemberTaskProtocol(teamName) {
8. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment:
{ teamName: "${teamName}", taskId: "", text: "", from: "" }
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
-9. When sending a message about a specific task, include its short display label like # in your SendMessage summary field for traceability.
+9. When sending a message about a specific task, include its short display label like # in your ${messagingProtocol.sendToolName} summary field for traceability.
+ - If the message is NOT about a real board task, do NOT include any # task label.
+ - Never invent placeholder task refs such as #00000000 or #.
10. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234").
11. Review workflow clarity (IMPORTANT):
- The work task (e.g. #1) is the thing that must end up APPROVED after review.
@@ -581,7 +702,7 @@ function buildMemberTaskProtocol(teamName) {
{ teamName: "${teamName}", taskId: "", value: "lead" }
b) STEP 2 — THEN, add a task comment describing exactly what you need:
{ teamName: "${teamName}", taskId: "", text: "question / blocker / missing info", from: "" }
- c) STEP 3 — THEN, send a message to your team lead via SendMessage so they notice it promptly.
+ c) STEP 3 — THEN, send a message to your team lead via ${messagingProtocol.sendLeadPhrase} so they notice it promptly.
IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board.
d) The clarification flag is durable until it is cleared explicitly.
When the blocker is truly resolved, clear the flag yourself with:
@@ -646,7 +767,7 @@ function normalizeMemberName(value) {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
}
-async function memberBriefing(context, memberName) {
+async function memberBriefing(context, memberName, options = {}) {
const requestedMemberName = String(memberName).trim();
const requestedMemberKey = normalizeMemberName(requestedMemberName);
const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
@@ -701,6 +822,9 @@ async function memberBriefing(context, memberName) {
}
const leadName = runtimeHelpers.inferLeadName(context.paths);
const effectiveMember = member;
+ const messagingProtocol = createMemberMessagingProtocol(
+ options.runtimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native')
+ );
const role =
typeof effectiveMember.role === 'string' && effectiveMember.role.trim() ?
@@ -729,12 +853,25 @@ async function memberBriefing(context, memberName) {
);
const taskQueue = await taskBriefing(context, requestedMemberName);
+ const completionNotifyExample = messagingProtocol.buildLeadMessageExample({
+ teamName: context.teamName,
+ leadName,
+ fromName: requestedMemberName,
+ text: '#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678.',
+ summary: '#abcd1234 done',
+ });
const lines = [
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
`Role: ${role}.`,
`CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`,
- `CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.`,
- `After task_complete, notify your team lead via SendMessage. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: "#abcd1234 done. Found 3 competitors, two lack kanban. For full details: task_get_comment { taskId: \\"abcd1234\\", commentId: \\"e5f6a7b8\\" }. Moving to #efgh5678."`,
+ `CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A direct message to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only send a direct message without a task comment, the user will never see your work.`,
+ `After task_complete, notify your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: ${completionNotifyExample}`,
+ ...(messagingProtocol.runtimeProvider === 'opencode'
+ ? [
+ 'OpenCode visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.',
+ 'For cross-team replies or messages to another team, call agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to.',
+ ]
+ : []),
`CRITICAL: A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are already finishing another task, blocked, or still need more context, leave a short task comment on the waiting task immediately with the reason and your best ETA or what you are waiting on, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.`,
`Team lead: ${leadName}.`,
buildMemberLanguageInstruction(config),
@@ -766,7 +903,7 @@ async function memberBriefing(context, memberName) {
'',
buildMemberFormattingProtocol(),
'',
- buildMemberTaskProtocol(context.teamName),
+ buildMemberTaskProtocol(context.teamName, messagingProtocol),
'',
buildMemberProcessProtocol(context.teamName)
);
diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js
index bcdeacf3..d896dd1d 100644
--- a/agent-teams-controller/src/mcpToolCatalog.js
+++ b/agent-teams-controller/src/mcpToolCatalog.js
@@ -11,6 +11,7 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
'task_get_comment',
'task_link',
'task_list',
+ 'task_restore',
'task_set_clarification',
'task_set_owner',
'task_set_status',
diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js
index 967b28a0..81bb58a1 100644
--- a/agent-teams-controller/test/controller.test.js
+++ b/agent-teams-controller/test/controller.test.js
@@ -151,6 +151,86 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain(
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
);
+ expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
+ expect(briefing).toContain('Full details in task comment e5f6a7b8');
+ expect(briefing).not.toContain('task_get_comment {');
+ });
+
+ it('uses OpenCode-native visible-message wording for OpenCode member briefing', async () => {
+ const claudeDir = makeClaudeDir();
+ const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ config.members = [
+ { name: 'alice', role: 'team-lead' },
+ { name: 'bob', role: 'developer', providerId: 'opencode', model: 'openrouter/test-model' },
+ ];
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
+
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const briefing = await controller.tasks.memberBriefing('bob');
+
+ expect(briefing).toContain(
+ 'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
+ );
+ expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
+ expect(briefing).toContain(
+ 'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
+ );
+ expect(briefing).toContain('Full details in task comment e5f6a7b8');
+ expect(briefing).toContain('Never invent placeholder task refs such as #00000000');
+ expect(briefing).not.toContain('task_get_comment {');
+ expect(briefing).not.toContain('notify your team lead via SendMessage');
+ });
+
+ it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+
+ controller.messages.sendMessage({
+ to: 'user',
+ from: 'bob',
+ text: '#00000000 bootstrap check-in and briefing retrieved. No actionable tasks.',
+ summary: '#00000000 ready',
+ });
+
+ const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
+ const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
+ expect(rows[0].text).toBe('bootstrap check-in and briefing retrieved. No actionable tasks.');
+ expect(rows[0].summary).toBe('ready');
+ });
+
+ it('does not infer OpenCode briefing from a generic provider-scoped model alone', async () => {
+ const claudeDir = makeClaudeDir();
+ const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ config.members = [
+ { name: 'alice', role: 'team-lead' },
+ { name: 'bob', role: 'developer', model: 'openai/gpt-5.4-mini' },
+ ];
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
+
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const briefing = await controller.tasks.memberBriefing('bob');
+
+ expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
+ expect(briefing).not.toContain('agent-teams_message_send');
+ });
+
+ it('keeps explicit native provider metadata stronger than OpenCode-looking model labels', async () => {
+ const claudeDir = makeClaudeDir();
+ const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ config.members = [
+ { name: 'alice', role: 'team-lead' },
+ { name: 'bob', role: 'developer', providerId: 'codex', model: 'opencode/minimax-m2.5-free' },
+ ];
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
+
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const briefing = await controller.tasks.memberBriefing('bob');
+
+ expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
+ expect(briefing).not.toContain('agent-teams_message_send');
});
it('resolves member briefing from members.meta.json when config members are missing', async () => {
@@ -385,6 +465,31 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain('Counters: actionable=4, awareness=3');
});
+ it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+
+ const staleTask = controller.tasks.createTask({
+ subject: 'Legacy stale approved task',
+ owner: 'bob',
+ status: 'pending',
+ reviewState: 'approved',
+ notifyOwner: false,
+ });
+
+ const briefing = await controller.tasks.taskBriefing('bob');
+ const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`));
+ expect(staleLine).toContain('[status=pending]');
+ expect(staleLine).not.toContain('review=');
+ expect(staleLine).toContain('reason=owner_ready');
+
+ const rows = controller.tasks.listTaskInventory({ owner: 'bob' });
+ expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({
+ status: 'pending',
+ reviewState: 'none',
+ });
+ });
+
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@@ -559,12 +664,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 +689,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 +726,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 +877,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 });
@@ -637,8 +906,10 @@ describe('agent-teams-controller API', () => {
from: 'team-lead',
text: 'Need your review',
summary: 'Review request',
+ commentId: 'comment-123',
relayOfMessageId: 'm-original-1',
source: 'system_notification',
+ messageKind: 'task_comment_notification',
leadSessionId: 'session-42',
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
});
@@ -650,11 +921,113 @@ describe('agent-teams-controller API', () => {
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('system_notification');
+ expect(rows[0].messageKind).toBe('task_comment_notification');
+ expect(rows[0].commentId).toBe('comment-123');
expect(rows[0].relayOfMessageId).toBe('m-original-1');
expect(rows[0].leadSessionId).toBe('session-42');
expect(rows[0].attachments[0].filename).toBe('note.txt');
});
+ it('persists slash command metadata through controller messages.appendSentMessage', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+
+ controller.messages.appendSentMessage({
+ from: 'user',
+ to: 'alice',
+ text: '/compact keep only kanban context',
+ messageKind: 'slash_command',
+ slashCommand: {
+ name: 'compact',
+ command: '/compact',
+ args: 'keep only kanban context',
+ knownDescription: 'Compact the active context',
+ },
+ });
+
+ controller.messages.appendSentMessage({
+ from: 'alice',
+ to: 'user',
+ text: 'Compacted context.',
+ messageKind: 'slash_command_result',
+ commandOutput: {
+ stream: 'stdout',
+ commandLabel: '/compact',
+ },
+ });
+
+ const sentPath = path.join(claudeDir, 'teams', 'my-team', 'sentMessages.json');
+ const rows = JSON.parse(fs.readFileSync(sentPath, 'utf8'));
+ expect(rows).toHaveLength(2);
+ expect(rows[0].messageKind).toBe('slash_command');
+ expect(rows[0].slashCommand).toMatchObject({
+ name: 'compact',
+ command: '/compact',
+ args: 'keep only kanban context',
+ });
+ expect(rows[1].messageKind).toBe('slash_command_result');
+ expect(rows[1].commandOutput).toEqual({
+ stream: 'stdout',
+ commandLabel: '/compact',
+ });
+ });
+
+ it('canonicalizes local message recipients and guards user-directed sender identity', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+
+ controller.messages.sendMessage({
+ to: 'team-lead',
+ from: 'bob',
+ text: 'Need lead input',
+ summary: 'Lead input',
+ actionMode: 'ask',
+ });
+
+ const leadInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
+ const leadRows = JSON.parse(fs.readFileSync(leadInboxPath, 'utf8'));
+ expect(leadRows).toHaveLength(1);
+ expect(leadRows[0].to).toBe('alice');
+ expect(leadRows[0].from).toBe('bob');
+ expect(leadRows[0].actionMode).toBe('ask');
+
+ controller.messages.sendMessage({
+ to: 'user',
+ from: 'lead',
+ text: 'Visible user reply',
+ summary: 'Reply',
+ });
+
+ const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
+ const userRows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
+ expect(userRows).toHaveLength(1);
+ expect(userRows[0].to).toBe('user');
+ expect(userRows[0].from).toBe('alice');
+
+ expect(() =>
+ controller.messages.sendMessage({
+ to: 'user',
+ text: 'Missing sender',
+ })
+ ).toThrow('message_send to user requires from to be the responding team member name');
+
+ expect(() =>
+ controller.messages.sendMessage({
+ to: 'other-team.alice',
+ from: 'bob',
+ text: 'Wrong transport',
+ })
+ ).toThrow('message_send cannot target another team. Use cross_team_send with toTeam.');
+
+ expect(() =>
+ controller.messages.sendMessage({
+ to: 'cross_team_send',
+ from: 'bob',
+ text: 'Wrong transport',
+ })
+ ).toThrow('message_send cannot target cross_team_send. Use cross_team_send with toTeam.');
+ });
+
it('wakes task owner on regular comment from another member', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@@ -723,10 +1096,11 @@ describe('agent-teams-controller API', () => {
text: 'Need your decision here.',
});
- const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
+ const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].from).toBe('bob');
+ expect(rows[0].to).toBe('alice');
expect(rows[0].text).toContain('Need your decision here.');
});
@@ -919,6 +1293,449 @@ 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('recognizes lead and orchestrator agent types as canonical team leads', 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: 'alice', role: 'developer' },
+ { name: 'leadbot', agentType: 'lead' },
+ { name: 'opsbot', agentType: 'orchestrator' },
+ ],
+ },
+ null,
+ 2
+ )
+ );
+
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
+ const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' });
+ const aliceBriefing = await controller.tasks.taskBriefing('alice');
+ const leadBriefing = await controller.tasks.leadBriefing();
+
+ expect(aliceBriefing).toContain(`#${aliceTask.displayId}`);
+ expect(aliceBriefing).toContain('actionOwner=@alice');
+ expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`);
+ expect(leadBriefing).toContain(`#${leadTask.displayId}`);
+ expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`);
+ });
+
+ it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => {
+ const claudeDir = makeClaudeDir();
+ const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
+ fs.writeFileSync(
+ configPath,
+ JSON.stringify(
+ {
+ name: 'my-team',
+ members: [
+ { name: 'leadbot', agentType: 'lead' },
+ { name: 'alice', role: 'reviewer' },
+ { name: 'bob', role: 'developer' },
+ ],
+ },
+ null,
+ 2
+ )
+ );
+
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
+ expect(leadOwnedTask.owner).toBe('leadbot');
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
+
+ const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' });
+ expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
+
+ controller.kanban.addReviewer('lead');
+ expect(controller.kanban.listReviewers()).toEqual(['leadbot']);
+
+ const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' });
+ controller.tasks.completeTask(reviewTask.id, 'bob');
+ controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' });
+
+ const requested = controller.tasks
+ .getTask(reviewTask.id)
+ .historyEvents.filter((event) => event.type === 'review_requested')
+ .at(-1);
+ expect(requested.reviewer).toBe('leadbot');
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
+ });
+
+ 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('rejects task_restore for non-deleted tasks', () => {
+ const claudeDir = makeClaudeDir();
+ const controller = createController({ teamName: 'my-team', claudeDir });
+ const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
+
+ controller.tasks.completeTask(task.id, 'bob');
+ controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
+ controller.review.approveReview(task.id, { from: 'alice' });
+
+ expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow(
+ 'task_restore only restores deleted tasks'
+ );
+ expect(controller.tasks.getTask(task.id).status).toBe('completed');
+ expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
+ });
+
+ 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/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js
index b4b67f43..9499f71e 100644
--- a/agent-teams-controller/test/crossTeam.test.js
+++ b/agent-teams-controller/test/crossTeam.test.js
@@ -59,8 +59,8 @@ describe('crossTeam module', () => {
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(inbox).toHaveLength(1);
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
- expect(inbox[0].from).toBe('team-a.lead');
- expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`);
+ expect(inbox[0].from).toBe('team-a.team-lead');
+ expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`);
expect(inbox[0].conversationId).toBeTruthy();
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
});
@@ -98,6 +98,60 @@ describe('crossTeam module', () => {
expect(sentMessages[0].messageId).toBe(outbox[0].messageId);
});
+ it('preserves taskRefs in target inbox, sender copy and outbox', () => {
+ const claudeDir = makeClaudeDir({
+ 'team-a': {
+ name: 'team-a',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ 'team-b': {
+ name: 'team-b',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ });
+ const taskRefs = [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }];
+
+ const controller = createController({ teamName: 'team-a', claudeDir });
+ controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ text: 'Please review the linked task',
+ taskRefs,
+ });
+
+ const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
+ const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
+ expect(inbox[0].taskRefs).toEqual(taskRefs);
+
+ const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json');
+ const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8'));
+ expect(sentMessages[0].taskRefs).toEqual(taskRefs);
+
+ const outbox = controller.crossTeam.getCrossTeamOutbox();
+ expect(outbox[0].taskRefs).toEqual(taskRefs);
+ });
+
+ it('rejects unknown source fromMember', () => {
+ const claudeDir = makeClaudeDir({
+ 'team-a': {
+ name: 'team-a',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ 'team-b': {
+ name: 'team-b',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ });
+
+ const controller = createController({ teamName: 'team-a', claudeDir });
+ expect(() =>
+ controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ fromMember: 'ghost',
+ text: 'Hello from nowhere',
+ })
+ ).toThrow('Unknown cross-team sender');
+ });
+
it('preserves reply conversation metadata for explicit replies', () => {
const claudeDir = makeClaudeDir({
'team-a': {
@@ -314,6 +368,108 @@ describe('crossTeam module', () => {
expect(fs.existsSync(inboxPath)).toBe(true);
});
+ it('resolves supported lead agent types before tech-lead role text', () => {
+ const claudeDir = makeClaudeDir({
+ 'team-a': {
+ name: 'team-a',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ 'team-b': {
+ name: 'team-b',
+ members: [
+ { name: 'alice', role: 'tech lead' },
+ { name: 'olivia', agentType: 'lead' },
+ ],
+ },
+ });
+
+ const controller = createController({ teamName: 'team-a', claudeDir });
+ controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ text: 'Hello',
+ });
+
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true);
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
+ });
+
+ it('resolves orchestrator lead from members.meta.json', () => {
+ const claudeDir = makeClaudeDir({
+ 'team-a': {
+ name: 'team-a',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ 'team-b': {
+ name: 'team-b',
+ members: [],
+ },
+ });
+
+ const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
+ fs.writeFileSync(
+ metaPath,
+ JSON.stringify({
+ members: [
+ { name: 'alice', role: 'tech lead' },
+ { name: 'orla', agentType: 'orchestrator' },
+ ],
+ })
+ );
+
+ const controller = createController({ teamName: 'team-a', claudeDir });
+ controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ text: 'Hello',
+ });
+
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true);
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
+ });
+
+ it('rejects phantom source teams before delivery or outbox writes', () => {
+ const claudeDir = makeClaudeDir({
+ 'team-b': {
+ name: 'team-b',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ });
+
+ const controller = createController({ teamName: 'team-a', claudeDir });
+
+ expect(() =>
+ controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ text: 'Hello from nowhere',
+ })
+ ).toThrow('Source team not found: team-a');
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false);
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
+ });
+
+ it('rejects unknown cross-team senders', () => {
+ const claudeDir = makeClaudeDir({
+ 'team-a': {
+ name: 'team-a',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ 'team-b': {
+ name: 'team-b',
+ members: [{ name: 'team-lead', agentType: 'team-lead' }],
+ },
+ });
+
+ const controller = createController({ teamName: 'team-a', claudeDir });
+
+ expect(() =>
+ controller.crossTeam.sendCrossTeamMessage({
+ toTeam: 'team-b',
+ fromMember: 'alicce',
+ text: 'Hello',
+ })
+ ).toThrow('Unknown cross-team sender: alicce');
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
+ });
+
it('resolves lead by name fallback', () => {
const claudeDir = makeClaudeDir({
'team-a': {
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
-
+
@@ -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/member-liveness-hardening-plan.md b/docs/team-management/member-liveness-hardening-plan.md
new file mode 100644
index 00000000..0913ae99
--- /dev/null
+++ b/docs/team-management/member-liveness-hardening-plan.md
@@ -0,0 +1,2281 @@
+# Member Liveness Hardening Plan
+
+## Коротко
+
+Нужно исправить кейс, где launch UI висит на `Members joining`, участники выглядят как `starting`, а runtime memory показывает около `2 MB`. По текущему коду это почти наверняка значит, что UI видит tmux pane/shell PID, а не реальный teammate runtime.
+
+Главное изменение: разделить "что-то зарегистрировано", "pane/shell жив", "процесс runtime реально найден" и "member сделал bootstrap/check-in". Сейчас эти сигналы частично смешаны через `runtimeAlive`.
+
+Рекомендуемый путь: **UI + строгая liveness-модель**.
+🎯 9/10 🛡️ 9/10 🧠 7/10 Примерно 650-950 строк production-кода + 350-550 строк тестов.
+
+## Почему не UI-only
+
+Топ 3 вариантов:
+
+1. UI-only diagnostics
+ 🎯 7 🛡️ 4 🧠 3 Примерно 180-260 строк.
+ Покажет, что происходит, но backend все равно сможет считать shell живым runtime. Зависание станет понятнее, но не надежнее.
+
+2. UI + строгая liveness-модель
+ 🎯 9 🛡️ 9 🧠 7 Примерно 650-950 строк.
+ Исправляет причину: weak evidence больше не маскирует timeout, UI получает понятные причины, self-heal остается только для надежных сигналов.
+
+3. Полный lease/heartbeat runtime manager
+ 🎯 8 🛡️ 10 🧠 9 Примерно 1200-1800 строк.
+ Самый надежный вариант, но слишком большой для первого фикса. Его лучше делать после варианта 2, когда станут видны реальные runtime-команды и частота edge cases.
+
+## Что проверено в коде
+
+Факты, которые важны для плана:
+
+- `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения.
+- `agent-teams-controller/src/internal/runtime.js` уже прокидывает `runtimeBootstrapCheckin()` в desktop runtime.
+- `src/main/services/team/TeamBootstrapStateReader.ts` уже читает `bootstrap-state.json`, `bootstrap-journal.jsonl` и классифицирует stuck bootstrap. Там уже есть важные тайминги: `ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 min` и `TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS = 5 min`.
+- `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` собирает evidence из config/meta/persisted runtime/tmux/process table и прогоняет его через strict resolver.
+- Для tmux раньше читался только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядело логично.
+- `attachLiveRuntimeMetadataToStatuses()` теперь повышает member до `runtimeAlive: true` только через strong evidence: `confirmed_bootstrap` или `runtime_process`.
+- `reevaluateMemberLaunchStatus()` больше не доверяет старому `runtimeAlive === true` без live metadata.
+- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` теперь не выставляет `runtimeAlive: true` для bridge-only `created` или `permission_blocked`. Такие сигналы остаются candidate/pending до bootstrap или OS verification.
+- `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` уже пишут `confirmed_alive`, `runtimeAlive: true`, `bootstrapConfirmed: true`, `nativeHeartbeat: true` через `updateOpenCodeRuntimeMemberLiveness()`. Значит confirmed state уже есть, надо не дать слабым сигналам выглядеть как он.
+- `OpenCodeLaunchTransactionStore.canMarkOpenCodeRunReady()` уже требует `member_session_recorded`, `required_tools_proven` и `bootstrap_confirmed`. Это strict readiness precedent, который надо сохранить.
+- Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`.
+- `teamSlice.areTeamAgentRuntimeEntriesEqual()` должен сравнивать `livenessKind`, `pidSource` и diagnostic fields, иначе UI может не перерендериться при смене strict evidence.
+- `teamSlice.areMemberSpawnStatusEntriesEqual()` должен сравнивать visible liveness fields (`livenessKind/runtimeDiagnostic`) и продолжать игнорировать timing-only fields.
+- `areLaunchSummaryCountsEqual()` должен сравнивать aggregate diagnostic counts (`shellOnlyPendingCount`, `runtimeProcessPendingCount`, `runtimeCandidatePendingCount`, `noRuntimePendingCount`, `permissionPendingCount`). UI не должен использовать legacy `runtimeAlivePendingCount` как process evidence.
+- `TeamAgentRuntimeWatcher` обновляет runtime snapshot раз в 5 секунд, а spawn statuses раз в 2.5 секунды. Диагностические поля должны попадать либо в оба snapshot слоя, либо UX должен быть устойчив к задержке runtime snapshot.
+- Renderer `member-spawn` event сейчас вызывает refresh spawn statuses, но не runtime snapshot. Если tooltip/detail зависят от `TeamAgentRuntimeSnapshot`, event handler тоже должен запланировать runtime refresh.
+- Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется.
+- `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation.
+- `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику.
+- Для member liveness strict model включена по умолчанию без отдельного env-флага.
+- `src/shared/types/api.ts`, `src/preload/index.ts` и `src/renderer/api/httpClient.ts` уже прокидывают `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` через shared snapshot types. Новый контракт можно добавить optional fields без нового IPC channel, но browser HTTP fallback должен возвращать валидный старый shape.
+- `TeamProvisioningService.readUnixProcessTableRows()` сейчас приватный, sync и читает только `pid,command`. Для надежного liveness нужен `ppid`, WSL-aware execution и unit-test seam. Это не должно оставаться приватным ad hoc helper внутри огромного service.
+- `getLiveTeamAgentRuntimeMetadata()` сейчас читает tmux panes и process table внутри одного метода. После strict model там станет слишком много правил, поэтому план должен вынести pure resolution в отдельный helper/module.
+
+## Главная проблема
+
+Текущий `runtimeAlive` слишком широкий:
+
+```text
+tmux pane exists
+-> pane_pid is zsh/bash with low RSS
+-> metadata.alive = true
+-> MemberSpawnStatusEntry.runtimeAlive = true
+-> grace timeout does not fail
+-> UI shows starting/joining for minutes
+```
+
+Нужно прекратить использовать один boolean для разных уровней доверия.
+
+## Целевой контракт
+
+### Evidence ladder
+
+Сигналы должны оцениваться сверху вниз:
+
+1. `confirmed_bootstrap`
+ Member сделал `member_briefing`, `runtime_bootstrap_checkin`, `runtime_heartbeat`, meaningful inbox heartbeat или успешный bootstrap transcript. Это самый сильный сигнал.
+
+2. `runtime_process`
+ Найден процесс runtime с надежной идентичностью: `--team-name ` + `--agent-id `, или OpenCode bridge вернул валидный `runtimePid`/`sessionId`, и PID жив.
+
+3. `runtime_process_candidate`
+ Найден non-shell descendant под tmux pane, но без строгого identity match. Это diagnostic signal, не strong alive signal в первой реализации.
+
+4. `permission_blocked`
+ Runtime/bridge явно говорит, что требуется permission approval.
+
+5. `shell_only`
+ Tmux pane жив, но foreground command или root pane process выглядит как shell, и runtime child не найден.
+
+6. `registered_only`
+ Member есть в `config.json`/`members.meta.json`, но live process не найден.
+
+7. `stale_metadata`
+ Есть persisted `agentId`, `tmuxPaneId` или `runtimePid`, но live evidence не подтвержден.
+
+8. `not_found`
+ Нет полезных runtime данных.
+
+### Strong vs weak
+
+Только эти состояния ставят `runtimeAlive: true`:
+
+- `confirmed_bootstrap`
+- `runtime_process`
+
+Эти состояния не ставят `runtimeAlive: true`:
+
+- `runtime_process_candidate`
+- `permission_blocked`
+- `shell_only`
+- `registered_only`
+- `stale_metadata`
+- `not_found`
+
+Почему `runtime_process_candidate` не strong: non-shell child может быть `node`, `script`, `sleep`, wrapper или одноразовая команда. Без `teamName/agentId/sessionId` это слишком рискованно для снятия failure.
+
+## Тайминги
+
+Оставить текущий `MEMBER_LAUNCH_GRACE_MS = 90_000` как короткий timeout для отсутствия strong evidence.
+
+Добавить отдельный bootstrap stall deadline:
+
+```ts
+const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
+```
+
+Правила:
+
+- После 90 секунд:
+ - `shell_only`, `registered_only`, `stale_metadata`, `not_found` -> `failed_to_start`.
+ - `permission_blocked` -> не hard fail, показать permission UI.
+ - `runtime_process_candidate` -> warning, но не считать ready.
+ - `runtime_process` -> warning `waiting for bootstrap`, но не hard fail на 90 сек.
+
+- После 5 минут:
+ - `runtime_process_candidate` без bootstrap -> `failed_to_start`.
+ - `runtime_process` без bootstrap -> `runtimeDiagnosticSeverity: "warning"` и launch banner должен перестать быть мутным: `runtime alive but no bootstrap after 5 min`.
+
+Важно: verified runtime process не надо сразу убивать или hard fail-ить только потому, что bootstrap не пришел. Но UI не должен продолжать generic `starting`.
+
+## Rollout mode
+
+Строгая модель меняет поведение launch timeout, поэтому изначальный план рассматривал rollout через отдельный флаг.
+Текущая реализация после hardening включает strict liveness по умолчанию и не содержит старый переключатель режима.
+
+Актуальное поведение:
+
+| Area | Strict-only behavior |
+| ------------------------------ | ------------------------------------- |
+| `livenessKind` | always filled when evidence exists |
+| UI labels | enabled |
+| `runtimeAlive` from shell-only | always false |
+| `already_running` shortcut | waits for strong runtime verification |
+| timeout self-heal | strong evidence only |
+| launchDiagnostics | enabled for warning/error states |
+
+Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом.
+
+## Structured launch diagnostics
+
+Файлы:
+
+- `src/shared/types/team.ts`
+- `src/main/services/team/TeamProvisioningService.ts`
+- `src/main/services/team/progressPayload.ts`
+- `src/renderer/components/team/ProvisioningProgressBlock.tsx`
+
+`TeamProvisioningProgress` сейчас почти полностью строковый:
+
+- `message`
+- `warnings`
+- `cliLogsTail`
+- `assistantOutput`
+
+`cliLogsTail` и `assistantOutput` уже специально ограничены (`PROGRESS_LOG_TAIL_LINES`, `PROGRESS_OUTPUT_TAIL_PARTS`), чтобы не провоцировать renderer OOM. Поэтому нельзя решать проблему "непонятно что происходит" простым расширением логов.
+
+Добавить маленький структурированный payload:
+
+```ts
+export interface TeamLaunchDiagnosticItem {
+ id: string;
+ memberName?: string;
+ severity: 'info' | 'warning' | 'error';
+ code:
+ | 'spawn_accepted'
+ | 'runtime_process_detected'
+ | 'runtime_process_candidate'
+ | 'tmux_shell_only'
+ | 'runtime_not_found'
+ | 'permission_pending'
+ | 'bootstrap_confirmed'
+ | 'bootstrap_stalled'
+ | 'stale_runtime_event_rejected'
+ | 'process_table_unavailable';
+ label: string;
+ detail?: string;
+ observedAt: string;
+}
+
+export interface TeamProvisioningProgress {
+ // existing fields...
+ launchDiagnostics?: TeamLaunchDiagnosticItem[];
+}
+```
+
+Bounded contract:
+
+- максимум 20 diagnostic items в progress payload;
+- newest-first или stable sorted by severity/member;
+- no raw unbounded command strings;
+- process command must be sanitized/truncated;
+- member-level details live in `MemberSpawnStatusEntry`/`TeamAgentRuntimeEntry`, progress diagnostics are only summary.
+
+Renderer:
+
+- `ProvisioningProgressBlock` can render a compact "Diagnostics" disclosure above Live output.
+- It should show code-specific rows like `bob - shell only - tmux pane foreground command is zsh`.
+- It should not require opening CLI logs to understand common stuck states.
+
+Recommended UI rows:
+
+```text
+bob shell only tmux pane foreground command is zsh
+jack waiting for bootstrap verified runtime process, no check-in yet
+tom no runtime found spawn accepted 94s ago
+```
+
+This is separate from `Copy diagnostics`, which can include full sanitized JSON.
+
+## Типы
+
+Файл: `src/shared/types/team.ts`
+
+```ts
+export type TeamAgentRuntimeLivenessKind =
+ | 'confirmed_bootstrap'
+ | 'runtime_process'
+ | 'runtime_process_candidate'
+ | 'permission_blocked'
+ | 'shell_only'
+ | 'registered_only'
+ | 'stale_metadata'
+ | 'not_found';
+
+export type TeamAgentRuntimePidSource =
+ | 'lead_process'
+ | 'tmux_pane'
+ | 'tmux_child'
+ | 'agent_process_table'
+ | 'opencode_bridge'
+ | 'runtime_bootstrap'
+ | 'persisted_metadata';
+
+export type TeamAgentRuntimeDiagnosticSeverity = 'info' | 'warning' | 'error';
+
+export interface TeamAgentRuntimeEntry {
+ memberName: string;
+ alive: boolean;
+ restartable: boolean;
+ backendType?: TeamAgentRuntimeBackendType;
+ providerId?: TeamProviderId;
+ providerBackendId?: TeamProviderBackendId;
+ laneId?: string;
+ laneKind?: 'primary' | 'secondary';
+ pid?: number;
+ runtimeModel?: string;
+ rssBytes?: number;
+ livenessKind?: TeamAgentRuntimeLivenessKind;
+ pidSource?: TeamAgentRuntimePidSource;
+ processCommand?: string;
+ paneId?: string;
+ panePid?: number;
+ paneCurrentCommand?: string;
+ runtimePid?: number;
+ runtimeSessionId?: string;
+ runtimeLeaseExpiresAt?: string;
+ runtimeLastSeenAt?: string;
+ runtimeDiagnostic?: string;
+ runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
+ diagnostics?: string[];
+ updatedAt: string;
+}
+```
+
+В `MemberSpawnStatusEntry` добавить только компактные поля для launch UI:
+
+```ts
+export interface MemberSpawnStatusEntry {
+ // existing fields
+ runtimeDiagnostic?: string;
+ runtimeDiagnosticSeverity?: 'info' | 'warning' | 'error';
+ livenessKind?: TeamAgentRuntimeLivenessKind;
+ livenessLastCheckedAt?: string;
+}
+```
+
+Почему `runtimeSessionId` и `runtimeLastSeenAt` важны:
+
+- OpenCode runtime tools всегда передают `runtimeSessionId`.
+- `runtime_bootstrap_checkin` и `runtime_heartbeat` уже являются lease-like сигналом.
+- Без `runtimeLastSeenAt` UI не сможет отличить "процесс подтвержден 10 секунд назад" от "persisted state висит со вчера".
+- `runtimeLeaseExpiresAt` можно не включать в Phase 0, но тип стоит заложить сразу, если lease/heartbeat manager будет Phase 5.
+
+## Runtime tool metadata
+
+Файлы:
+
+- `mcp-server/src/tools/runtimeTools.ts`
+- `src/main/services/team/TeamProvisioningService.ts`
+
+`runtime_bootstrap_checkin` и `runtime_heartbeat` уже принимают `metadata`, но main сейчас не извлекает из нее ничего. Это упущение: OpenCode/runtime может передать полезные low-level детали, которые не стоит парсить из logs.
+
+Поддержать bounded metadata:
+
+```ts
+interface RuntimeToolMetadata {
+ runtimePid?: number;
+ processCommand?: string;
+ runtimeVersion?: string;
+ hostPid?: number;
+ cwd?: string;
+}
+```
+
+Parser:
+
+```ts
+function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return {};
+ }
+ const raw = value as Record;
+ const runtimePid =
+ typeof raw.runtimePid === 'number' && Number.isFinite(raw.runtimePid) && raw.runtimePid > 0
+ ? Math.trunc(raw.runtimePid)
+ : undefined;
+ const processCommand =
+ typeof raw.processCommand === 'string' ? raw.processCommand.slice(0, 500) : undefined;
+ return {
+ ...(runtimePid ? { runtimePid } : {}),
+ ...(processCommand ? { processCommand } : {}),
+ };
+}
+```
+
+Security/robustness:
+
+- bound string lengths;
+- ignore nested objects except allowlisted fields;
+- never put raw metadata into logs/UI;
+- include sanitized fields in copy diagnostics.
+
+`updateOpenCodeRuntimeMemberLiveness()` should accept sanitized metadata:
+
+```ts
+await this.updateOpenCodeRuntimeMemberLiveness({
+ teamName,
+ runId,
+ memberName,
+ runtimeSessionId,
+ observedAt,
+ diagnostics: payload.diagnostics,
+ metadata: parseRuntimeToolMetadata(payload.metadata),
+ reason: 'OpenCode runtime bootstrap check-in accepted',
+});
+```
+
+If metadata has `runtimePid`, still verify it:
+
+- PID must be alive now;
+- command must still look like the expected runtime, if command info is available;
+- runId/teamName/sessionId must match current tombstone/launch state.
+
+Do not trust metadata PID by itself.
+
+## Internal metadata
+
+Файл: `src/main/services/team/TeamProvisioningService.ts`
+
+Расширить внутренний тип:
+
+```ts
+interface LiveTeamAgentRuntimeMetadata {
+ alive: boolean;
+ backendType?: TeamAgentRuntimeBackendType;
+ agentId?: string;
+ pid?: number;
+ metricsPid?: number;
+ model?: string;
+ tmuxPaneId?: string;
+ livenessKind?: TeamAgentRuntimeLivenessKind;
+ pidSource?: TeamAgentRuntimePidSource;
+ processCommand?: string;
+ panePid?: number;
+ paneCurrentCommand?: string;
+ runtimeSessionId?: string;
+ diagnostics?: string[];
+}
+```
+
+Helper:
+
+```ts
+function isStrongRuntimeEvidence(metadata: LiveTeamAgentRuntimeMetadata | undefined): boolean {
+ return (
+ metadata?.livenessKind === 'confirmed_bootstrap' || metadata?.livenessKind === 'runtime_process'
+ );
+}
+
+function isWeakRuntimeEvidence(metadata: LiveTeamAgentRuntimeMetadata | undefined): boolean {
+ return (
+ metadata?.livenessKind === 'runtime_process_candidate' ||
+ metadata?.livenessKind === 'permission_blocked' ||
+ metadata?.livenessKind === 'shell_only' ||
+ metadata?.livenessKind === 'registered_only' ||
+ metadata?.livenessKind === 'stale_metadata' ||
+ metadata?.livenessKind === 'not_found'
+ );
+}
+```
+
+## Liveness resolver seam
+
+Файл: `src/main/services/team/TeamRuntimeLivenessResolver.ts`
+
+Не стоит держать весь liveness algorithm внутри `TeamProvisioningService`. Там уже смешаны launch state, persistence, progress, tmux, OpenCode, inbox audit и runtime snapshot. Для надежности и тестов лучше вынести pure resolver.
+
+Варианты:
+
+1. Вынести только pure helpers
+ 🎯 8 🛡️ 7 🧠 4 Примерно 120-180 строк.
+ Быстро, но `getLiveTeamAgentRuntimeMetadata()` останется большим orchestration методом.
+
+2. Вынести resolver с input/output контрактом
+ 🎯 9 🛡️ 9 🧠 6 Примерно 220-340 строк.
+ Лучший баланс: service собирает raw facts, resolver принимает facts и возвращает `LiveTeamAgentRuntimeMetadata`.
+
+3. Вынести полноценный runtime monitor service
+ 🎯 8 🛡️ 10 🧠 8 Примерно 500-800 строк.
+ Архитектурно чище, но слишком большой шаг для текущего фикса.
+
+Рекомендация: вариант 2.
+
+Resolver input:
+
+```ts
+export interface ResolveTeamMemberRuntimeLivenessInput {
+ teamName: string;
+ memberName: string;
+ agentId?: string;
+ backendType?: TeamAgentRuntimeBackendType;
+ providerId?: TeamProviderId;
+ tmuxPaneId?: string;
+ persistedRuntimePid?: number;
+ persistedRuntimeSessionId?: string;
+ trackedSpawnStatus?: MemberSpawnStatusEntry;
+ openCodeEvidence?: TeamRuntimeMemberLaunchEvidence;
+ pane?: TmuxPaneRuntimeInfo;
+ processRows: readonly RuntimeProcessTableRow[];
+ nowIso: string;
+}
+```
+
+Resolver output:
+
+```ts
+export interface ResolvedTeamMemberRuntimeLiveness {
+ alive: boolean;
+ livenessKind: TeamAgentRuntimeLivenessKind;
+ pidSource?: TeamAgentRuntimePidSource;
+ pid?: number;
+ metricsPid?: number;
+ panePid?: number;
+ paneCurrentCommand?: string;
+ processCommand?: string;
+ runtimeSessionId?: string;
+ runtimeDiagnostic: string;
+ runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity;
+ diagnostics: string[];
+}
+```
+
+`TeamProvisioningService` responsibilities after extraction:
+
+- read config/meta/persisted launch/runtime state;
+- batch-read tmux pane runtime info once;
+- batch-read process table once;
+- call resolver per member;
+- cache and expose the resolved metadata;
+- invalidate caches on check-in/heartbeat/restart/stop/pane kill.
+
+Resolver responsibilities:
+
+- classify shell-only vs runtime process vs candidate;
+- enforce strong/weak evidence rules;
+- choose `pidSource`;
+- sanitize diagnostics;
+- never read filesystem, tmux, process table or stores directly.
+
+This seam makes the hardest rules unit-testable without spawning tmux or fake processes.
+
+## Tmux runtime info
+
+Файл: `src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts`
+
+Сейчас читается только pane PID. Нужно получать больше контекста:
+
+```ts
+export interface TmuxPaneRuntimeInfo {
+ paneId: string;
+ panePid: number;
+ currentCommand?: string;
+ currentPath?: string;
+ sessionName?: string;
+ windowName?: string;
+}
+
+async listPaneRuntimeInfo(paneIds: readonly string[]): Promise> {
+ const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
+ if (normalizedPaneIds.length === 0) return new Map();
+
+ const format = [
+ '#{pane_id}',
+ '#{pane_pid}',
+ '#{pane_current_command}',
+ '#{pane_current_path}',
+ '#{session_name}',
+ '#{window_name}',
+ ].join('\t');
+
+ const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000);
+ if (result.exitCode !== 0) {
+ throw new Error(result.stderr || 'Failed to list tmux panes');
+ }
+
+ const wanted = new Set(normalizedPaneIds);
+ const infoByPaneId = new Map();
+
+ for (const line of result.stdout.split('\n')) {
+ const [paneId = '', rawPid = '', currentCommand = '', currentPath = '', sessionName = '', windowName = ''] =
+ line.split('\t');
+ const normalizedPaneId = paneId.trim();
+ if (!wanted.has(normalizedPaneId)) continue;
+
+ const panePid = Number.parseInt(rawPid.trim(), 10);
+ if (!Number.isFinite(panePid) || panePid <= 0) continue;
+
+ infoByPaneId.set(normalizedPaneId, {
+ paneId: normalizedPaneId,
+ panePid,
+ currentCommand: currentCommand.trim() || undefined,
+ currentPath: currentPath.trim() || undefined,
+ sessionName: sessionName.trim() || undefined,
+ windowName: windowName.trim() || undefined,
+ });
+ }
+
+ return infoByPaneId;
+}
+```
+
+Оставить старый метод как wrapper:
+
+```ts
+async listPanePids(paneIds: readonly string[]): Promise> {
+ const info = await this.listPaneRuntimeInfo(paneIds);
+ return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid]));
+}
+```
+
+Compatibility rule:
+
+- `listPanePids()` remains "does this pane exist and what is its root pane PID".
+- It must not imply teammate runtime liveness.
+- Existing callers like `waitForTmuxPanesToExit()` should keep working without knowing about `livenessKind`.
+
+## Process table
+
+Нужен `ppid`, иначе невозможно понять, есть ли runtime child под tmux pane.
+
+```ts
+interface RuntimeProcessTableRow {
+ pid: number;
+ ppid: number;
+ command: string;
+}
+```
+
+Do not implement this as `readUnixProcessTableRows()` inside `TeamProvisioningService`. The current helper is private, sync and native-only. The strict model needs a testable, platform-aware provider.
+
+Recommended shape:
+
+```ts
+export interface RuntimeProcessTableProvider {
+ listRuntimeProcesses(): Promise;
+}
+```
+
+`TmuxPlatformCommandExecutor` can implement it because it already knows whether the current tmux runtime is native or WSL-backed.
+
+На macOS/Linux:
+
+```ts
+ps -ax -o pid=,ppid=,command=
+```
+
+На Windows/WSL важно: `ps` должен выполняться внутри той же WSL distro, где выполняется tmux. Host-side Windows `ps` не увидит Linux children.
+
+Практичный вариант:
+
+- добавить в `TmuxPlatformCommandExecutor` метод `listRuntimeProcesses()`;
+- внутри Windows ветки использовать `TmuxWslService` и запускать `wsl -d -e ps -ax -o pid=,ppid=,command=`;
+- на native платформах использовать обычный `execFile('ps', ...)`.
+
+Пример парсинга:
+
+```ts
+function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
+ const rows: RuntimeProcessTableRow[] = [];
+
+ for (const line of output.split('\n')) {
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
+ if (!match) continue;
+
+ const pid = Number.parseInt(match[1], 10);
+ const ppid = Number.parseInt(match[2], 10);
+ const command = match[3]?.trim() ?? '';
+
+ if (Number.isFinite(pid) && pid > 0 && Number.isFinite(ppid) && command) {
+ rows.push({ pid, ppid, command });
+ }
+ }
+
+ return rows;
+}
+```
+
+Performance contract:
+
+- read process table once per runtime snapshot, not once per member;
+- reuse the same rows for every member resolver call;
+- respect the existing backend cache TTL around 2 seconds;
+- if process table read fails, return an explicit diagnostic and do not mark shell-only as strong alive.
+
+Failure contract:
+
+- `process_table_unavailable` is a warning, not an immediate hard fail by itself;
+- if tmux pane info is available but process table is unavailable, classify as `shell_only` only when `pane_current_command` is shell-like;
+- if both tmux and process table are unavailable, classify as `stale_metadata` or `not_found` based on persisted evidence;
+- do not self-clear a previous failure on provider failure.
+
+### PID freshness and reuse
+
+PID alone is not identity. A stale persisted `runtimePid` can be reused by the OS for another process.
+
+Rules:
+
+- Never treat persisted PID as strong evidence without reading the current process table.
+- A PID match is strong only if current command identity also matches expected runtime identity.
+- If possible later, add process start time to the table and compare it with `firstSpawnAcceptedAt`/`runtimeLastSeenAt`.
+- If process start time is unavailable, use command identity and current run/session identity as the minimum.
+
+Optional future row:
+
+```ts
+interface RuntimeProcessTableRow {
+ pid: number;
+ ppid: number;
+ command: string;
+ startedAtMs?: number;
+}
+```
+
+Do not block Phase 1 on `startedAtMs`; block it on "no PID-only strong evidence".
+
+## Shell detection
+
+```ts
+const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'login', 'tmux']);
+
+function basenameCommand(command: string | undefined): string {
+ const firstToken = command?.trim().split(/\s+/, 1)[0] ?? '';
+ const base = firstToken.split(/[\\/]/).pop() ?? firstToken;
+ return base.replace(/^-/, '').toLowerCase();
+}
+
+function isShellLikeCommand(command: string | undefined): boolean {
+ return SHELL_COMMAND_NAMES.has(basenameCommand(command));
+}
+```
+
+## Runtime identity matching
+
+Текущий `commandContainsCliArgValue()` лучше заменить на helper, который поддерживает оба вида:
+
+- `--agent-id abc`
+- `--agent-id=abc`
+- quoted values
+
+Минимально:
+
+```ts
+function extractCliArgValues(command: string, argName: string): string[] {
+ const escapedArg = argName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const pattern = new RegExp(
+ `(?:^|\\s)${escapedArg}(?:=|\\s+)("([^"]*)"|'([^']*)'|([^\\s]+))`,
+ 'g'
+ );
+
+ const values: string[] = [];
+ for (const match of command.matchAll(pattern)) {
+ const value = (match[2] ?? match[3] ?? match[4] ?? '').trim();
+ if (value) values.push(value);
+ }
+ return values;
+}
+
+function commandArgEquals(command: string, argName: string, expected: string | undefined): boolean {
+ if (!expected?.trim()) return false;
+ return extractCliArgValues(command, argName).some((value) => value === expected.trim());
+}
+```
+
+Strong process match:
+
+```ts
+function isVerifiedRuntimeProcess(params: {
+ row: RuntimeProcessTableRow;
+ teamName: string;
+ agentId?: string;
+}): boolean {
+ return (
+ commandArgEquals(params.row.command, '--team-name', params.teamName) &&
+ commandArgEquals(params.row.command, '--agent-id', params.agentId)
+ );
+}
+```
+
+Sanitize any command before it reaches UI/logs/copy diagnostics:
+
+```ts
+const SECRET_FLAG_PATTERN =
+ /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
+
+function sanitizeProcessCommandForDiagnostics(command: string | undefined): string | undefined {
+ const trimmed = command?.trim();
+ if (!trimmed) return undefined;
+ return trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]').slice(0, 500);
+}
+```
+
+Do not use sanitized commands for identity matching. Match on the raw process table row inside main process memory, then only expose sanitized/truncated command text.
+
+## Descendant resolution
+
+```ts
+function collectDescendants(
+ rows: readonly RuntimeProcessTableRow[],
+ rootPid: number
+): RuntimeProcessTableRow[] {
+ const childrenByParent = new Map();
+
+ for (const row of rows) {
+ const bucket = childrenByParent.get(row.ppid) ?? [];
+ bucket.push(row);
+ childrenByParent.set(row.ppid, bucket);
+ }
+
+ const result: RuntimeProcessTableRow[] = [];
+ const queue = [...(childrenByParent.get(rootPid) ?? [])];
+
+ while (queue.length > 0) {
+ const next = queue.shift();
+ if (!next) continue;
+ result.push(next);
+ queue.push(...(childrenByParent.get(next.pid) ?? []));
+ }
+
+ return result;
+}
+```
+
+Resolution:
+
+```ts
+interface ResolvedRuntimeProcess {
+ kind: TeamAgentRuntimeLivenessKind;
+ pid?: number;
+ command?: string;
+ pidSource?: TeamAgentRuntimePidSource;
+ diagnostics: string[];
+}
+
+function resolveTmuxRuntimeProcess(params: {
+ teamName: string;
+ agentId?: string;
+ pane: TmuxPaneRuntimeInfo;
+ rows: readonly RuntimeProcessTableRow[];
+}): ResolvedRuntimeProcess {
+ const descendants = collectDescendants(params.rows, params.pane.panePid);
+
+ const verified = descendants.find((row) =>
+ isVerifiedRuntimeProcess({
+ row,
+ teamName: params.teamName,
+ agentId: params.agentId,
+ })
+ );
+
+ if (verified) {
+ return {
+ kind: 'runtime_process',
+ pid: verified.pid,
+ command: verified.command,
+ pidSource: 'tmux_child',
+ diagnostics: ['matched tmux descendant by team-name and agent-id'],
+ };
+ }
+
+ const candidate = descendants.find((row) => !isShellLikeCommand(row.command));
+ if (candidate) {
+ return {
+ kind: 'runtime_process_candidate',
+ pid: candidate.pid,
+ command: candidate.command,
+ pidSource: 'tmux_child',
+ diagnostics: ['found non-shell descendant without team/member identity'],
+ };
+ }
+
+ if (isShellLikeCommand(params.pane.currentCommand)) {
+ return {
+ kind: 'shell_only',
+ pid: params.pane.panePid,
+ command: params.pane.currentCommand,
+ pidSource: 'tmux_pane',
+ diagnostics: [
+ `tmux pane is alive, but foreground command is ${params.pane.currentCommand}`,
+ 'no verified runtime descendant process was found',
+ ],
+ };
+ }
+
+ return {
+ kind: 'not_found',
+ diagnostics: ['tmux pane exists, but no runtime process could be identified'],
+ };
+}
+```
+
+## OpenCode bridge correction
+
+Файл: `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts`
+
+Сейчас `pendingRuntimeObserved = createdOrBlocked && runtimeMaterialized`, а `runtimeMaterialized` фактически означает "bridge вернул member". Это не равно live runtime.
+
+Надо разделить:
+
+- `agentToolAccepted`: bridge принял/создал member.
+- `runtimeAlive`: есть подтвержденный live runtime signal или confirmed bootstrap.
+- `bootstrapConfirmed`: `launchState === "confirmed_alive"`.
+
+Пример:
+
+```ts
+function mapBridgeMemberToRuntimeEvidence(
+ memberName: string,
+ launchState: OpenCodeTeamMemberLaunchBridgeState,
+ sessionId: string | undefined,
+ runtimePid: number | undefined,
+ pendingPermissionRequestIds: string[] | undefined,
+ runtimeMaterialized: boolean,
+ diagnostics: string[]
+): TeamRuntimeMemberLaunchEvidence {
+ const confirmed = launchState === 'confirmed_alive';
+ const failed = launchState === 'failed';
+ const permissionBlocked = launchState === 'permission_blocked';
+ const validRuntimePid =
+ typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
+ const hasRuntimeSession = typeof sessionId === 'string' && sessionId.trim().length > 0;
+ const runtimeLivenessKind = confirmed
+ ? 'confirmed_bootstrap'
+ : validRuntimePid
+ ? 'runtime_process'
+ : permissionBlocked
+ ? 'permission_blocked'
+ : hasRuntimeSession
+ ? 'runtime_process_candidate'
+ : undefined;
+
+ return {
+ memberName,
+ providerId: 'opencode',
+ launchState: failed
+ ? 'failed_to_start'
+ : confirmed
+ ? 'confirmed_alive'
+ : permissionBlocked
+ ? 'runtime_pending_permission'
+ : 'runtime_pending_bootstrap',
+ agentToolAccepted: confirmed || runtimeMaterialized,
+ runtimeAlive: confirmed || validRuntimePid,
+ bootstrapConfirmed: confirmed,
+ hardFailure: failed,
+ hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
+ pendingPermissionRequestIds:
+ pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0
+ ? [...new Set(pendingPermissionRequestIds)]
+ : undefined,
+ sessionId,
+ ...(validRuntimePid ? { runtimePid } : {}),
+ ...(runtimeLivenessKind ? { livenessKind: runtimeLivenessKind } : {}),
+ diagnostics,
+ };
+}
+```
+
+Важно: `sessionId` без `runtimePid` лучше считать candidate, а не strong live process. Session id полезен для delivery/permission correlation, но сам по себе не доказывает, что процесс сейчас жив.
+
+Также `toOpenCodePersistedLaunchMember()` должен сохранять `runtimePid` и `sessionId`, если они есть. Сейчас для primary OpenCode launch evidence это легко потерять.
+
+### OpenCode transaction/readiness invariant
+
+`canMarkOpenCodeRunReady()` уже требует `bootstrap_confirmed`, поэтому новая liveness-модель не должна поднимать aggregate state в `clean_success`, если есть только:
+
+- bridge `created`;
+- `sessionId` без bootstrap;
+- permission request;
+- stale launch-state member.
+
+Regression test:
+
+```ts
+expect(
+ canMarkOpenCodeRunReady({
+ members: [{ name: 'bob', launchState: 'runtime_pending_bootstrap' }],
+ // checkpoints exist except bootstrap
+ }).ok
+).toBe(false);
+```
+
+### Stale runtime events
+
+`assertOpenCodeRuntimeEvidenceAccepted()` already checks tombstones/current run ownership before accepting bootstrap/heartbeat/delivery evidence. This must remain the gate for all strong OpenCode liveness.
+
+Rules:
+
+- `runtime_bootstrap_checkin` from an old `runId` must not revive a stopped/relaunched member.
+- `runtime_heartbeat` from an old lane must not refresh `runtimeLastSeenAt`.
+- Runtime metadata from rejected evidence must not be written to persisted launch state.
+- UI copy diagnostics should include `runId` and `runtimeSessionId` only after accepted evidence.
+
+Regression tests:
+
+```ts
+await expect(
+ service.recordOpenCodeRuntimeHeartbeat({
+ teamName,
+ runId: oldRunId,
+ memberName: 'bob',
+ runtimeSessionId: oldSessionId,
+ })
+).rejects.toThrow();
+```
+
+## `getLiveTeamAgentRuntimeMetadata()`
+
+Новая логика:
+
+1. Сначала читать durable status:
+ - `bootstrapConfirmed`
+ - `lastHeartbeatAt`
+ - `runtime_bootstrap_checkin`
+ - transcript success
+
+2. Потом читать verified runtime:
+ - process table match by `--team-name` + `--agent-id`
+ - OpenCode runtimePid/sessionId
+ - tmux descendant with verified identity
+
+3. Потом diagnostic-only:
+ - tmux pane shell
+ - config/meta registration
+ - stale persisted metadata
+
+Sketch:
+
+```ts
+const status = this.findTrackedMemberSpawnStatus(run, memberName);
+const diagnostics: string[] = [];
+
+let livenessKind: TeamAgentRuntimeLivenessKind = 'not_found';
+let pid: number | undefined;
+let pidSource: TeamAgentRuntimePidSource | undefined;
+let processCommand: string | undefined;
+
+if (status?.bootstrapConfirmed === true) {
+ livenessKind = 'confirmed_bootstrap';
+ diagnostics.push('bootstrap was confirmed by member check-in or heartbeat');
+}
+
+if (livenessKind !== 'confirmed_bootstrap' && metadata.agentId) {
+ const processPid = processPidByAgentId.get(metadata.agentId);
+ if (processPid) {
+ livenessKind = 'runtime_process';
+ pid = processPid;
+ pidSource = 'agent_process_table';
+ diagnostics.push('matched process table by team-name and agent-id');
+ }
+}
+
+if (livenessKind !== 'runtime_process' && paneInfo) {
+ const resolved = resolveTmuxRuntimeProcess({
+ teamName,
+ agentId: metadata.agentId,
+ pane: paneInfo,
+ rows: processRows,
+ });
+
+ livenessKind = resolved.kind;
+ pid = resolved.pid;
+ pidSource = resolved.pidSource;
+ processCommand = resolved.command;
+ diagnostics.push(...resolved.diagnostics);
+}
+
+if (livenessKind === 'not_found' && metadata.agentId) {
+ livenessKind = 'stale_metadata';
+ diagnostics.push('persisted agent id exists, but no live process matched it');
+}
+
+const alive = livenessKind === 'confirmed_bootstrap' || livenessKind === 'runtime_process';
+
+metadataByMember.set(memberName, {
+ ...metadata,
+ alive,
+ livenessKind,
+ ...(pid ? { pid } : {}),
+ ...(pidSource ? { pidSource } : {}),
+ ...(processCommand ? { processCommand } : {}),
+ ...(paneInfo
+ ? {
+ panePid: paneInfo.panePid,
+ paneCurrentCommand: paneInfo.currentCommand,
+ }
+ : {}),
+ diagnostics,
+});
+```
+
+Fallback policy:
+
+- Если enhanced tmux info failed, не возвращать `alive: true` только из старого `panePid`.
+- Если `ps` failed, показывать diagnostic `process table unavailable`; не self-clear failure.
+- Если cached metadata есть, сохранять `model/backendType`, но не сохранять stale `alive`.
+- Если `previousMember.bootstrapConfirmed === true`, persisted launch state может оставаться confirmed для истории, но runtime snapshot должен показывать `alive` отдельно от historical `bootstrapConfirmed`. Иначе UI может считать старого member live после stop/relaunch.
+
+## Persisted launch state
+
+Файл: `src/main/services/team/TeamLaunchStateEvaluator.ts`
+
+Сейчас `RuntimeMemberSpawnState` и persisted member normalization не знают про новые diagnostic поля. Нужно расширить аккуратно, чтобы старые snapshots читались без migration.
+
+Добавить в `PersistedTeamLaunchMemberState`:
+
+```ts
+runtimeSessionId?: string;
+livenessKind?: TeamAgentRuntimeLivenessKind;
+pidSource?: TeamAgentRuntimePidSource;
+runtimeDiagnostic?: string;
+runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
+```
+
+Правило:
+
+- persisted `livenessKind` можно использовать для UI explanation;
+- persisted `livenessKind` нельзя использовать как live proof без свежего `lastRuntimeAliveAt` или live runtime check.
+
+Normalize:
+
+```ts
+function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | undefined {
+ return value === 'confirmed_bootstrap' ||
+ value === 'runtime_process' ||
+ value === 'runtime_process_candidate' ||
+ value === 'permission_blocked' ||
+ value === 'shell_only' ||
+ value === 'registered_only' ||
+ value === 'stale_metadata' ||
+ value === 'not_found'
+ ? value
+ : undefined;
+}
+```
+
+`updateOpenCodeRuntimeMemberLiveness()` должен сохранять:
+
+```ts
+livenessKind: 'confirmed_bootstrap',
+pidSource: 'runtime_bootstrap',
+runtimeSessionId: input.runtimeSessionId,
+runtimeDiagnostic: undefined,
+runtimeDiagnosticSeverity: undefined,
+```
+
+`toOpenCodePersistedLaunchMember()` должен сохранять:
+
+```ts
+runtimePid: evidence?.runtimePid,
+runtimeSessionId: evidence?.sessionId,
+livenessKind: evidence?.bootstrapConfirmed
+ ? 'confirmed_bootstrap'
+ : evidence?.runtimeAlive
+ ? 'runtime_process'
+ : evidence?.pendingPermissionRequestIds?.length
+ ? 'permission_blocked'
+ : undefined,
+```
+
+Mapping functions that must be updated:
+
+- `RuntimeMemberSpawnState` pick list must include `livenessKind`, `runtimeDiagnostic`, `runtimeDiagnosticSeverity`.
+- `snapshotFromRuntimeMemberStatuses()` must copy those fields into `PersistedTeamLaunchMemberState`.
+- `snapshotToMemberSpawnStatuses()` must copy them back into `MemberSpawnStatusEntry`.
+- `normalizePersistedLaunchSnapshot()` must normalize unknown old files without dropping valid new fields.
+
+Example:
+
+```ts
+statuses[memberName] = {
+ status,
+ launchState: entry.launchState,
+ error: entry.hardFailure ? entry.hardFailureReason : undefined,
+ hardFailureReason: entry.hardFailureReason,
+ livenessSource,
+ agentToolAccepted: entry.agentToolAccepted,
+ runtimeAlive: entry.runtimeAlive,
+ bootstrapConfirmed: entry.bootstrapConfirmed,
+ hardFailure: entry.hardFailure,
+ pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
+ firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
+ lastHeartbeatAt: entry.lastHeartbeatAt,
+ livenessKind: entry.livenessKind,
+ runtimeDiagnostic: entry.runtimeDiagnostic,
+ runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
+ updatedAt: entry.lastEvaluatedAt,
+};
+```
+
+Backward compatibility:
+
+- old snapshots without these fields should behave exactly as today;
+- new optional summary counts should default to `0` at presentation time;
+- do not bump snapshot `version` unless a required field is introduced. For this plan, keep `version: 2`.
+
+## `attachLiveRuntimeMetadataToStatuses()`
+
+Текущий behavior:
+
+```ts
+if (metadata.alive) {
+ nextEntry.runtimeAlive = true;
+ nextEntry.livenessSource = 'process';
+}
+```
+
+Новый behavior:
+
+```ts
+const strongRuntimeAlive = isStrongRuntimeEvidence(metadata);
+const weakRuntimeEvidence = isWeakRuntimeEvidence(metadata);
+
+if (
+ strongRuntimeAlive &&
+ current.hardFailure !== true &&
+ current.launchState !== 'failed_to_start'
+) {
+ nextEntry.status = 'online';
+ nextEntry.agentToolAccepted = true;
+ nextEntry.runtimeAlive = true;
+ nextEntry.hardFailure = false;
+ nextEntry.hardFailureReason = undefined;
+ nextEntry.error = undefined;
+ nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
+ nextEntry.livenessKind = metadata.livenessKind;
+ nextEntry.runtimeDiagnostic = undefined;
+ nextEntry.runtimeDiagnosticSeverity = undefined;
+ nextEntry.launchState = deriveMemberLaunchState(nextEntry);
+}
+
+if (weakRuntimeEvidence && current.bootstrapConfirmed !== true) {
+ nextEntry.runtimeAlive = false;
+ nextEntry.livenessKind = metadata.livenessKind;
+ nextEntry.runtimeDiagnostic = buildRuntimeDiagnostic(metadata);
+ nextEntry.runtimeDiagnosticSeverity = metadata.livenessKind === 'shell_only' ? 'warning' : 'info';
+}
+```
+
+Self-heal из `failed_to_start` оставить только для strong evidence:
+
+```ts
+if (
+ strongRuntimeAlive &&
+ current.launchState === 'failed_to_start' &&
+ isAutoClearableLaunchFailureReason(failureReason)
+) {
+ // clear auto failure
+}
+```
+
+## Spawn tool result handling
+
+Файл: `src/main/services/team/TeamProvisioningService.ts`
+
+`handleMemberSpawnToolResult()` раньше содержал shortcut:
+
+```ts
+if (parsedStatus.reason === 'already_running') {
+ this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
+}
+```
+
+В strict liveness модели это опасно: `already_running` доказывает, что runtime/CLI отказался дублировать spawn, но не доказывает, что нужный teammate сейчас прошел bootstrap или что текущий pane PID является runtime процессом.
+
+Итоговая логика:
+
+```ts
+if (parsedStatus.reason === 'already_running') {
+ this.agentRuntimeSnapshotCache.delete(run.teamName);
+ this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
+ this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
+ this.appendMemberBootstrapDiagnostic(
+ run,
+ spawnedMemberName,
+ 'already_running requires strong runtime verification'
+ );
+ void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
+}
+```
+
+Tests:
+
+- `already_running` + shell-only pane -> stays pending/warning, no `runtimeAlive`.
+- `already_running` + verified process -> can become `runtime_pending_bootstrap`.
+- `already_running` + confirmed bootstrap -> confirmed alive.
+
+## `reevaluateMemberLaunchStatus()`
+
+Текущий early return по `refreshed.runtimeAlive` слишком широкий.
+
+Новый sketch:
+
+```ts
+await this.refreshMemberSpawnStatusesFromLeadInbox(run);
+await this.maybeAuditMemberSpawnStatuses(run, { force: true });
+
+const refreshed = run.memberSpawnStatuses.get(memberName);
+if (!refreshed) return;
+
+const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName);
+const runtime = findRuntimeMetadataForMember(runtimeByMember, memberName);
+const strongRuntimeAlive = isStrongRuntimeEvidence(runtime);
+
+if (refreshed.launchState === 'failed_to_start' || refreshed.launchState === 'confirmed_alive') {
+ return;
+}
+
+if (strongRuntimeAlive) {
+ this.setMemberRuntimeDiagnostic(run, memberName, {
+ livenessKind: runtime?.livenessKind,
+ message: 'Runtime process is alive, waiting for teammate bootstrap/check-in.',
+ severity: 'warning',
+ });
+ return;
+}
+
+if (runtime?.livenessKind === 'permission_blocked') {
+ return;
+}
+
+const reason =
+ runtime?.livenessKind === 'shell_only'
+ ? `Teammate did not join within the launch grace window. Tmux pane is alive, but only shell command "${runtime.paneCurrentCommand ?? 'unknown'}" was detected.`
+ : runtime?.livenessKind === 'runtime_process_candidate'
+ ? 'Teammate did not confirm bootstrap. Only an unverified runtime process candidate was found.'
+ : 'Teammate did not join within the launch grace window.';
+
+this.setMemberSpawnStatus(run, memberName, 'error', reason);
+```
+
+Для `runtime_process_candidate` лучше использовать 5 минут, не 90 секунд:
+
+```ts
+const acceptedAtMs = Date.parse(refreshed.firstSpawnAcceptedAt ?? '');
+const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : 0;
+if (
+ runtime?.livenessKind === 'runtime_process_candidate' &&
+ elapsedMs < MEMBER_BOOTSTRAP_STALL_MS
+) {
+ return;
+}
+```
+
+## Runtime snapshot and memory display
+
+`getTeamAgentRuntimeSnapshot()` сейчас выбирает `rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid`. Это нормально для сбора метрики, но UI должен знать источник.
+
+Правило:
+
+- `pidSource = tmux_pane` + `livenessKind = shell_only` -> memory is shell/pane RSS, не runtime RSS.
+- `pidSource = tmux_child` или `agent_process_table` -> memory is runtime process RSS.
+- OpenCode shared host `metricsPid` -> показать как shared host, не как member-owned runtime.
+- `launchSnapshotAlive` сейчас может сделать `alive: true`, если persisted launch member был `runtimeAlive` или `bootstrapConfirmed`. После изменения это надо разделить:
+ - `historicallyConfirmedBootstrap` - для display/history.
+ - `alive` - только свежий live runtime или свежий heartbeat lease.
+
+Добавить в `TeamAgentRuntimeEntry`:
+
+```ts
+runtimeDiagnostic?: string;
+pidSource?: TeamAgentRuntimePidSource;
+paneCurrentCommand?: string;
+historicalBootstrapConfirmed?: boolean;
+runtimeLastSeenAt?: string;
+```
+
+UI tooltip может объяснить:
+
+```text
+RSS source: tmux pane shell
+PID: 26691
+Command: zsh
+Runtime process: not found
+Bootstrap: no check-in yet
+```
+
+## Restartability semantics
+
+Файлы:
+
+- `src/main/services/team/TeamProvisioningService.ts`
+- `src/renderer/components/team/members/MemberDetailDialog.tsx`
+
+Важно не смешать `alive` и `restartable`.
+
+`shell_only` должен быть `alive: false`, но часто должен оставаться `restartable: true`, если есть `tmuxPaneId`. Иначе пользователь увидит `shell only`, но не сможет нажать Restart.
+
+Rules:
+
+- `confirmed_bootstrap` / `runtime_process` with member-owned PID -> `alive: true`, `restartable: true`.
+- `shell_only` with `tmuxPaneId` -> `alive: false`, `restartable: true`, restart kills pane.
+- `registered_only` without PID/pane -> `alive: false`, `restartable: false`.
+- OpenCode shared host `metricsPid` -> `restartable: false` unless adapter owns a member lane stop/restart path.
+- `in-process` -> keep `restartable: false`.
+
+`restartMember()` already kills persisted tmux panes via `killTmuxPaneForCurrentPlatformSync(paneId)`, so strict liveness should not remove pane ids from runtime snapshot just because they are weak evidence.
+
+Test:
+
+```ts
+expect(shellOnlyRuntimeEntry).toMatchObject({
+ alive: false,
+ restartable: true,
+ livenessKind: 'shell_only',
+ pidSource: 'tmux_pane',
+});
+```
+
+## IPC/store implications
+
+Файлы:
+
+- `src/main/ipc/teams.ts`
+- `src/renderer/store/index.ts`
+- `src/renderer/store/slices/teamSlice.ts`
+- `src/renderer/components/team/TeamDetailView.tsx`
+
+IPC уже возвращает `TeamAgentRuntimeSnapshot`, значит новый контракт проходит без нового channel. Но store equality обязательно надо обновить:
+
+```ts
+function areTeamAgentRuntimeEntriesEqual(
+ left: TeamAgentRuntimeEntry | undefined,
+ right: TeamAgentRuntimeEntry | undefined
+): boolean {
+ if (left === right) return true;
+ if (!left || !right) return left === right;
+ return (
+ left.memberName === right.memberName &&
+ left.alive === right.alive &&
+ left.restartable === right.restartable &&
+ left.backendType === right.backendType &&
+ left.pid === right.pid &&
+ left.runtimeModel === right.runtimeModel &&
+ left.rssBytes === right.rssBytes &&
+ left.livenessKind === right.livenessKind &&
+ left.pidSource === right.pidSource &&
+ left.paneCurrentCommand === right.paneCurrentCommand &&
+ left.runtimeDiagnostic === right.runtimeDiagnostic &&
+ left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
+ left.runtimeLastSeenAt === right.runtimeLastSeenAt
+ );
+}
+```
+
+Если не сделать это, backend может правильно вычислять `shell_only`, а UI продолжит показывать старую карточку из-за suppressed store update.
+
+Нужно обновить и spawn equality:
+
+```ts
+function areMemberSpawnStatusEntriesEqual(
+ left: MemberSpawnStatusEntry | undefined,
+ right: MemberSpawnStatusEntry | undefined
+): boolean {
+ if (left === right) return true;
+ if (!left || !right) return left === right;
+ return (
+ // existing visible fields
+ left.status === right.status &&
+ left.launchState === right.launchState &&
+ left.error === right.error &&
+ left.hardFailureReason === right.hardFailureReason &&
+ left.livenessSource === right.livenessSource &&
+ left.runtimeAlive === right.runtimeAlive &&
+ left.runtimeModel === right.runtimeModel &&
+ left.bootstrapConfirmed === right.bootstrapConfirmed &&
+ left.hardFailure === right.hardFailure &&
+ // new visible diagnostic fields
+ left.livenessKind === right.livenessKind &&
+ left.runtimeDiagnostic === right.runtimeDiagnostic &&
+ left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity
+ );
+}
+```
+
+Summary equality:
+
+```ts
+function areLaunchSummaryCountsEqual(
+ left: PersistedTeamLaunchSummary | undefined,
+ right: PersistedTeamLaunchSummary | undefined
+): boolean {
+ if (left === right) return true;
+ if (!left || !right) return left === right;
+ return (
+ left.confirmedCount === right.confirmedCount &&
+ left.pendingCount === right.pendingCount &&
+ left.failedCount === right.failedCount &&
+ left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
+ left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
+ left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
+ left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
+ left.noRuntimePendingCount === right.noRuntimePendingCount &&
+ left.permissionPendingCount === right.permissionPendingCount
+ );
+}
+```
+
+Event handling:
+
+```ts
+if (event.type === 'member-spawn') {
+ if (isStaleRuntimeEvent) return;
+ seedCurrentRunIdIfMissing();
+ scheduleMemberSpawnStatusesRefresh(event.teamName);
+ scheduleTeamAgentRuntimeRefresh(event.teamName);
+ return;
+}
+```
+
+If `scheduleTeamAgentRuntimeRefresh()` does not exist, add a small debounced variant mirroring `scheduleMemberSpawnStatusesRefresh()`.
+
+Polling:
+
+- `TeamSpawnStatusWatcher` - 2.5 sec.
+- `TeamAgentRuntimeWatcher` - 5 sec.
+- Backend runtime metadata cache TTL is 2 sec.
+
+Для launch UI лучше продублировать короткий `livenessKind/runtimeDiagnostic` в `MemberSpawnStatusEntry`, а подробные PID/command детали оставить в runtime snapshot. Тогда badge меняется быстро, tooltip догоняет через runtime snapshot.
+
+Cache invalidation checklist:
+
+- invalidate `agentRuntimeSnapshotCache` and `liveTeamAgentRuntimeMetadataCache` on runtime check-in;
+- invalidate on heartbeat;
+- invalidate on member restart/stop/remove;
+- invalidate when tmux pane kill succeeds;
+- invalidate when launch state store writes a new liveness diagnostic.
+
+Without this, a member can remain visually `shell only` for up to the polling interval after a valid check-in, which is acceptable, but not after an explicit check-in event.
+
+## API/preload propagation
+
+No new IPC channel is needed, but the type propagation still has sharp edges.
+
+Files to verify:
+
+- `src/shared/types/team.ts`
+- `src/shared/types/api.ts`
+- `src/preload/index.ts`
+- `src/renderer/api/httpClient.ts`
+- `src/renderer/store/slices/teamSlice.ts`
+
+Rules:
+
+- New fields on `TeamAgentRuntimeEntry`, `MemberSpawnStatusEntry` and `PersistedTeamLaunchSummary` must be optional at first.
+- `src/preload/index.ts` can keep the same `invokeIpcWithResult()` calls.
+- `src/shared/types/api.ts` should not need method signature changes, but typecheck must prove it.
+- `src/renderer/api/httpClient.ts` browser fallback must still return valid snapshots when new fields are absent.
+- Renderer helpers must tolerate `undefined` `livenessKind` and map it to current behavior.
+
+Recommended type compatibility test:
+
+```ts
+const snapshot: TeamAgentRuntimeSnapshot = {
+ teamName: 'demo',
+ updatedAt: new Date().toISOString(),
+ runId: null,
+ members: {
+ bob: {
+ memberName: 'bob',
+ alive: false,
+ restartable: true,
+ livenessKind: 'shell_only',
+ pidSource: 'tmux_pane',
+ paneCurrentCommand: 'zsh',
+ updatedAt: new Date().toISOString(),
+ },
+ },
+};
+```
+
+This catches accidental required fields before runtime.
+
+## Progress diagnostics update path
+
+`updateProgress()` currently accepts only:
+
+```ts
+Pick<
+ TeamProvisioningProgress,
+ 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity'
+>;
+```
+
+If `launchDiagnostics` is added to `TeamProvisioningProgress`, `updateProgress()` must accept it explicitly:
+
+```ts
+extras?: Pick<
+ TeamProvisioningProgress,
+ | 'pid'
+ | 'error'
+ | 'warnings'
+ | 'cliLogsTail'
+ | 'configReady'
+ | 'messageSeverity'
+ | 'launchDiagnostics'
+>
+```
+
+And keep it bounded:
+
+```ts
+launchDiagnostics: boundLaunchDiagnostics(
+ extras?.launchDiagnostics ?? run.progress.launchDiagnostics
+),
+```
+
+Do not store this as `assistantOutput`. `assistantOutput` is rendered as markdown and is the wrong surface for machine-produced liveness facts.
+
+## Renderer UX
+
+### Member card labels
+
+Файлы:
+
+- `src/renderer/utils/memberHelpers.ts`
+- `src/renderer/components/team/members/MemberCard.tsx`
+- `src/renderer/utils/memberRuntimeSummary.ts`
+
+Новые visual states:
+
+```ts
+export type MemberLaunchVisualState =
+ | 'waiting'
+ | 'spawning'
+ | 'permission_pending'
+ | 'waiting_bootstrap'
+ | 'shell_only'
+ | 'runtime_candidate'
+ | 'registered_only'
+ | 'stale_runtime'
+ | 'error'
+ | null;
+```
+
+Mapping:
+
+```ts
+function resolveLaunchVisualState(params: {
+ spawnStatus?: MemberSpawnStatusEntry;
+ runtimeEntry?: TeamAgentRuntimeEntry;
+}): MemberLaunchVisualState {
+ const { spawnStatus, runtimeEntry } = params;
+
+ if (spawnStatus?.launchState === 'failed_to_start') return 'error';
+ if (spawnStatus?.launchState === 'runtime_pending_permission') return 'permission_pending';
+
+ if (runtimeEntry?.livenessKind === 'shell_only') return 'shell_only';
+ if (runtimeEntry?.livenessKind === 'runtime_process_candidate') return 'runtime_candidate';
+ if (runtimeEntry?.livenessKind === 'registered_only') return 'registered_only';
+ if (runtimeEntry?.livenessKind === 'stale_metadata') return 'stale_runtime';
+
+ if (
+ spawnStatus?.launchState === 'runtime_pending_bootstrap' &&
+ runtimeEntry?.livenessKind === 'runtime_process'
+ ) {
+ return 'waiting_bootstrap';
+ }
+
+ return spawnStatus?.status === 'spawning' ? 'spawning' : 'waiting';
+}
+```
+
+Labels:
+
+```ts
+const MEMBER_LAUNCH_LABELS: Record, string> = {
+ waiting: 'starting',
+ spawning: 'starting',
+ permission_pending: 'permission',
+ waiting_bootstrap: 'waiting for bootstrap',
+ shell_only: 'shell only',
+ runtime_candidate: 'process candidate',
+ registered_only: 'registered',
+ stale_runtime: 'stale runtime',
+ error: 'spawn failed',
+};
+```
+
+Текущий `MemberCard` не принимает `runtimeEntry`, поэтому надо изменить props:
+
+```ts
+interface MemberCardProps {
+ // existing
+ runtimeEntry?: TeamAgentRuntimeEntry;
+ spawnEntry?: MemberSpawnStatusEntry;
+}
+```
+
+И передавать из `MemberList`:
+
+```tsx
+
+```
+
+Затем `buildMemberLaunchPresentation()` должен принимать `runtimeEntry` или хотя бы `livenessKind`:
+
+```ts
+const launchPresentation = buildMemberLaunchPresentation({
+ member,
+ spawnStatus,
+ spawnLaunchState,
+ spawnLivenessSource,
+ spawnRuntimeAlive,
+ runtimeEntry,
+ runtimeAdvisory: member.runtimeAdvisory,
+ isLaunchSettling,
+ isTeamAlive,
+ isTeamProvisioning,
+ leadActivity,
+});
+```
+
+То же нужно для `MemberDetailHeader` и `MemberHoverCard`, иначе список и detail view будут расходиться по labels.
+
+### Tooltip
+
+Tooltip examples:
+
+```text
+bob
+Spawn accepted: yes
+Registered in config: yes
+Runtime: tmux pane alive, foreground command is zsh
+Runtime process: not found
+PID source: tmux pane
+Bootstrap: no member_briefing/check-in yet
+```
+
+```text
+alice
+Spawn accepted: yes
+Runtime: verified process detected
+PID source: tmux child
+Bootstrap: waiting for member_briefing/check-in
+```
+
+```text
+tom
+Spawn accepted: yes
+Runtime: not found after 90s
+Bootstrap: no check-in
+Last error: Teammate did not join within the launch grace window.
+```
+
+### Launch banner
+
+Файл: `src/renderer/utils/teamProvisioningPresentation.ts`
+
+Generic:
+
+```text
+4 teammates still joining
+```
+
+Заменить на aggregate detail:
+
+```text
+4 teammates still joining - 3 shell-only, 1 waiting for bootstrap
+```
+
+Helper:
+
+```ts
+function summarizePendingLaunchDiagnostics(params: {
+ statuses: Record;
+ runtimeEntries: Record | undefined;
+}): string | null {
+ const counts = {
+ shellOnly: 0,
+ waitingBootstrap: 0,
+ candidate: 0,
+ permission: 0,
+ noRuntime: 0,
+ };
+
+ for (const [memberName, status] of Object.entries(params.statuses)) {
+ if (status.launchState === 'confirmed_alive' || status.launchState === 'failed_to_start') {
+ continue;
+ }
+
+ const runtimeEntry = params.runtimeEntries?.[memberName];
+ if (status.launchState === 'runtime_pending_permission') counts.permission += 1;
+ else if (runtimeEntry?.livenessKind === 'shell_only') counts.shellOnly += 1;
+ else if (runtimeEntry?.livenessKind === 'runtime_process') counts.waitingBootstrap += 1;
+ else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') counts.candidate += 1;
+ else counts.noRuntime += 1;
+ }
+
+ const parts = [
+ counts.shellOnly ? `${counts.shellOnly} shell-only` : '',
+ counts.waitingBootstrap ? `${counts.waitingBootstrap} waiting for bootstrap` : '',
+ counts.candidate ? `${counts.candidate} process candidates` : '',
+ counts.permission ? `${counts.permission} awaiting permission` : '',
+ counts.noRuntime ? `${counts.noRuntime} no runtime found` : '',
+ ].filter(Boolean);
+
+ return parts.length > 0 ? parts.join(', ') : null;
+}
+```
+
+Сейчас `buildTeamProvisioningPresentation()` принимает только spawn statuses/snapshot, не runtime entries. Есть три варианта:
+
+1. Добавить `runtimeSnapshot?: TeamAgentRuntimeSnapshot` в `buildTeamProvisioningPresentation()`.
+ 🎯 8 🛡️ 8 🧠 5 Примерно 80-130 строк.
+
+2. Дублировать aggregate diagnostic counts в `MemberSpawnStatusesSnapshot.summary`.
+ 🎯 9 🛡️ 9 🧠 6 Примерно 120-190 строк.
+
+3. Использовать только `progress.message`.
+ 🎯 6 🛡️ 5 🧠 3 Примерно 30-60 строк.
+
+Рекомендую 2: backend уже лучше знает truth model и может атомарно отдать `shellOnlyCount`, `runtimeProcessPendingCount`, `candidateCount`, `noRuntimeCount`. UI тогда не зависит от race между 2.5 sec spawn polling и 5 sec runtime polling.
+
+Расширить summary:
+
+```ts
+export interface PersistedTeamLaunchSummary {
+ confirmedCount: number;
+ pendingCount: number;
+ failedCount: number;
+ // Compatibility aggregate only. Do not use as process evidence in UI.
+ runtimeAlivePendingCount: number;
+ shellOnlyPendingCount?: number;
+ runtimeProcessPendingCount?: number;
+ runtimeCandidatePendingCount?: number;
+ noRuntimePendingCount?: number;
+ permissionPendingCount?: number;
+}
+```
+
+### Stepper semantics
+
+Файл: `src/renderer/components/team/provisioningSteps.ts`
+
+The current stepper uses:
+
+- `heartbeatConfirmedCount`
+- `processOnlyAliveCount`
+- `pendingSpawnCount`
+- `failedSpawnCount`
+
+After strict liveness, `processOnlyAliveCount` must mean **strong runtime process only**. It must not include:
+
+- `shell_only`
+- `runtime_process_candidate`
+- `registered_only`
+- `stale_metadata`
+- `permission_blocked`
+
+Mapping:
+
+```ts
+if (entry.launchState === 'runtime_pending_bootstrap') {
+ if (entry.runtimeAlive === true && entry.livenessKind === 'runtime_process') {
+ processOnlyAliveCount += 1;
+ } else {
+ pendingSpawnCount += 1;
+ }
+}
+```
+
+Why this matters: the screenshot problem is exactly the UI being stuck on "Members joining". Shell-only should remain in joining until it fails, while verified process can move toward finalizing but still show `waiting for bootstrap`.
+
+### Copy diagnostics
+
+Добавить в launch details или member tooltip маленькое действие `Copy diagnostics`.
+
+Payload:
+
+```ts
+interface MemberLaunchDiagnosticsPayload {
+ teamName: string;
+ memberName: string;
+ launchState?: MemberLaunchState;
+ spawnStatus?: MemberSpawnStatus;
+ livenessKind?: TeamAgentRuntimeLivenessKind;
+ livenessSource?: MemberSpawnLivenessSource;
+ pid?: number;
+ pidSource?: TeamAgentRuntimePidSource;
+ paneId?: string;
+ panePid?: number;
+ paneCurrentCommand?: string;
+ processCommand?: string;
+ runtimeDiagnostic?: string;
+ diagnostics?: string[];
+ updatedAt?: string;
+}
+```
+
+Это поможет быстро понять проблему на скрине друга без доступа к его машине.
+
+## Файлы для изменения
+
+Backend/shared:
+
+- `src/shared/types/team.ts`
+ - добавить liveness/pid source типы;
+ - расширить `TeamAgentRuntimeEntry`;
+ - добавить компактные diagnostic fields в `MemberSpawnStatusEntry`.
+ - добавить bounded `TeamLaunchDiagnosticItem` и `TeamProvisioningProgress.launchDiagnostics`.
+
+- `src/main/services/team/TeamRuntimeLivenessResolver.ts`
+ - вынести pure liveness classification;
+ - принимать tmux/process/OpenCode/persisted facts;
+ - возвращать strong/weak classification и sanitized diagnostics.
+
+- `src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts`
+ - добавить `listPaneRuntimeInfo()`;
+ - добавить `listRuntimeProcesses()` или equivalent;
+ - оставить `listPanePids()` совместимым wrapper.
+
+- `src/features/tmux-installer/main/composition/runtimeSupport.ts`
+ - экспортировать `listTmuxPaneRuntimeInfoForCurrentPlatform()`;
+ - экспортировать process table helper, если он живет в tmux runtime executor.
+
+- `src/main/services/team/TeamProvisioningService.ts`
+ - расширить `LiveTeamAgentRuntimeMetadata`;
+ - parse sanitized runtime tool `metadata`;
+ - добавить strict evidence helpers;
+ - использовать `TeamRuntimeLivenessResolver`;
+ - обновить `updateProgress()` extras для `launchDiagnostics`;
+ - переписать tmux/process resolution;
+ - убрать strong `online/process` shortcut из `already_running`;
+ - исправить `attachLiveRuntimeMetadataToStatuses()`;
+ - исправить `reevaluateMemberLaunchStatus()`;
+ - invalidate runtime caches на check-in/heartbeat/restart/stop;
+ - прокинуть diagnostics в `getTeamAgentRuntimeSnapshot()`.
+
+- `src/main/services/team/TeamLaunchStateEvaluator.ts`
+ - нормализовать persisted liveness diagnostic fields;
+ - считать optional diagnostic counts в summary;
+ - не превращать stale persisted `runtimeAlive` в live proof.
+
+- `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts`
+ - не считать `created` bridge member strong alive без `runtimePid`;
+ - сохранить `runtimePid` и `sessionId` в persisted launch state.
+
+- `src/main/services/team/runtime/TeamRuntimeAdapter.ts`
+ - расширить `TeamRuntimeMemberLaunchEvidence` полями `livenessKind`, `pidSource`, `runtimeDiagnostic`;
+ - сохранить backward compatibility для существующих adapters.
+
+- `src/main/services/team/progressPayload.ts`
+ - добавить `boundLaunchDiagnostics()` и не расширять raw log tails.
+
+- `src/shared/types/api.ts`
+ - проверить, что existing `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` contracts не требуют нового channel.
+
+- `src/preload/index.ts`
+ - оставить существующие IPC methods, убедиться typecheck проходит с optional fields.
+
+- `src/renderer/api/httpClient.ts`
+ - browser fallback должен оставаться valid при отсутствующих diagnostic fields.
+
+- `src/renderer/store/slices/teamSlice.ts`
+ - обновить `areTeamAgentRuntimeEntriesEqual()`;
+ - обновить `areMemberSpawnStatusEntriesEqual()`;
+ - обновить `areLaunchSummaryCountsEqual()`;
+ - убедиться, что runtime diagnostic changes не suppress-ятся.
+
+- `src/renderer/store/index.ts`
+ - на `member-spawn` event обновлять и spawn statuses, и runtime snapshot.
+
+Renderer:
+
+- `src/renderer/utils/memberHelpers.ts`
+ - добавить visual states и labels.
+
+- `src/renderer/utils/memberRuntimeSummary.ts`
+ - memory summary должен учитывать `pidSource`.
+
+- `src/renderer/components/team/members/MemberList.tsx`
+ - передать `runtimeEntry` и `spawnEntry` в presentation/member card layer.
+
+- `src/renderer/components/team/members/MemberCard.tsx`
+ - badge + tooltip + copy diagnostics.
+
+- `src/renderer/components/team/members/MemberDetailHeader.tsx`
+ - использовать тот же launch presentation contract, что и card.
+
+- `src/renderer/components/team/members/MemberHoverCard.tsx`
+ - не отставать от list/card labels.
+
+- `src/renderer/utils/teamProvisioningPresentation.ts`
+ - aggregate launch diagnostics.
+
+- `src/renderer/components/team/provisioningSteps.ts`
+ - `processOnlyAliveCount` считать только для strong runtime process.
+
+- `src/renderer/components/team/ProvisioningProgressBlock.tsx`
+ - добавить компактный Diagnostics disclosure для `launchDiagnostics`.
+
+## Tests
+
+Backend:
+
+- `TeamRuntimeLivenessResolver.test.ts`
+ - tmux foreground shell + no child -> `shell_only`;
+ - verified process row by `--team-name` + `--agent-id` -> `runtime_process`;
+ - non-shell descendant without identity -> `runtime_process_candidate`;
+ - persisted PID without current process identity -> `stale_metadata`;
+ - process command secrets are redacted in diagnostics;
+ - provider failure diagnostic does not produce strong alive.
+
+- `TeamProvisioningService.test.ts`
+ - tmux shell-only pane не ставит `runtimeAlive`;
+ - shell-only после 90 секунд становится `failed_to_start`;
+ - stale persisted `tmuxPaneId` не self-clear-ит failure;
+ - verified process by `--team-name` + `--agent-id` ставит `runtimeAlive`;
+ - runtime process candidate не считается strong alive;
+ - OpenCode `created` без `runtimePid` не ставит `runtimeAlive`;
+ - OpenCode `created` с `runtimePid` ставит `runtimeAlive`;
+ - OpenCode `sessionId` без `runtimePid` становится `runtime_process_candidate`, а не strong alive;
+ - `runtime_bootstrap_checkin` сохраняет `runtimeSessionId`, `livenessKind: "confirmed_bootstrap"`;
+ - stale runtime heartbeat от old `runId` rejected и не меняет launch state;
+ - runtime metadata PID без process identity не становится strong alive;
+ - `already_running` + shell-only не ставит `runtimeAlive`;
+ - permission blocked остается pending permission, не hard fail.
+
+- `TmuxPlatformCommandExecutor.test.ts`
+ - `listPaneRuntimeInfo()` парсит `pane_current_command`;
+ - `listPanePids()` остается совместимым pane-existence helper;
+ - process table parser поддерживает `pid`, `ppid`, `command`;
+ - WSL branch не использует host process table.
+
+Renderer:
+
+- `memberHelpers.test.ts`
+ - `shell_only` -> `shell only`;
+ - `runtime_process` + pending bootstrap -> `waiting for bootstrap`;
+ - `runtime_process_candidate` -> `process candidate`;
+ - permission state не затирается runtime diagnostics.
+
+- `memberRuntimeSummary.test.ts`
+ - `2 MB` с `pidSource=tmux_pane` получает tooltip/source `tmux pane shell`;
+ - runtime child показывает обычный runtime memory.
+
+- `teamSlice.test.ts`
+ - изменение `livenessKind` обновляет `teamAgentRuntimeByTeam`;
+ - изменение `runtimeDiagnostic` обновляет `teamAgentRuntimeByTeam`.
+ - изменение spawn `livenessKind/runtimeDiagnostic` обновляет `memberSpawnStatusesByTeam`;
+ - изменение optional summary diagnostic counts обновляет presentation.
+ - `member-spawn` event schedules both spawn status refresh and runtime snapshot refresh.
+
+- `httpClient.test.ts`
+ - browser fallback `getTeamAgentRuntime()` remains valid without diagnostic fields;
+ - browser fallback `getMemberSpawnStatuses()` remains valid without summary diagnostic counts.
+
+- `teamProvisioningPresentation.test.ts`
+ - banner показывает `3 shell-only, 1 waiting for bootstrap`;
+ - pending permission получает отдельный count.
+
+- `provisioningSteps.test.ts`
+ - `shell_only` не увеличивает `processOnlyAliveCount`;
+ - `runtime_process_candidate` не увеличивает `processOnlyAliveCount`;
+ - `runtime_process` увеличивает `processOnlyAliveCount`.
+
+- `ProvisioningProgressBlock.test.tsx`
+ - renders bounded `launchDiagnostics`;
+ - does not require opening CLI logs to see `shell only`;
+ - long process command is truncated/sanitized.
+
+## Phases
+
+### Phase 0 - Diagnostics without behavior change
+
+🎯 10 🛡️ 10 🧠 4 Примерно 180-260 строк.
+
+Добавить новые optional fields и заполнить `livenessKind`, `pidSource`, `paneCurrentCommand`, `diagnostics`, но пока не менять timeout behavior.
+
+Цель: увидеть на реальном launch, что именно определяется у друга: shell-only, process candidate, stale metadata или OpenCode bridge claim.
+
+Add:
+
+- `TeamRuntimeLivenessResolver` pure tests;
+- process table/tmux providers;
+- strict-only runtime evidence flow without a runtime-mode switch.
+
+Verification:
+
+```bash
+pnpm typecheck
+pnpm exec vitest run test/main/features/tmux-installer test/main/services/team/TeamProvisioningService.test.ts
+```
+
+### Phase 1 - Strict strong evidence
+
+🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк.
+
+Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only. Shell/pane/candidate больше не выставляют `runtimeAlive`.
+
+Verification:
+
+```bash
+pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts
+```
+
+### Phase 2 - Timeout and self-heal hardening
+
+🎯 9 🛡️ 9 🧠 6 Примерно 120-180 строк.
+
+Исправить `reevaluateMemberLaunchStatus()`:
+
+- shell-only/no-runtime/stale -> fail after 90s;
+- permission -> stay pending permission;
+- candidate -> warning, fail after 5 min;
+- verified runtime -> warning, no false hard fail at 90s;
+- auto-clear failure только по strong evidence.
+
+### Phase 3 - UI visibility
+
+🎯 9 🛡️ 8 🧠 6 Примерно 220-320 строк.
+
+Добавить:
+
+- labels `shell only`, `waiting for bootstrap`, `process candidate`;
+- tooltip;
+- aggregate banner detail;
+- copy diagnostics.
+
+### Phase 4 - Real launch validation
+
+🎯 8 🛡️ 9 🧠 6 Примерно 100-180 строк тестовых fixtures/scripts.
+
+Manual checks:
+
+```bash
+tmux list-panes -a -F '#{pane_id} #{pane_pid} #{pane_current_command}'
+ps -ax -o pid=,ppid=,command= | rg '||claude|codex|opencode'
+```
+
+Scenarios:
+
+1. Успешный Anthropic tmux launch.
+2. Shell-only pane.
+3. Missing MCP/member_briefing.
+4. Permission pending.
+5. OpenCode bridge member without `runtimePid`.
+6. OpenCode bridge member with `runtimePid`.
+7. Restart member while old pane exists.
+
+## Acceptance criteria
+
+1. Tmux pane жив, foreground command `zsh/bash/sh`, runtime child не найден:
+ - `TeamAgentRuntimeEntry.alive === false`
+ - `livenessKind === "shell_only"`
+ - `pidSource === "tmux_pane"`
+ - UI показывает `shell only`
+ - после 90 секунд member становится `failed_to_start`
+
+2. Найден process с `--team-name --agent-id `:
+ - `TeamAgentRuntimeEntry.alive === true`
+ - `livenessKind === "runtime_process"`
+ - `MemberSpawnStatusEntry.runtimeAlive === true`
+ - UI показывает `waiting for bootstrap`, если bootstrap еще не пришел
+
+3. Member сделал check-in:
+ - `bootstrapConfirmed === true`
+ - `livenessKind === "confirmed_bootstrap"`
+ - `launchState === "confirmed_alive"`
+ - UI показывает `ready`
+
+4. Persisted metadata есть, process не найден:
+ - не self-clear failure;
+ - не `runtimeAlive`;
+ - UI показывает `stale runtime` или `registered`.
+
+5. OpenCode bridge вернул member без `runtimePid`:
+ - `agentToolAccepted === true`;
+ - `runtimeAlive === false`;
+ - UI показывает pending/bridge diagnostics, не `online`.
+
+6. `2.0 MB` больше не выглядит как полноценный runtime:
+ - tooltip объясняет `RSS source: tmux pane shell`;
+ - launch badge показывает `shell only`.
+
+7. Launch details объясняет stuck state без открытия logs:
+ - `launchDiagnostics` содержит bounded rows;
+ - UI показывает хотя бы `shell only`, `waiting for bootstrap`, `no runtime found`;
+ - `cliLogsTail` и `assistantOutput` остаются bounded.
+
+8. Store suppression не скрывает диагностику:
+ - изменение `livenessKind` меняет renderer state;
+ - изменение summary diagnostic counts меняет presentation;
+ - `member-spawn` event refreshes runtime snapshot.
+
+9. Rollout безопасен:
+ - strict behavior включен по умолчанию;
+ - diagnostics UI остается доступным без отдельного mode flag;
+ - rollback требует явного code revert или отдельного follow-up setting.
+
+10. Provider failures не создают ложный ready:
+
+- process table failure дает `process_table_unavailable`;
+- tmux/process provider failure не self-clear-ит failure;
+- command diagnostics sanitized and truncated.
+
+## Main risks
+
+### False negative для реального runtime
+
+Если реальный teammate не содержит `--team-name`/`--agent-id` в command, strict model может понизить его до candidate.
+
+Mitigation:
+
+- Phase 0 сначала собирает diagnostics без behavior change.
+- Candidate не fail-ится за 90 секунд.
+- Allowlist runtime command markers добавлять только после реальных данных.
+
+### Windows/WSL process tree
+
+Host-side process table не увидит Linux tmux descendants.
+
+Mitigation:
+
+- process table должен жить рядом с tmux executor;
+- Windows branch должен запускать `ps` внутри WSL distro.
+
+### OpenCode shared host
+
+Один OpenCode host PID может обслуживать несколько members.
+
+Mitigation:
+
+- `runtimePid` хранить как `metricsPid`, если это shared host;
+- `restartable=false`, если PID не member-owned;
+- UI label `shared OpenCode host`, не "member runtime".
+
+### UI overload
+
+Слишком много деталей в карточке сделают интерфейс шумным.
+
+Mitigation:
+
+- короткий badge в карточке;
+- детали в tooltip;
+- aggregate counts в banner;
+- полный JSON только через copy diagnostics.
+
+### Process command privacy
+
+`ps` command can include cwd, file paths, API keys or tokens.
+
+Mitigation:
+
+- identity matching uses raw command only inside main process memory;
+- UI/logs/copy diagnostics receive sanitized command only;
+- redact common secret flags;
+- truncate command strings to 500 chars;
+- do not include raw runtime tool metadata.
+
+### Process table overhead
+
+Reading `ps` per member would be wasteful and flaky on large systems.
+
+Mitigation:
+
+- read process table once per runtime snapshot;
+- keep existing 2 sec backend cache TTL;
+- do not call `pidusage` for weak shell-only rows unless UI needs memory display;
+- cap diagnostics to 20 progress rows.
+
+## Minimal safe patch order
+
+1. Добавить типы и optional fields.
+2. Добавить sanitized runtime tool metadata parser.
+3. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`.
+4. Добавить process table provider/parser с `ppid`.
+5. Вынести `TeamRuntimeLivenessResolver`.
+6. Заполнить `livenessKind`.
+7. Написать backend tests на shell-only, verified runtime, stale event, metadata PID.
+8. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence.
+9. Исправить `already_running` shortcut.
+10. Переключить timeout/self-heal logic на strong evidence.
+11. Исправить OpenCode bridge mapping.
+12. Обновить persisted summary diagnostics и store equality.
+13. Добавить `launchDiagnostics` в progress payload и UI disclosure.
+14. Добавить renderer labels/tooltips/banner.
+15. Добавить copy diagnostics.
+16. Manual validation: создать команду, проверить pending names, runtime diagnostics и отсутствие false-ready shell-only процесса.
+
+## Expected UX
+
+Before:
+
+```text
+bob starting 2.0 MB
+jack starting 2.0 MB
+tom starting 2.0 MB
+```
+
+After:
+
+```text
+bob shell only Anthropic · Opus 4.7 · 2.0 MB
+jack waiting for bootstrap Anthropic · Opus 4.7 · 418 MB
+tom spawn failed no runtime process after 90s
+```
+
+Launch banner:
+
+```text
+4 teammates still joining - 3 shell-only, 1 waiting for bootstrap
+```
+
+Tooltip for shell-only:
+
+```text
+Spawn accepted: yes
+Registered in config: yes
+Runtime process: not found
+Tmux pane: alive
+Foreground command: zsh
+PID source: tmux pane
+Bootstrap: no member_briefing/check-in yet
+```
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..59b4348e
--- /dev/null
+++ b/docs/team-management/opencode-native-semantic-messaging-plan.md
@@ -0,0 +1,3471 @@
+# 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`. Direct proof should match plain names internally, but bridge output should keep a clearly named field if canonical OpenCode ids are needed later. Do not silently change `observedMcpTools` semantics.
+- `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.
+- The old project-proof gate was removed from OpenCode launch readiness. Do not reintroduce project-scoped launch blocking for selected models; runtime readiness should be based on inventory, capabilities, runtime stores, app MCP tool proof, and the execution probe.
+- 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/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`
+
+`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 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.
+- Keep `readiness.evidence.observedMcpTools` canonical if it is exposed through the bridge. 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 - Keep app tool proof in readiness only
+
+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/mcp/OpenCodeMcpToolAvailability.ts
+```
+
+Current rule:
+
+- OpenCode launch readiness should not require a project-scoped proof artifact.
+- App tool proof belongs in the live readiness path through capability, runtime-store, MCP tool, and execution checks.
+- If tool requirements change, update `OpenCodeMcpToolAvailability` and readiness tests directly.
+
+Acceptance:
+
+- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are covered by readiness tests.
+- Missing app MCP tools fails readiness directly with a clear diagnostic.
+- No project-specific artifact is required to create or launch a team.
+
+### 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. Add tests for these 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 tests:
+
+```ts
+it('uses full app tool ids for OpenCode readiness expectations', async () => {
+ const result = await bridge.runReadiness({
+ selectedModel: 'minimax-m2.5-free',
+ // ...
+ });
+
+ expect(result.supportLevel).toBe('production_supported');
+ expect(result.evidence.observedMcpTools).toEqual(
+ expect.arrayContaining(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS)
+ );
+});
+```
+
+```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/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. Keep OpenCode readiness requiring the full app tool id list without project-scoped artifacts.
+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.
+- Readiness passes with runtime-only app tool coverage. This means `OpenCodeMcpToolAvailability` still uses only runtime tools instead of the full app tool id list.
+- 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.
+- 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;
- Claude Agent Teams
+ Agent Teams
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 @@
@@ -31,7 +34,7 @@ const year = new Date().getFullYear();
.app-footer__copy {
font-size: 13px;
opacity: 0.5;
- font-family: "JetBrains Mono", monospace;
+ font-family: 'JetBrains Mono', monospace;
}
.app-footer__links {
@@ -46,7 +49,7 @@ const year = new Date().getFullYear();
font-size: 13px;
opacity: 0.7;
transition: opacity 0.2s ease;
- font-family: "JetBrains Mono", monospace;
+ font-family: 'JetBrains Mono', monospace;
}
.app-footer__link:hover {
diff --git a/landing/components/layout/AppHeader.vue b/landing/components/layout/AppHeader.vue
index 5674a1a4..c5602e15 100644
--- a/landing/components/layout/AppHeader.vue
+++ b/landing/components/layout/AppHeader.vue
@@ -2,6 +2,7 @@
import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js';
const { t } = useI18n();
+const { repoUrl } = useGithubRepo();
const menuOpen = ref(false);
const navItems = computed(() => [
@@ -28,7 +29,7 @@ const navItems = computed(() => [
@@ -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();
-
-
+
+
"
{{ item.text }}
-
+
{{ getInitial(item.name) }}
@@ -75,17 +63,14 @@ const getInitial = (name: string) => name.charAt(0).toUpperCase();
-
+
{{ expanded ? t('testimonials.showLess') : t('testimonials.showMore') }}
{{ t('testimonials.feedbackCta') }}
- GitHub
+ GitHub
@@ -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/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts
index bdc6f469..9382d425 100644
--- a/mcp-server/src/agent-teams-controller.d.ts
+++ b/mcp-server/src/agent-teams-controller.d.ts
@@ -27,7 +27,10 @@ declare module 'agent-teams-controller' {
setNeedsClarification(taskId: string, value: string | null): unknown;
linkTask(taskId: string, targetId: string, linkType: string): unknown;
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
- memberBriefing(memberName: string): Promise
;
+ memberBriefing(
+ memberName: string,
+ options?: { runtimeProvider?: 'native' | 'opencode' }
+ ): Promise;
leadBriefing(): Promise;
taskBriefing(memberName: string): Promise;
}
@@ -52,7 +55,7 @@ declare module 'agent-teams-controller' {
export interface ControllerMessageApi {
appendSentMessage(flags: Record): unknown;
sendMessage(flags: Record): unknown;
- lookupMessage(messageId: string): { message: Record };
+ lookupMessage(messageId: string): { message: Record; store: string };
}
export interface ControllerProcessApi {
diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts
index 0cfa581a..cbe3e96c 100644
--- a/mcp-server/src/controller.ts
+++ b/mcp-server/src/controller.ts
@@ -8,12 +8,20 @@ const controllerModule =
(agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule;
const { createController } = controllerModule;
+const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
+
/** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */
export const agentBlocks = controllerModule.agentBlocks;
export function getController(teamName: string, claudeDir?: string) {
+ const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim();
+ let resolvedClaudeDir = claudeDir;
+ if (forcedClaudeDir) {
+ resolvedClaudeDir = forcedClaudeDir;
+ }
+
return createController({
teamName,
- ...(claudeDir ? { claudeDir } : {}),
+ ...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}),
});
}
diff --git a/mcp-server/src/tools/crossTeamTools.ts b/mcp-server/src/tools/crossTeamTools.ts
index 486eb014..e68ef5e5 100644
--- a/mcp-server/src/tools/crossTeamTools.ts
+++ b/mcp-server/src/tools/crossTeamTools.ts
@@ -3,12 +3,19 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
+import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
};
+const taskRefSchema = z.object({
+ taskId: z.string().min(1),
+ displayId: z.string().min(1),
+ teamName: z.string().min(1),
+});
+
export function registerCrossTeamTools(server: Pick) {
server.addTool({
name: 'cross_team_send',
@@ -22,6 +29,7 @@ export function registerCrossTeamTools(server: Pick) {
summary: z.string().optional(),
conversationId: z.string().optional(),
replyToConversationId: z.string().optional(),
+ taskRefs: z.array(taskRefSchema).optional(),
chainDepth: z.number().int().nonnegative().optional(),
}),
execute: async ({
@@ -33,9 +41,11 @@ export function registerCrossTeamTools(server: Pick) {
summary,
conversationId,
replyToConversationId,
+ taskRefs,
chainDepth,
- }) =>
- await Promise.resolve(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
toTeam,
@@ -44,10 +54,12 @@ export function registerCrossTeamTools(server: Pick) {
...(summary ? { summary } : {}),
...(conversationId ? { conversationId } : {}),
...(replyToConversationId ? { replyToConversationId } : {}),
+ ...(taskRefs?.length ? { taskRefs } : {}),
...(chainDepth !== undefined ? { chainDepth } : {}),
})
)
- ),
+ );
+ },
});
server.addTool({
@@ -57,14 +69,16 @@ export function registerCrossTeamTools(server: Pick) {
...toolContextSchema,
excludeTeam: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, excludeTeam }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, excludeTeam }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
...(excludeTeam ? { excludeTeam } : {}),
})
)
- ),
+ );
+ },
});
server.addTool({
@@ -73,9 +87,11 @@ export function registerCrossTeamTools(server: Pick) {
parameters: z.object({
...toolContextSchema,
}),
- execute: async ({ teamName, claudeDir }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
- ),
+ );
+ },
});
}
diff --git a/mcp-server/src/tools/kanbanTools.ts b/mcp-server/src/tools/kanbanTools.ts
index a086db38..4d4fa851 100644
--- a/mcp-server/src/tools/kanbanTools.ts
+++ b/mcp-server/src/tools/kanbanTools.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
+import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@@ -16,33 +17,41 @@ export function registerKanbanTools(server: Pick) {
parameters: z.object({
...toolContextSchema,
}),
- execute: async ({ teamName, claudeDir }) =>
- await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
+ execute: async ({ teamName, claudeDir }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()));
+ },
});
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),
column: z.enum(['review', 'approved']),
}),
- execute: async ({ teamName, claudeDir, taskId, column }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, taskId, column }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
- ),
+ );
+ },
});
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),
}),
- execute: async ({ teamName, claudeDir, taskId }) =>
- await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
+ execute: async ({ teamName, claudeDir, taskId }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)));
+ },
});
server.addTool({
@@ -51,8 +60,10 @@ export function registerKanbanTools(server: Pick) {
parameters: z.object({
...toolContextSchema,
}),
- execute: async ({ teamName, claudeDir }) =>
- await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
+ execute: async ({ teamName, claudeDir }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()));
+ },
});
server.addTool({
@@ -62,8 +73,10 @@ export function registerKanbanTools(server: Pick) {
...toolContextSchema,
reviewer: z.string().min(1),
}),
- execute: async ({ teamName, claudeDir, reviewer }) =>
- await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
+ execute: async ({ teamName, claudeDir, reviewer }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)));
+ },
});
server.addTool({
@@ -73,9 +86,11 @@ export function registerKanbanTools(server: Pick) {
...toolContextSchema,
reviewer: z.string().min(1),
}),
- execute: async ({ teamName, claudeDir, reviewer }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, reviewer }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
- ),
+ );
+ },
});
}
diff --git a/mcp-server/src/tools/leadTools.ts b/mcp-server/src/tools/leadTools.ts
index ed986169..ae8c8add 100644
--- a/mcp-server/src/tools/leadTools.ts
+++ b/mcp-server/src/tools/leadTools.ts
@@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
+import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick) {
parameters: z.object({
...toolContextSchema,
}),
- execute: async ({ teamName, claudeDir }) => ({
- content: [
- {
- type: 'text' as const,
- text: await getController(teamName, claudeDir).tasks.leadBriefing(),
- },
- ],
- }),
+ execute: async ({ teamName, claudeDir }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return {
+ content: [
+ {
+ type: 'text' as const,
+ text: await getController(teamName, claudeDir).tasks.leadBriefing(),
+ },
+ ],
+ };
+ },
});
}
diff --git a/mcp-server/src/tools/messageTools.ts b/mcp-server/src/tools/messageTools.ts
index 59030abf..91419a1c 100644
--- a/mcp-server/src/tools/messageTools.ts
+++ b/mcp-server/src/tools/messageTools.ts
@@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
+import { assertConfiguredTeam } from '../utils/teamConfig';
import { jsonTextContent } from '../utils/format';
const toolContextSchema = {
@@ -12,7 +13,8 @@ const toolContextSchema = {
export function registerMessageTools(server: Pick) {
server.addTool({
name: 'message_send',
- description: 'Send a message into team inbox',
+ description:
+ 'Send a visible team/user message into team inbox. 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. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
parameters: z.object({
...toolContextSchema,
to: z.string().min(1),
@@ -31,6 +33,15 @@ export function registerMessageTools(server: Pick) {
})
)
.optional(),
+ taskRefs: z
+ .array(
+ z.object({
+ taskId: z.string().min(1),
+ displayId: z.string().min(1),
+ teamName: z.string().min(1),
+ })
+ )
+ .optional(),
}),
execute: async ({
teamName,
@@ -42,19 +53,23 @@ export function registerMessageTools(server: Pick) {
source,
leadSessionId,
attachments,
- }) =>
- await Promise.resolve(
+ taskRefs,
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).messages.sendMessage({
- to,
- text,
- ...(from ? { from } : {}),
- ...(summary ? { summary } : {}),
- ...(source ? { source } : {}),
- ...(leadSessionId ? { leadSessionId } : {}),
- ...(attachments?.length ? { attachments } : {}),
+ to,
+ text,
+ ...(from ? { from } : {}),
+ ...(summary ? { summary } : {}),
+ ...(source ? { source } : {}),
+ ...(leadSessionId ? { leadSessionId } : {}),
+ ...(attachments?.length ? { attachments } : {}),
+ ...(taskRefs?.length ? { taskRefs } : {}),
})
)
- ),
+ );
+ },
});
}
diff --git a/mcp-server/src/tools/processTools.ts b/mcp-server/src/tools/processTools.ts
index ac40b4e3..4038c52a 100644
--- a/mcp-server/src/tools/processTools.ts
+++ b/mcp-server/src/tools/processTools.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
+import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@@ -34,8 +35,9 @@ export function registerProcessTools(server: Pick) {
port,
url,
claudeProcessId,
- }) =>
- await Promise.resolve(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).processes.registerProcess({
pid,
@@ -47,7 +49,8 @@ export function registerProcessTools(server: Pick) {
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
})
)
- ),
+ );
+ },
});
server.addTool({
@@ -57,10 +60,12 @@ export function registerProcessTools(server: Pick) {
parameters: z.object({
...toolContextSchema,
}),
- execute: async ({ teamName, claudeDir }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
- ),
+ );
+ },
});
server.addTool({
@@ -71,10 +76,12 @@ export function registerProcessTools(server: Pick) {
...toolContextSchema,
pid: z.number().int().positive(),
}),
- execute: async ({ teamName, claudeDir, pid }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, pid }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
- ),
+ );
+ },
});
server.addTool({
@@ -85,9 +92,11 @@ export function registerProcessTools(server: Pick) {
...toolContextSchema,
pid: z.number().int().positive(),
}),
- execute: async ({ teamName, claudeDir, pid }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, pid }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
- ),
+ );
+ },
});
}
diff --git a/mcp-server/src/tools/reviewTools.ts b/mcp-server/src/tools/reviewTools.ts
index 60a8ef8b..9c9546b0 100644
--- a/mcp-server/src/tools/reviewTools.ts
+++ b/mcp-server/src/tools/reviewTools.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent, slimTask } from '../utils/format';
+import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@@ -20,8 +21,9 @@ export function registerReviewTools(server: Pick) {
reviewer: z.string().optional(),
leadSessionId: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).review.requestReview(taskId, {
@@ -31,7 +33,8 @@ export function registerReviewTools(server: Pick) {
}) as Record
)
)
- ),
+ );
+ },
});
server.addTool({
@@ -42,14 +45,16 @@ export function registerReviewTools(server: Pick) {
taskId: z.string().min(1),
from: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, taskId, from }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, taskId, from }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).review.startReview(taskId, {
...(from ? { from } : {}),
}) as Record
)
- ),
+ );
+ },
});
server.addTool({
@@ -63,19 +68,21 @@ export function registerReviewTools(server: Pick) {
notifyOwner: z.boolean().optional(),
leadSessionId: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).review.approveReview(taskId, {
...(from ? { from } : {}),
...(note ? { note } : {}),
- ...(notifyOwner !== false ? { 'notify-owner': true } : {}),
+ ...(notifyOwner === true ? { 'notify-owner': true } : {}),
...(leadSessionId ? { leadSessionId } : {}),
}) as Record
)
)
- ),
+ );
+ },
});
server.addTool({
@@ -88,8 +95,9 @@ export function registerReviewTools(server: Pick) {
comment: z.string().optional(),
leadSessionId: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).review.requestChanges(taskId, {
@@ -99,6 +107,7 @@ export function registerReviewTools(server: Pick) {
}) as Record
)
)
- ),
+ );
+ },
});
}
diff --git a/mcp-server/src/tools/runtimeTools.ts b/mcp-server/src/tools/runtimeTools.ts
index 6dd065e9..2f66baa1 100644
--- a/mcp-server/src/tools/runtimeTools.ts
+++ b/mcp-server/src/tools/runtimeTools.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
+import { assertConfiguredTeam } from '../utils/teamConfig';
const toolContextSchema = {
teamName: z.string().min(1),
@@ -57,8 +58,9 @@ export function registerRuntimeTools(server: Pick) {
worktree,
extraCliArgs,
waitForReady,
- }) =>
- jsonTextContent(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return jsonTextContent(
await getController(teamName, claudeDir).runtime.launchTeam({
cwd,
...(prompt ? { prompt } : {}),
@@ -72,7 +74,8 @@ export function registerRuntimeTools(server: Pick) {
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
...(waitForReady !== undefined ? { waitForReady } : {}),
})
- ),
+ );
+ },
});
server.addTool({
@@ -82,14 +85,16 @@ export function registerRuntimeTools(server: Pick) {
...toolContextSchema,
waitForStop: z.boolean().optional(),
}),
- execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) =>
- jsonTextContent(
+ execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return jsonTextContent(
await getController(teamName, claudeDir).runtime.stopTeam({
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
...(waitForStop !== undefined ? { waitForStop } : {}),
})
- ),
+ );
+ },
});
server.addTool({
@@ -112,8 +117,9 @@ export function registerRuntimeTools(server: Pick) {
observedAt,
diagnostics,
metadata,
- }) =>
- jsonTextContent(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
runId,
memberName,
@@ -124,12 +130,14 @@ export function registerRuntimeTools(server: Pick) {
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
- ),
+ );
+ },
});
server.addTool({
name: 'runtime_deliver_message',
- description: 'Deliver an OpenCode runtime message to the app-owned team journal and destination',
+ 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.',
parameters: z.object({
...toolContextSchema,
idempotencyKey: z.string().min(1),
@@ -156,8 +164,9 @@ export function registerRuntimeTools(server: Pick) {
createdAt,
summary,
taskRefs,
- }) =>
- jsonTextContent(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
idempotencyKey,
runId,
@@ -171,7 +180,8 @@ export function registerRuntimeTools(server: Pick) {
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
- ),
+ );
+ },
});
server.addTool({
@@ -203,8 +213,9 @@ export function registerRuntimeTools(server: Pick) {
createdAt,
summary,
metadata,
- }) =>
- jsonTextContent(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
idempotencyKey,
runId,
@@ -218,7 +229,8 @@ export function registerRuntimeTools(server: Pick) {
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
- ),
+ );
+ },
});
server.addTool({
@@ -241,8 +253,9 @@ export function registerRuntimeTools(server: Pick) {
observedAt,
status,
metadata,
- }) =>
- jsonTextContent(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
runId,
memberName,
@@ -253,6 +266,7 @@ export function registerRuntimeTools(server: Pick) {
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
- ),
+ );
+ },
});
}
diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts
index f4fc62a2..0785923d 100644
--- a/mcp-server/src/tools/taskTools.ts
+++ b/mcp-server/src/tools/taskTools.ts
@@ -1,9 +1,8 @@
import type { FastMCP } from 'fastmcp';
-import fs from 'node:fs';
-import path from 'node:path';
import { z } from 'zod';
import { agentBlocks, getController } from '../controller';
+import { assertConfiguredTeam } from '../utils/teamConfig';
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
@@ -70,42 +69,6 @@ function buildCreateTaskPayload(params: {
};
}
-function resolveConfigPath(teamName: string, claudeDir?: string): string {
- const controller = getController(teamName, claudeDir) as {
- context?: { paths?: { teamDir?: string } };
- };
- const teamDir = controller.context?.paths?.teamDir;
- if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
- throw new Error(
- `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
- );
- }
- return path.join(teamDir, 'config.json');
-}
-
-function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
- const configPath = resolveConfigPath(teamName, claudeDir);
- let raw = '';
- try {
- raw = fs.readFileSync(configPath, 'utf8');
- } catch {
- throw new Error(
- `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
- );
- }
-
- try {
- const parsed = JSON.parse(raw) as { name?: unknown };
- if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
- throw new Error('invalid');
- }
- } catch {
- throw new Error(
- `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
- );
- }
-}
-
export function registerTaskTools(server: Pick) {
server.addTool({
name: 'task_create',
@@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick) {
...toolContextSchema,
taskId: z.string().min(1),
}),
- execute: async ({ teamName, claudeDir, taskId }) =>
- await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
+ execute: async ({ teamName, claudeDir, taskId }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))
+ );
+ },
});
server.addTool({
@@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick) {
taskId: z.string().min(1),
commentId: z.string().min(1),
}),
- execute: async ({ teamName, claudeDir, taskId, commentId }) =>
- await Promise.resolve(
- jsonTextContent(
- getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)
- )
- ),
+ execute: async ({ teamName, claudeDir, taskId, commentId }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId))
+ );
+ },
});
server.addTool({
@@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick) {
relatedTo,
blockedBy,
limit,
- }) =>
- await Promise.resolve(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
getController(teamName, claudeDir).tasks.listTaskInventory({
...(owner ? { owner } : {}),
@@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick) {
limit: normalizeTaskListLimit(limit),
})
)
- ),
+ );
+ },
});
server.addTool({
@@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick) {
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
actor: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
- await Promise.resolve(
- jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record))
- ),
+ execute: async ({ teamName, claudeDir, taskId, status, actor }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(
+ slimTask(
+ getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<
+ string,
+ unknown
+ >
+ )
+ )
+ );
+ },
+ });
+
+ server.addTool({
+ name: 'task_restore',
+ description: 'Restore a deleted task back to pending work state',
+ parameters: z.object({
+ ...toolContextSchema,
+ taskId: z.string().min(1),
+ actor: z.string().optional(),
+ }),
+ execute: async ({ teamName, claudeDir, taskId, actor }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(
+ slimTask(
+ getController(teamName, claudeDir).tasks.restoreTask(taskId, actor) as Record<
+ string,
+ unknown
+ >
+ )
+ )
+ );
+ },
});
server.addTool({
@@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick) {
taskId: z.string().min(1),
actor: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, taskId, actor }) =>
- await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record))),
+ execute: async ({ teamName, claudeDir, taskId, actor }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(
+ slimTask(
+ getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<
+ string,
+ unknown
+ >
+ )
+ )
+ );
+ },
});
server.addTool({
@@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick) {
taskId: z.string().min(1),
actor: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, taskId, actor }) =>
- await Promise.resolve(
- jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record))
- ),
+ execute: async ({ teamName, claudeDir, taskId, actor }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(
+ slimTask(
+ getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<
+ string,
+ unknown
+ >
+ )
+ )
+ );
+ },
});
server.addTool({
@@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick) {
taskId: z.string().min(1),
owner: z.string().nullable(),
}),
- execute: async ({ teamName, claudeDir, taskId, owner }) =>
- await Promise.resolve(
- jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record))
- ),
+ execute: async ({ teamName, claudeDir, taskId, owner }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(
+ slimTask(
+ getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
+ string,
+ unknown
+ >
+ )
+ )
+ );
+ },
});
server.addTool({
@@ -413,17 +443,19 @@ export function registerTaskTools(server: Pick) {
text: z.string().min(1),
from: z.string().optional(),
}),
- execute: async ({ teamName, claudeDir, taskId, text, from }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, taskId, text, from }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
taskWriteResult(
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
- text,
- ...(from ? { from } : {}),
+ text,
+ ...(from ? { from } : {}),
}) as Record
)
)
- ),
+ );
+ },
});
server.addTool({
@@ -448,20 +480,22 @@ export function registerTaskTools(server: Pick) {
filename,
mimeType,
noFallback,
- }) =>
- await Promise.resolve(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
taskWriteResult(
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
- file: filePath,
- ...(mode ? { mode } : {}),
- ...(filename ? { filename } : {}),
- ...(mimeType ? { 'mime-type': mimeType } : {}),
- ...(noFallback ? { 'no-fallback': true } : {}),
+ file: filePath,
+ ...(mode ? { mode } : {}),
+ ...(filename ? { filename } : {}),
+ ...(mimeType ? { 'mime-type': mimeType } : {}),
+ ...(noFallback ? { 'no-fallback': true } : {}),
}) as Record
)
)
- ),
+ );
+ },
});
server.addTool({
@@ -488,20 +522,22 @@ export function registerTaskTools(server: Pick) {
filename,
mimeType,
noFallback,
- }) =>
- await Promise.resolve(
+ }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
taskWriteResult(
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
- file: filePath,
- ...(mode ? { mode } : {}),
- ...(filename ? { filename } : {}),
- ...(mimeType ? { 'mime-type': mimeType } : {}),
- ...(noFallback ? { 'no-fallback': true } : {}),
+ file: filePath,
+ ...(mode ? { mode } : {}),
+ ...(filename ? { filename } : {}),
+ ...(mimeType ? { 'mime-type': mimeType } : {}),
+ ...(noFallback ? { 'no-fallback': true } : {}),
}) as Record
)
)
- ),
+ );
+ },
});
server.addTool({
@@ -512,17 +548,19 @@ export function registerTaskTools(server: Pick) {
taskId: z.string().min(1),
value: z.enum(['lead', 'user', 'clear']),
}),
- execute: async ({ teamName, claudeDir, taskId, value }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, taskId, value }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
slimTask(
getController(teamName, claudeDir).tasks.setNeedsClarification(
- taskId,
- value === 'clear' ? null : value
+ taskId,
+ value === 'clear' ? null : value
) as Record
)
)
- ),
+ );
+ },
});
server.addTool({
@@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick) {
targetId: z.string().min(1),
relationship: relationshipTypeSchema,
}),
- execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
- await Promise.resolve(
- jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record))
- ),
+ execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
+ jsonTextContent(
+ slimTask(
+ getController(teamName, claudeDir).tasks.linkTask(
+ taskId,
+ targetId,
+ relationship
+ ) as Record
+ )
+ )
+ );
+ },
});
server.addTool({
@@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick) {
targetId: z.string().min(1),
relationship: relationshipTypeSchema,
}),
- execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
- await Promise.resolve(
+ execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return await Promise.resolve(
jsonTextContent(
- slimTask(getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship) as Record)
+ slimTask(
+ getController(teamName, claudeDir).tasks.unlinkTask(
+ taskId,
+ targetId,
+ relationship
+ ) as Record
+ )
)
- ),
+ );
+ },
});
server.addTool({
@@ -564,15 +620,21 @@ export function registerTaskTools(server: Pick) {
parameters: z.object({
...toolContextSchema,
memberName: z.string().min(1),
+ runtimeProvider: z.enum(['native', 'opencode']).optional(),
}),
- execute: async ({ teamName, claudeDir, memberName }) => ({
- content: [
- {
- type: 'text' as const,
- text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
- },
- ],
- }),
+ execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return {
+ content: [
+ {
+ type: 'text' as const,
+ text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
+ ...(runtimeProvider ? { runtimeProvider } : {}),
+ }),
+ },
+ ],
+ };
+ },
});
server.addTool({
@@ -582,13 +644,16 @@ export function registerTaskTools(server: Pick) {
...toolContextSchema,
memberName: z.string().min(1),
}),
- execute: async ({ teamName, claudeDir, memberName }) => ({
- content: [
- {
- type: 'text' as const,
- text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
- },
- ],
- }),
+ execute: async ({ teamName, claudeDir, memberName }) => {
+ assertConfiguredTeam(teamName, claudeDir);
+ return {
+ content: [
+ {
+ type: 'text' as const,
+ text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
+ },
+ ],
+ };
+ },
});
}
diff --git a/mcp-server/src/utils/teamConfig.ts b/mcp-server/src/utils/teamConfig.ts
new file mode 100644
index 00000000..1e9cb650
--- /dev/null
+++ b/mcp-server/src/utils/teamConfig.ts
@@ -0,0 +1,40 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { getController } from '../controller';
+
+function resolveConfigPath(teamName: string, claudeDir?: string): string {
+ const controller = getController(teamName, claudeDir) as {
+ context?: { paths?: { teamDir?: string } };
+ };
+ const teamDir = controller.context?.paths?.teamDir;
+ if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
+ throw new Error(
+ `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
+ );
+ }
+ return path.join(teamDir, 'config.json');
+}
+
+export function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
+ const configPath = resolveConfigPath(teamName, claudeDir);
+ let raw = '';
+ try {
+ raw = fs.readFileSync(configPath, 'utf8');
+ } catch {
+ throw new Error(
+ `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
+ );
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as { name?: unknown };
+ if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
+ throw new Error('invalid');
+ }
+ } catch {
+ throw new Error(
+ `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
+ );
+ }
+}
diff --git a/mcp-server/test/stdio.e2e.test.ts b/mcp-server/test/stdio.e2e.test.ts
index e9b46b32..93a53ff4 100644
--- a/mcp-server/test/stdio.e2e.test.ts
+++ b/mcp-server/test/stdio.e2e.test.ts
@@ -487,6 +487,52 @@ describe('agent-teams-mcp stdio e2e', () => {
}
});
+ it('fails closed for primary queue and inventory tools when team config is missing over stdio', async () => {
+ const client = new McpStdIoClient(serverPath, workspaceRoot);
+ const expected =
+ 'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
+
+ try {
+ await client.initialize();
+
+ const leadBriefing = (await client.callTool(
+ 'lead_briefing',
+ {
+ claudeDir,
+ teamName: 'team-lead',
+ },
+ 40
+ )) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
+ expect(leadBriefing.result?.isError).toBe(true);
+ expect(leadBriefing.result?.content?.[0]?.text).toContain(expected);
+
+ const taskBriefing = (await client.callTool(
+ 'task_briefing',
+ {
+ claudeDir,
+ teamName: 'team-lead',
+ memberName: 'alice',
+ },
+ 41
+ )) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
+ expect(taskBriefing.result?.isError).toBe(true);
+ expect(taskBriefing.result?.content?.[0]?.text).toContain(expected);
+
+ const taskList = (await client.callTool(
+ 'task_list',
+ {
+ claudeDir,
+ teamName: 'team-lead',
+ },
+ 42
+ )) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
+ expect(taskList.result?.isError).toBe(true);
+ expect(taskList.result?.content?.[0]?.text).toContain(expected);
+ } finally {
+ await client.close();
+ }
+ });
+
it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
@@ -996,7 +1042,6 @@ describe('agent-teams-mcp stdio e2e', () => {
claudeDir,
teamName: 'review-roundtrip-team',
taskId: roundtripTask.id,
- from: 'bob',
},
39
);
@@ -1563,7 +1608,6 @@ describe('agent-teams-mcp stdio e2e', () => {
claudeDir,
teamName: 'terminal-routing-team',
taskId: approvedTask.id,
- from: 'bob',
},
87
);
@@ -1779,4 +1823,172 @@ 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');
+
+ const restoreResult = await client.callTool(
+ 'task_restore',
+ {
+ claudeDir,
+ teamName: 'stdio-hardening-team',
+ taskId: task.id,
+ actor: 'team-lead',
+ },
+ 110
+ );
+ const restored = parseJsonToolResult((restoreResult as { result: unknown }).result);
+ expect(restored.status).toBe('pending');
+ expect(restored.reviewState).toBe('none');
+
+ const restoreAgainResult = await client.callTool(
+ 'task_restore',
+ {
+ claudeDir,
+ teamName: 'stdio-hardening-team',
+ taskId: task.id,
+ actor: 'team-lead',
+ },
+ 111
+ );
+ const restoreAgainResponse = restoreAgainResult as {
+ error?: { message?: string };
+ result?: { content?: Array<{ text?: string }> };
+ };
+ const restoreAgainErrorText =
+ restoreAgainResponse.error?.message ?? (restoreAgainResponse.result?.content?.[0]?.text ?? '');
+ expect(restoreAgainErrorText).toContain('task_restore only restores deleted tasks');
+ } finally {
+ await client.close();
+ }
+ });
});
diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts
index b5bbc643..befbdd40 100644
--- a/mcp-server/test/tools.test.ts
+++ b/mcp-server/test/tools.test.ts
@@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => {
res.end(JSON.stringify(result.body));
} catch (error) {
res.writeHead(500, { 'content-type': 'application/json' });
- res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
+ res.end(
+ JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
+ );
}
});
});
@@ -119,12 +121,17 @@ describe('agent-teams-mcp tools', () => {
text: 'Reply',
conversationId: 'conv-1',
replyToConversationId: 'conv-1',
+ taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'alpha' }],
});
expect(parsed?.success).toBe(true);
});
it('launches and stops teams through the runtime MCP tools', async () => {
+ const claudeDir = makeClaudeDir();
+ writeTeamConfig(claudeDir, 'alpha', {
+ members: [{ name: 'lead', role: 'team-lead' }],
+ });
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
@@ -171,6 +178,7 @@ describe('agent-teams-mcp tools', () => {
try {
const launched = parseJsonToolResult(
await getTool('team_launch').execute({
+ claudeDir,
teamName: 'alpha',
cwd: '/tmp/project',
controlUrl: server.baseUrl,
@@ -182,6 +190,7 @@ describe('agent-teams-mcp tools', () => {
const stopped = parseJsonToolResult(
await getTool('team_stop').execute({
+ claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
})
@@ -216,6 +225,13 @@ describe('agent-teams-mcp tools', () => {
});
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
+ const claudeDir = makeClaudeDir();
+ writeTeamConfig(claudeDir, 'alpha', {
+ members: [
+ { name: 'lead', role: 'team-lead' },
+ { name: 'alice', role: 'developer' },
+ ],
+ });
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
@@ -224,6 +240,7 @@ describe('agent-teams-mcp tools', () => {
try {
await getTool('runtime_bootstrap_checkin').execute({
+ claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
runId: 'run-oc',
@@ -231,6 +248,7 @@ describe('agent-teams-mcp tools', () => {
runtimeSessionId: 'ses-1',
});
await getTool('runtime_deliver_message').execute({
+ claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
idempotencyKey: 'idem-1',
@@ -241,6 +259,7 @@ describe('agent-teams-mcp tools', () => {
text: 'hello',
});
await getTool('runtime_task_event').execute({
+ claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
idempotencyKey: 'idem-task-1',
@@ -251,6 +270,7 @@ describe('agent-teams-mcp tools', () => {
event: 'started',
});
await getTool('runtime_heartbeat').execute({
+ claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
runId: 'run-oc',
@@ -280,6 +300,9 @@ describe('agent-teams-mcp tools', () => {
it('discovers the control endpoint from the published state file', async () => {
const claudeDir = makeClaudeDir();
+ writeTeamConfig(claudeDir, 'alpha', {
+ members: [{ name: 'lead', role: 'team-lead' }],
+ });
const statePath = path.join(claudeDir, 'team-control-api.json');
const server = await startControlServer(async ({ method, url }) => {
@@ -555,6 +578,25 @@ describe('agent-teams-mcp tools', () => {
'Use task_list only to search/browse inventory rows, not as your working queue.'
);
expect(memberBriefingText).toContain('Review MCP adapter');
+ expect(memberBriefingText).toContain('Full details in task comment e5f6a7b8');
+ expect(memberBriefingText).not.toContain('task_get_comment {');
+
+ const openCodeMemberBriefing = await getTool('member_briefing').execute({
+ claudeDir,
+ teamName,
+ memberName: 'alice',
+ runtimeProvider: 'opencode',
+ });
+ const openCodeMemberBriefingText = (
+ openCodeMemberBriefing as { content: Array<{ text: string }> }
+ ).content[0]?.text;
+ expect(openCodeMemberBriefingText).toContain('agent-teams_message_send');
+ expect(openCodeMemberBriefingText).toContain('Full details in task comment e5f6a7b8');
+ expect(openCodeMemberBriefingText).toContain(
+ 'Never invent placeholder task refs such as #00000000'
+ );
+ expect(openCodeMemberBriefingText).not.toContain('task_get_comment {');
+ expect(openCodeMemberBriefingText).not.toContain('notify your team lead via SendMessage');
});
it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => {
@@ -648,12 +690,16 @@ describe('agent-teams-mcp tools', () => {
expect(ownerInbox[0].text).toContain('task_start');
expect(ownerInbox[0].text).toContain('task_add_comment');
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
- expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
+ expect(ownerInbox[0].text).toContain(
+ 'If you are idle and this task is ready to start, start it now.'
+ );
expect(ownerInbox[0].text).toContain(
'If you are busy, blocked, or still need more context, immediately add a short task comment'
);
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
- expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
+ expect(ownerInbox[3].text).toContain(
+ 'If you are idle and this task is ready to start, start it now.'
+ );
expect(ownerInbox[3].text).toContain('task_add_comment');
const briefing = (await getTool('task_briefing').execute({
@@ -695,14 +741,22 @@ describe('agent-teams-mcp tools', () => {
expect(memberBriefingText).toContain(
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
);
- expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
+ expect(memberBriefingText).toContain(
+ 'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'
+ );
expect(memberBriefingText).toContain('Task briefing for alice:');
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]');
- fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]');
- fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]');
+ fs.writeFileSync(
+ path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'),
+ '[]'
+ );
+ fs.writeFileSync(
+ path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'),
+ '[]'
+ );
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
claudeDir,
@@ -710,7 +764,9 @@ describe('agent-teams-mcp tools', () => {
memberName: 'carol',
})) as { content: Array<{ text: string }> };
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
- expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).');
+ expect(inboxResolvedBriefingText).toContain(
+ 'Member briefing for carol on team "gamma" (gamma).'
+ );
expect(inboxResolvedBriefingText).toContain('Role: team member.');
await expect(
@@ -897,9 +953,9 @@ describe('agent-teams-mcp tools', () => {
teamName,
})
);
- expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
- 'needsFix'
- );
+ expect(
+ listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState
+ ).toBe('needsFix');
const kanbanCleared = parseJsonToolResult(
await getTool('kanban_clear').execute({
@@ -910,7 +966,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 +975,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,9 +1032,188 @@ 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);
+
+ const restored = parseJsonToolResult(
+ await getTool('task_restore').execute({
+ claudeDir,
+ teamName,
+ taskId: reviewTask.id,
+ actor: 'lead',
+ })
+ );
+ expect(restored.status).toBe('pending');
+ expect(restored.reviewState).toBe('none');
+
+ await expect(
+ getTool('task_restore').execute({
+ claudeDir,
+ teamName,
+ taskId: reviewTask.id,
+ actor: 'lead',
+ })
+ ).rejects.toThrow('task_restore only restores deleted tasks');
+ });
+
+ 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';
+ writeTeamConfig(claudeDir, teamName, {
+ members: [
+ { name: 'lead', role: 'team-lead' },
+ { name: 'alice', role: 'developer' },
+ ],
+ });
const sent = parseJsonToolResult(
await getTool('message_send').execute({
@@ -994,6 +1226,7 @@ describe('agent-teams-mcp tools', () => {
source: 'system_notification',
leadSessionId: 'session-42',
attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }],
+ taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName }],
})
);
@@ -1003,6 +1236,84 @@ describe('agent-teams-mcp tools', () => {
expect(rows[0].source).toBe('system_notification');
expect(rows[0].leadSessionId).toBe('session-42');
expect(rows[0].attachments[0].filename).toBe('note.txt');
+ expect(rows[0].taskRefs).toEqual([{ taskId: 'task-1', displayId: 'abcd1234', teamName }]);
+ });
+
+ it('uses forced app claude dir over model-supplied claudeDir when configured', async () => {
+ const forcedClaudeDir = makeClaudeDir();
+ const wrongClaudeDir = makeClaudeDir();
+ const teamName = 'forced-root';
+ writeTeamConfig(forcedClaudeDir, teamName, {
+ members: [
+ { name: 'lead', role: 'team-lead' },
+ { name: 'bob', role: 'developer' },
+ ],
+ });
+
+ const previousForcedDir = process.env.AGENT_TEAMS_MCP_CLAUDE_DIR;
+ process.env.AGENT_TEAMS_MCP_CLAUDE_DIR = forcedClaudeDir;
+ try {
+ const sent = parseJsonToolResult(
+ await getTool('message_send').execute({
+ claudeDir: wrongClaudeDir,
+ teamName,
+ to: 'user',
+ text: 'Forced root reply',
+ from: 'bob',
+ })
+ );
+
+ expect(sent.deliveredToInbox).toBe(true);
+ const forcedInboxPath = path.join(forcedClaudeDir, 'teams', teamName, 'inboxes', 'user.json');
+ const wrongInboxPath = path.join(wrongClaudeDir, 'teams', teamName, 'inboxes', 'user.json');
+ expect(JSON.parse(fs.readFileSync(forcedInboxPath, 'utf8'))[0]).toMatchObject({
+ from: 'bob',
+ to: 'user',
+ text: 'Forced root reply',
+ });
+ expect(fs.existsSync(wrongInboxPath)).toBe(false);
+ } finally {
+ if (previousForcedDir === undefined) {
+ delete process.env.AGENT_TEAMS_MCP_CLAUDE_DIR;
+ } else {
+ process.env.AGENT_TEAMS_MCP_CLAUDE_DIR = previousForcedDir;
+ }
+ }
+ });
+
+ it('rejects non-configured teams before MCP side-effect writes', async () => {
+ const claudeDir = makeClaudeDir();
+ writeTeamConfig(claudeDir, 'real-team', {
+ members: [{ name: 'lead', role: 'team-lead' }],
+ });
+
+ await expect(
+ getTool('message_send').execute({
+ claudeDir,
+ teamName: 'typo-team',
+ to: 'alice',
+ text: 'Should not create inbox',
+ })
+ ).rejects.toThrow('Unknown team "typo-team"');
+ await expect(
+ getTool('process_register').execute({
+ claudeDir,
+ teamName: 'typo-team',
+ pid: process.pid,
+ label: 'watcher',
+ })
+ ).rejects.toThrow('Unknown team "typo-team"');
+ await expect(
+ getTool('cross_team_send').execute({
+ claudeDir,
+ teamName: 'typo-team',
+ toTeam: 'real-team',
+ text: 'Should not deliver',
+ })
+ ).rejects.toThrow('Unknown team "typo-team"');
+
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false);
+ expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false);
});
it('exposes zod schemas that reject obviously invalid payloads', () => {
@@ -1153,9 +1464,7 @@ describe('agent-teams-mcp tools', () => {
expect(completed.comments).toBeUndefined();
// task_list: explicit inventory shape only
- const listed = parseJsonToolResult(
- await getTool('task_list').execute({ claudeDir, teamName })
- );
+ const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName }));
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
expect(listedTask).toBeDefined();
expect(listedTask).toEqual({
@@ -1195,9 +1504,7 @@ describe('agent-teams-mcp tools', () => {
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
const teamDir = path.join(claudeDir, 'teams', teamName);
fs.mkdirSync(teamDir, { recursive: true });
- const existing = fs.existsSync(sentPath)
- ? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
- : [];
+ const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : [];
existing.push(message);
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
}
@@ -1543,9 +1850,7 @@ describe('agent-teams-mcp tools', () => {
text: 'Roundtrip test message',
timestamp: '2026-03-15T16:00:00.000Z',
source: 'user_sent',
- attachments: [
- { id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
- ],
+ attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }],
});
const created = parseJsonToolResult(
@@ -1663,4 +1968,20 @@ describe('agent-teams-mcp tools', () => {
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
);
});
+
+ it('fails closed for primary queue and inventory tools when team config does not exist', async () => {
+ const claudeDir = makeClaudeDir();
+ const params = { claudeDir, teamName: 'team-lead' };
+ const expected =
+ 'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
+
+ await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected);
+ await expect(
+ getTool('task_briefing').execute({
+ ...params,
+ memberName: 'alice',
+ })
+ ).rejects.toThrow(expected);
+ await expect(getTool('task_list').execute(params)).rejects.toThrow(expected);
+ });
});
diff --git a/package.json b/package.json
index fa1dd358..c8ec837c 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)",
@@ -21,7 +21,6 @@
"dev": "node ./scripts/dev-with-runtime.mjs",
"dev:web": "node ./scripts/dev-web.mjs",
"dev:kill": "node bin/kill-dev.js",
- "opencode:prove-production": "node ./scripts/prove-opencode-production.mjs",
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
@@ -43,8 +42,11 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
+ "test:workspace:ci": "pnpm test:ci && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
"check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e",
+ "check:workspace:ci": "pnpm typecheck:workspace && pnpm test:workspace:ci && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e",
"check": "pnpm check:workspace && pnpm lint && pnpm lint:mcp",
+ "check:ci": "pnpm check:workspace:ci && pnpm lint && pnpm lint:mcp",
"fix": "pnpm lint:fix && pnpm format",
"quality": "pnpm check && pnpm format:check && npx knip",
"test:chunks": "tsx test/test-chunk-building.ts",
@@ -52,6 +54,7 @@
"test:noise": "tsx test/test-noise-filtering.ts",
"test:task-filtering": "tsx test/test-task-filtering.ts",
"test": "vitest run",
+ "test:ci": "vitest run --maxWorkers 1 --minWorkers 1",
"test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
@@ -259,6 +262,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 +277,8 @@
"icon": "resources/icons/mac/icon.icns"
},
"dmg": {
- "sign": false
+ "sign": false,
+ "artifactName": "Claude.Agent.Teams.UI-${version}-${arch}.${ext}"
},
"win": {
"target": [
@@ -291,10 +296,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 +311,8 @@
"publish": [
{
"provider": "github",
+ "owner": "777genius",
+ "repo": "claude_agent_teams_ui",
"releaseType": "release"
}
]
diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts
index 0e844e9d..e530b971 100644
--- a/packages/agent-graph/src/canvas/draw-agents.ts
+++ b/packages/agent-graph/src/canvas/draw-agents.ts
@@ -279,7 +279,13 @@ function drawLaunchStage(
for (let index = 0; index < 3; index += 1) {
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
ctx.beginPath();
- ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2);
+ ctx.arc(
+ x + Math.cos(angle) * dotOrbit,
+ y + Math.sin(angle) * dotOrbit,
+ 1.7,
+ 0,
+ Math.PI * 2
+ );
ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72);
ctx.fill();
}
@@ -736,6 +742,13 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri
return hexWithAlpha('#f59e0b', 0.92);
case 'runtime_pending':
return hexWithAlpha('#67e8f9', 0.9);
+ case 'shell_only':
+ case 'runtime_candidate':
+ return hexWithAlpha('#f97316', 0.9);
+ case 'registered_only':
+ return hexWithAlpha('#a1a1aa', 0.82);
+ case 'stale_runtime':
+ return hexWithAlpha('#ef4444', 0.82);
case 'settling':
return hexWithAlpha('#22c55e', 0.9);
case 'error':
diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts
index fa7461bc..cf7b21cc 100644
--- a/packages/agent-graph/src/ports/types.ts
+++ b/packages/agent-graph/src/ports/types.ts
@@ -22,8 +22,13 @@ export type GraphLaunchVisualState =
| 'spawning'
| 'permission_pending'
| 'runtime_pending'
+ | 'shell_only'
+ | 'runtime_candidate'
+ | 'registered_only'
+ | 'stale_runtime'
| 'settling'
- | 'error';
+ | 'error'
+ | 'skipped';
// ─── Edge & Particle Types ───────────────────────────────────────────────────
@@ -82,7 +87,7 @@ export interface GraphNode {
/** Avatar image URL (e.g., robohash) */
avatarUrl?: string;
/** Spawn lifecycle status */
- spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
+ spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped';
/** Shared launch-stage visual derived by the host app */
launchVisualState?: GraphLaunchVisualState;
/** Shared launch-stage text shown beside the node during launch only */
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..cdaa66c4 100644
--- a/runtime.lock.json
+++ b/runtime.lock.json
@@ -1,27 +1,27 @@
{
- "version": "0.0.6",
- "sourceRef": "v0.0.6",
+ "version": "0.0.8",
+ "sourceRef": "v0.0.8",
"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.8.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.8.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.8.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.8.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}
diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs
index b0baa0d1..ebd5db07 100644
--- a/scripts/dev-with-runtime.mjs
+++ b/scripts/dev-with-runtime.mjs
@@ -19,6 +19,7 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
: defaultRuntimeCacheRoot;
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
+const runtimeDisplayName = 'teams orchestrator';
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
function shouldUseWindowsShell(cmd) {
@@ -108,9 +109,10 @@ function getPlatformAssetKey() {
}
function getReleaseAssetUrl(runtimeLock, asset) {
- const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
- ? runtimeLock.releaseTag.trim()
- : runtimeLock.sourceRef;
+ const releaseTag =
+ typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
+ ? runtimeLock.releaseTag.trim()
+ : runtimeLock.sourceRef;
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
}
@@ -152,9 +154,7 @@ function truncateMiddle(value, maxLength) {
function buildProgressBar(progressRatio, width) {
const safeWidth = Math.max(10, width);
- const clampedRatio = Number.isFinite(progressRatio)
- ? Math.min(1, Math.max(0, progressRatio))
- : 0;
+ const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0;
const filledWidth = Math.round(safeWidth * clampedRatio);
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
}
@@ -164,7 +164,8 @@ function supportsProgressRedraw() {
}
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
- const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
+ const columns =
+ process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
const ratio = hasTotal ? writtenBytes / totalBytes : 0;
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
const bytesText = hasTotal
@@ -196,6 +197,16 @@ function readBinaryVersion(binaryPath) {
return runAndCapture(binaryPath, ['--version']);
}
+function formatRuntimeVersionForDisplay(versionText) {
+ const trimmed = versionText.trim();
+ if (!trimmed) {
+ return runtimeDisplayName;
+ }
+
+ const versionOnly = trimmed.replace(/\s*\([^)]*\)\s*$/, '');
+ return `${versionOnly} (${runtimeDisplayName})`;
+}
+
function isExecutable(filePath) {
if (!fs.existsSync(filePath)) {
return false;
@@ -305,7 +316,10 @@ async function downloadWithProgress(url, destinationPath) {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
- } else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) {
+ } else if (
+ (hasTotal && lastLoggedPercent < 100) ||
+ (!hasTotal && writtenBytes !== lastLoggedBytes)
+ ) {
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
}
}
@@ -511,7 +525,9 @@ async function main() {
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
}
- process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`);
+ process.stdout.write(
+ `Runtime version: ${formatRuntimeVersionForDisplay(resolvedRuntime.versionText)}\n`
+ );
const uiEnv = {
...process.env,
diff --git a/scripts/prove-opencode-production.mjs b/scripts/prove-opencode-production.mjs
deleted file mode 100644
index 020fba5d..00000000
--- a/scripts/prove-opencode-production.mjs
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/env node
-
-import { spawnSync } from 'node:child_process';
-import os from 'node:os';
-import path from 'node:path';
-import process from 'node:process';
-import { fileURLToPath } from 'node:url';
-
-const scriptDir = path.dirname(fileURLToPath(import.meta.url));
-const repoRoot = path.resolve(scriptDir, '..');
-const defaultEvidencePath = path.join(
- resolveAppDataDir(),
- 'claude-agent-teams-ui',
- 'opencode-bridge',
- 'production-e2e-evidence.json'
-);
-const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
-const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
-
-const env = {
- ...process.env,
- OPENCODE_E2E: '1',
- OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
- OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
- OPENCODE_E2E_WRITE_APP_EVIDENCE: '1',
- OPENCODE_E2E_WRITE_EVIDENCE_PATH:
- process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim() ||
- process.env.CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH?.trim() ||
- defaultEvidencePath,
- OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
-};
-
-if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
- const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
- env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
-}
-
-console.log('Running OpenCode production proof');
-console.log(`Model: ${env.OPENCODE_E2E_MODEL}`);
-console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
-console.log(`Evidence: ${env.OPENCODE_E2E_WRITE_EVIDENCE_PATH}`);
-console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
-
-const result = spawnSync(
- 'pnpm',
- ['exec', 'vitest', 'run', 'test/main/services/team/OpenCodeProductionGate.live.test.ts'],
- {
- cwd: repoRoot,
- env,
- stdio: 'inherit',
- shell: process.platform === 'win32',
- }
-);
-
-if (result.error) {
- console.error(`Failed to run OpenCode production proof: ${result.error.message}`);
- process.exit(1);
-}
-
-process.exit(result.status ?? 1);
-
-function resolveAppDataDir() {
- if (process.platform === 'darwin') {
- return path.join(os.homedir(), 'Library', 'Application Support');
- }
-
- if (process.platform === 'win32') {
- return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
- }
-
- return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
-}
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/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts
index 348fc1b4..f1429e4e 100644
--- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts
+++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts
@@ -13,7 +13,7 @@ import type {
} from '../ports/RecentProjectsSourcePort';
const DEFAULT_CACHE_TTL_MS = 10_000;
-const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
+const DEFAULT_DEGRADED_CACHE_TTL_MS = 30_000;
interface SourceLoadResult {
candidates: RecentProjectCandidate[];
@@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase {
}
const viewModel = this.deps.output.present(response);
- const cacheTtlMs = hasDegradedSources
- ? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
- : this.#cacheTtlMs;
+ const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs;
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
this.deps.logger.info('recent-projects loaded', {
diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
index 19c2ad0c..113eae5a 100644
--- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
+++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts
@@ -16,16 +16,26 @@ import type {
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
import type { ServiceContext } from '@main/services';
-const CODEX_THREAD_LIMIT = 40;
+const CODEX_THREAD_LIMIT = 20;
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
-const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
-const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
+const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
+const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
- CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
+ CODEX_INITIALIZE_TIMEOUT_MS +
+ CODEX_ARCHIVED_FETCH_TIMEOUT_MS +
+ CODEX_LIVE_FETCH_TIMEOUT_MS +
+ CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
+const CODEX_STALE_CANDIDATES_TTL_MS = 5 * 60_000;
+const CODEX_FULL_FAILURE_COOLDOWN_MS = 30_000;
+
+interface StaleCodexCandidatesSnapshot {
+ candidates: RecentProjectCandidate[];
+ capturedAt: number;
+}
function isInteractiveSource(source: unknown): boolean {
return source === 'vscode' || source === 'cli';
@@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
return Boolean(result.live.error || result.archived.error);
}
+function getFullFailureReason(result: CodexRecentThreadsResult): string | null {
+ if (!result.live.error || !result.archived.error) {
+ return null;
+ }
+
+ if (result.live.error === result.archived.error) {
+ return result.live.error;
+ }
+
+ return `live: ${result.live.error}; archived: ${result.archived.error}`;
+}
+
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
readonly sourceId = 'codex';
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
+ #staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null;
+ #fullFailureCooldownUntil = 0;
+ #fullFailureCooldownReason: string | null = null;
constructor(
private readonly deps: {
@@ -77,8 +102,19 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
};
}
+ const cooldown = this.#getActiveCooldown();
+ if (cooldown) {
+ this.deps.logger.info('codex recent-projects source cooldown active', cooldown);
+ return {
+ candidates: this.#getFreshStaleCandidates() ?? [],
+ degraded: true,
+ };
+ }
+
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
const degraded = isDegradedThreadResult(threadSegments);
+ const fullFailureReason = getFullFailureReason(threadSegments);
+ this.#updateFullFailureCooldown(fullFailureReason);
this.#logSegmentFailure(threadSegments, 'live');
this.#logSegmentFailure(threadSegments, 'archived');
const liveThreads = threadSegments.live.threads;
@@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
+ if (!degraded) {
+ this.#rememberHealthyCandidates(candidates);
+ }
+
+ if (degraded && candidates.length === 0) {
+ const staleCandidates = this.#getFreshStaleCandidates();
+ if (staleCandidates) {
+ this.deps.logger.info('codex recent-projects served stale candidates', {
+ count: staleCandidates.length,
+ reason: fullFailureReason ?? 'degraded-empty-result',
+ });
+
+ return {
+ candidates: staleCandidates,
+ degraded: true,
+ };
+ }
+ }
+
this.deps.logger.info('codex recent-projects source loaded', {
count: candidates.length,
degraded,
@@ -103,6 +158,53 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
};
}
+ #getActiveCooldown(): { retryAfterMs: number; reason: string | null } | null {
+ const retryAfterMs = this.#fullFailureCooldownUntil - Date.now();
+ if (retryAfterMs <= 0) {
+ return null;
+ }
+
+ return {
+ retryAfterMs,
+ reason: this.#fullFailureCooldownReason,
+ };
+ }
+
+ #updateFullFailureCooldown(reason: string | null): void {
+ if (!reason) {
+ this.#fullFailureCooldownUntil = 0;
+ this.#fullFailureCooldownReason = null;
+ return;
+ }
+
+ this.#fullFailureCooldownUntil = Date.now() + CODEX_FULL_FAILURE_COOLDOWN_MS;
+ this.#fullFailureCooldownReason = reason;
+ }
+
+ #rememberHealthyCandidates(candidates: RecentProjectCandidate[]): void {
+ this.#staleCandidatesSnapshot =
+ candidates.length > 0
+ ? {
+ candidates,
+ capturedAt: Date.now(),
+ }
+ : null;
+ }
+
+ #getFreshStaleCandidates(): RecentProjectCandidate[] | null {
+ const snapshot = this.#staleCandidatesSnapshot;
+ if (!snapshot) {
+ return null;
+ }
+
+ if (Date.now() - snapshot.capturedAt > CODEX_STALE_CANDIDATES_TTL_MS) {
+ this.#staleCandidatesSnapshot = null;
+ return null;
+ }
+
+ return [...snapshot.candidates];
+ }
+
async #listRecentThreads(binaryPath: string): Promise {
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
limit: CODEX_THREAD_LIMIT,
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..cc1d6975 100644
--- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts
+++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts
@@ -74,10 +74,7 @@ export class CodexAppServerClient {
}
): Promise {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
- const initializeTimeoutMs = Math.max(
- options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
- requestTimeoutMs
- );
+ const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
const totalTimeoutMs = Math.max(
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
@@ -122,13 +119,13 @@ export class CodexAppServerClient {
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
- const initializeTimeoutMs = Math.max(
- options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
- sessionRequestTimeoutMs
- );
+ const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
const totalTimeoutMs = Math.max(
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
- initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
+ initializeTimeoutMs +
+ liveRequestTimeoutMs +
+ archivedRequestTimeoutMs +
+ MIN_SESSION_OVERHEAD_TIMEOUT_MS
);
return this.#withThreadListSession(
@@ -140,50 +137,55 @@ export class CodexAppServerClient {
label: 'codex app-server thread/list',
},
async (session) => {
- const [live, archived] = await Promise.allSettled([
- session.request(
- 'thread/list',
- {
- archived: false,
- limit: options.limit,
- sortKey: 'updated_at',
- },
- liveRequestTimeoutMs
- ),
- session.request(
- 'thread/list',
- {
- archived: true,
- limit: options.limit,
- sortKey: 'updated_at',
- },
- archivedRequestTimeoutMs
- ),
- ]);
+ const live = await this.#requestThreadListSegment(session, {
+ archived: false,
+ limit: options.limit,
+ timeoutMs: liveRequestTimeoutMs,
+ });
+ const archived = await this.#requestThreadListSegment(session, {
+ archived: true,
+ limit: options.limit,
+ timeoutMs: archivedRequestTimeoutMs,
+ });
return {
- live:
- live.status === 'fulfilled'
- ? { threads: live.value.data ?? [] }
- : {
- threads: [],
- error: live.reason instanceof Error ? live.reason.message : String(live.reason),
- },
- archived:
- archived.status === 'fulfilled'
- ? { threads: archived.value.data ?? [] }
- : {
- threads: [],
- error:
- archived.reason instanceof Error
- ? archived.reason.message
- : String(archived.reason),
- },
+ live,
+ archived,
};
}
);
}
+ async #requestThreadListSegment(
+ session: JsonRpcSession,
+ options: {
+ archived: boolean;
+ limit: number;
+ timeoutMs: number;
+ }
+ ): Promise {
+ try {
+ const response = await session.request(
+ 'thread/list',
+ {
+ archived: options.archived,
+ limit: options.limit,
+ sortKey: 'updated_at',
+ },
+ options.timeoutMs
+ );
+
+ return {
+ threads: response.data ?? [],
+ };
+ } catch (error) {
+ return {
+ threads: [],
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+
async #withThreadListSession(
options: ThreadListSessionOptions,
handler: (session: JsonRpcSession) => Promise
@@ -201,7 +203,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/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts
index 79ae9ce3..27f7c5d9 100644
--- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts
+++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts
@@ -25,8 +25,8 @@ import type { TeamSummary } from '@shared/types';
const INITIAL_RECENT_PROJECTS = 11;
const LOAD_MORE_STEP = 8;
-const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
-const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
+const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000;
+const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000;
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts
index 40cdbf9e..e6c18df8 100644
--- a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts
+++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts
@@ -6,7 +6,7 @@ import type {
} from '@features/recent-projects/contracts';
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
-const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500;
+const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000;
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
let cachedAt = 0;
diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts
index f39822c6..dc6fb0f7 100644
--- a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts
+++ b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts
@@ -76,6 +76,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 0,
+ shellOnlyPendingCount: 0,
+ runtimeProcessPendingCount: 0,
+ runtimeCandidatePendingCount: 0,
+ noRuntimePendingCount: 0,
+ permissionPendingCount: 0,
});
expect(snapshot.teamLaunchState).toBe('partial_pending');
});
@@ -130,6 +135,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
bootstrapConfirmed: true,
hardFailure: false,
runtimePid: 333,
+ runtimeSessionId: 'session-bob',
+ livenessKind: 'confirmed_bootstrap',
+ pidSource: 'runtime_bootstrap',
+ runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
+ runtimeDiagnosticSeverity: 'info',
diagnostics: ['spawn accepted', 'late heartbeat received'],
},
},
@@ -145,12 +155,22 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
runtimeAlive: true,
bootstrapConfirmed: true,
runtimePid: 333,
+ runtimeSessionId: 'session-bob',
+ livenessKind: 'confirmed_bootstrap',
+ pidSource: 'runtime_bootstrap',
+ runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
+ runtimeDiagnosticSeverity: 'info',
});
expect(snapshot.summary).toEqual({
confirmedCount: 2,
pendingCount: 0,
failedCount: 0,
runtimeAlivePendingCount: 0,
+ shellOnlyPendingCount: 0,
+ runtimeProcessPendingCount: 0,
+ runtimeCandidatePendingCount: 0,
+ noRuntimePendingCount: 0,
+ permissionPendingCount: 0,
});
expect(snapshot.teamLaunchState).toBe('clean_success');
});
@@ -229,6 +249,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
pendingCount: 0,
failedCount: 1,
runtimeAlivePendingCount: 0,
+ shellOnlyPendingCount: 0,
+ runtimeProcessPendingCount: 0,
+ runtimeCandidatePendingCount: 0,
+ noRuntimePendingCount: 0,
+ permissionPendingCount: 0,
});
expect(snapshot.teamLaunchState).toBe('partial_failure');
});
@@ -279,9 +304,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
evidence: {
launchState: 'runtime_pending_permission',
agentToolAccepted: true,
- runtimeAlive: true,
+ runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
+ livenessKind: 'permission_blocked',
+ runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
+ runtimeDiagnosticSeverity: 'warning',
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
},
},
@@ -292,9 +320,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
launchState: 'runtime_pending_permission',
- runtimeAlive: true,
+ runtimeAlive: false,
agentToolAccepted: true,
bootstrapConfirmed: false,
+ livenessKind: 'permission_blocked',
+ runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
+ runtimeDiagnosticSeverity: 'warning',
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
hardFailure: false,
});
@@ -303,7 +334,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
confirmedCount: 1,
pendingCount: 1,
failedCount: 0,
- runtimeAlivePendingCount: 1,
+ runtimeAlivePendingCount: 0,
+ shellOnlyPendingCount: 0,
+ runtimeProcessPendingCount: 0,
+ runtimeCandidatePendingCount: 0,
+ noRuntimePendingCount: 0,
+ permissionPendingCount: 1,
});
expect(snapshot.teamLaunchState).toBe('partial_pending');
});
diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts
index 651a0640..546fd3ff 100644
--- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts
+++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts
@@ -11,6 +11,9 @@ import type {
PersistedTeamLaunchPhase,
PersistedTeamLaunchSnapshot,
ProviderModelLaunchIdentity,
+ TeamAgentRuntimeDiagnosticSeverity,
+ TeamAgentRuntimeLivenessKind,
+ TeamAgentRuntimePidSource,
TeamFastMode,
TeamProviderBackendId,
TeamProviderId,
@@ -38,6 +41,12 @@ export interface MixedSecondaryLaneMemberStateInput {
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
runtimePid?: number;
+ runtimeSessionId?: string;
+ sessionId?: string;
+ livenessKind?: TeamAgentRuntimeLivenessKind;
+ pidSource?: TeamAgentRuntimePidSource;
+ runtimeDiagnostic?: string;
+ runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
diagnostics?: string[];
} | null;
pendingReason?: string;
@@ -65,6 +74,19 @@ function deriveMemberLaunchState(params: {
return 'starting';
}
+function preservesStrongRuntimeAlive(value: {
+ runtimeAlive?: boolean;
+ bootstrapConfirmed?: boolean;
+ livenessKind?: TeamAgentRuntimeLivenessKind;
+}): boolean {
+ return (
+ value.runtimeAlive === true &&
+ (value.bootstrapConfirmed === true ||
+ value.livenessKind === 'confirmed_bootstrap' ||
+ value.livenessKind === 'runtime_process')
+ );
+}
+
function buildDiagnostics(
member: Pick<
PersistedTeamLaunchMemberState,
@@ -93,14 +115,17 @@ function buildDiagnostics(
}
function createSourcesFromStatus(
- status: Pick
+ status: Pick<
+ MemberSpawnStatusEntry,
+ 'livenessSource' | 'runtimeAlive' | 'bootstrapConfirmed' | 'livenessKind'
+ >
): PersistedTeamLaunchMemberSources | undefined {
const sources: PersistedTeamLaunchMemberSources = {};
if (status.livenessSource === 'heartbeat') {
sources.nativeHeartbeat = true;
sources.inboxHeartbeat = true;
}
- if (status.livenessSource === 'process' || status.runtimeAlive) {
+ if (status.livenessSource === 'process' && preservesStrongRuntimeAlive(status)) {
sources.processAlive = true;
}
return Object.values(sources).some(Boolean) ? sources : undefined;
@@ -119,6 +144,7 @@ function createPrimaryLaneMemberState(params: {
const providerId =
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
const runtime = params.status;
+ const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
const base: PersistedTeamLaunchMemberState = {
name: params.member.name.trim(),
@@ -151,21 +177,25 @@ function createPrimaryLaneMemberState(params: {
deriveMemberLaunchState({
hardFailure: runtime?.hardFailure,
bootstrapConfirmed: runtime?.bootstrapConfirmed,
- runtimeAlive: runtime?.runtimeAlive,
+ runtimeAlive: strongRuntimeAlive,
agentToolAccepted: runtime?.agentToolAccepted,
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
}),
agentToolAccepted: runtime?.agentToolAccepted === true,
- runtimeAlive: runtime?.runtimeAlive === true,
+ runtimeAlive: strongRuntimeAlive,
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
? [...new Set(runtime.pendingPermissionRequestIds)]
: undefined,
+ livenessKind: runtime?.livenessKind,
+ runtimeDiagnostic: runtime?.runtimeDiagnostic,
+ runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
lastHeartbeatAt: runtime?.lastHeartbeatAt,
- lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined,
+ runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
+ lastRuntimeAliveAt: preservesStrongRuntimeAlive(runtime ?? {}) ? params.updatedAt : undefined,
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
sources,
diagnostics: undefined,
@@ -180,13 +210,14 @@ function createSecondaryLaneMemberState(
const providerId =
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
const evidence = params.evidence;
+ const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
const hardFailureReason = evidence?.hardFailureReason;
const launchState =
evidence?.launchState ??
deriveMemberLaunchState({
hardFailure: evidence?.hardFailure,
bootstrapConfirmed: evidence?.bootstrapConfirmed,
- runtimeAlive: evidence?.runtimeAlive,
+ runtimeAlive: strongRuntimeAlive,
agentToolAccepted: evidence?.agentToolAccepted,
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
});
@@ -214,7 +245,7 @@ function createSecondaryLaneMemberState(
laneOwnerProviderId: providerId,
launchState,
agentToolAccepted: evidence?.agentToolAccepted === true,
- runtimeAlive: evidence?.runtimeAlive === true,
+ runtimeAlive: strongRuntimeAlive,
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
hardFailureReason,
@@ -227,15 +258,21 @@ function createSecondaryLaneMemberState(
evidence.runtimePid > 0
? Math.trunc(evidence.runtimePid)
: undefined,
+ runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
+ livenessKind: evidence?.livenessKind,
+ pidSource: evidence?.pidSource,
+ runtimeDiagnostic: evidence?.runtimeDiagnostic,
+ runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity,
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
- lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,
+ runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
+ lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined,
lastEvaluatedAt: params.updatedAt,
- sources: evidence?.runtimeAlive
+ sources: strongRuntimeAlive
? {
processAlive: true,
- nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined,
- inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined,
+ nativeHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
+ inboxHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
}
: undefined,
diagnostics: evidence?.diagnostics?.length
@@ -256,6 +293,11 @@ function summarizeMembers(
let pendingCount = 0;
let failedCount = 0;
let runtimeAlivePendingCount = 0;
+ let shellOnlyPendingCount = 0;
+ let runtimeProcessPendingCount = 0;
+ let runtimeCandidatePendingCount = 0;
+ let noRuntimePendingCount = 0;
+ let permissionPendingCount = 0;
for (const memberName of expectedMembers) {
const entry = members[memberName];
@@ -275,6 +317,22 @@ function summarizeMembers(
if (entry.runtimeAlive) {
runtimeAlivePendingCount += 1;
}
+ if (entry.launchState === 'runtime_pending_permission') {
+ permissionPendingCount += 1;
+ }
+ if (entry.livenessKind === 'shell_only') {
+ shellOnlyPendingCount += 1;
+ } else if (entry.livenessKind === 'runtime_process') {
+ runtimeProcessPendingCount += 1;
+ } else if (entry.livenessKind === 'runtime_process_candidate') {
+ runtimeCandidatePendingCount += 1;
+ } else if (
+ entry.livenessKind === 'not_found' ||
+ entry.livenessKind === 'stale_metadata' ||
+ entry.livenessKind === 'registered_only'
+ ) {
+ noRuntimePendingCount += 1;
+ }
}
return {
@@ -282,6 +340,11 @@ function summarizeMembers(
pendingCount,
failedCount,
runtimeAlivePendingCount,
+ shellOnlyPendingCount,
+ runtimeProcessPendingCount,
+ runtimeCandidatePendingCount,
+ noRuntimePendingCount,
+ permissionPendingCount,
};
}
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/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts
index 183b918b..0ae0c94e 100644
--- a/src/features/tmux-installer/main/composition/runtimeSupport.ts
+++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts
@@ -1,5 +1,9 @@
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
-import { TmuxPlatformCommandExecutor } from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
+import {
+ type RuntimeProcessTableRow,
+ type TmuxPaneRuntimeInfo,
+ TmuxPlatformCommandExecutor,
+} from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
const runtimeStatusSource = new TmuxStatusSourceAdapter();
const runtimeCommandExecutor = new TmuxPlatformCommandExecutor();
@@ -24,6 +28,18 @@ export async function listTmuxPanePidsForCurrentPlatform(
return runtimeCommandExecutor.listPanePids(paneIds);
}
+export async function listTmuxPaneRuntimeInfoForCurrentPlatform(
+ paneIds: readonly string[]
+): Promise> {
+ return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
+}
+
+export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
+ RuntimeProcessTableRow[]
+> {
+ return runtimeCommandExecutor.listRuntimeProcesses();
+}
+
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
runtimeCommandExecutor.killPaneSync(paneId);
invalidateTmuxRuntimeStatusCache();
diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts
index 21c41e9f..d18d99ea 100644
--- a/src/features/tmux-installer/main/index.ts
+++ b/src/features/tmux-installer/main/index.ts
@@ -9,5 +9,12 @@ export {
isTmuxRuntimeReadyForCurrentPlatform,
killTmuxPaneForCurrentPlatform,
killTmuxPaneForCurrentPlatformSync,
+ listRuntimeProcessesForCurrentTmuxPlatform,
listTmuxPanePidsForCurrentPlatform,
+ listTmuxPaneRuntimeInfoForCurrentPlatform,
} from './composition/runtimeSupport';
+export type {
+ RuntimeProcessTableRow,
+ TmuxPaneRuntimeInfo,
+} from './infrastructure/runtime/TmuxPlatformCommandExecutor';
+export { parseRuntimeProcessTable } from './infrastructure/runtime/TmuxPlatformCommandExecutor';
diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts
index 4b062134..0500d252 100644
--- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts
+++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts
@@ -12,6 +12,43 @@ interface ExecResult {
stderr: string;
}
+export interface TmuxPaneRuntimeInfo {
+ paneId: string;
+ panePid: number;
+ currentCommand?: string;
+ currentPath?: string;
+ sessionName?: string;
+ windowName?: string;
+}
+
+export interface RuntimeProcessTableRow {
+ pid: number;
+ ppid: number;
+ command: string;
+}
+
+export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
+ const rows: RuntimeProcessTableRow[] = [];
+ for (const line of output.split('\n')) {
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
+ if (!match) continue;
+
+ const pid = Number.parseInt(match[1], 10);
+ const ppid = Number.parseInt(match[2], 10);
+ const command = match[3]?.trim() ?? '';
+ if (
+ Number.isFinite(pid) &&
+ pid > 0 &&
+ Number.isFinite(ppid) &&
+ ppid >= 0 &&
+ command.length > 0
+ ) {
+ rows.push({ pid, ppid, command });
+ }
+ }
+ return rows;
+}
+
export class TmuxPlatformCommandExecutor {
readonly #wslService: TmuxWslService;
readonly #packageManagerResolver: TmuxPackageManagerResolver;
@@ -54,34 +91,70 @@ export class TmuxPlatformCommandExecutor {
}
}
- async listPanePids(paneIds: readonly string[]): Promise> {
+ async listPaneRuntimeInfo(paneIds: readonly string[]): Promise> {
const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
if (normalizedPaneIds.length === 0) {
return new Map();
}
- const result = await this.execTmux(
- ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
- 3_000
- );
+ const format = [
+ '#{pane_id}',
+ '#{pane_pid}',
+ '#{pane_current_command}',
+ '#{pane_current_path}',
+ '#{session_name}',
+ '#{window_name}',
+ ].join('\t');
+
+ const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000);
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Failed to list tmux panes');
}
const wanted = new Set(normalizedPaneIds);
- const panePidById = new Map();
+ const paneInfoById = new Map();
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
- const [paneId = '', rawPid = ''] = trimmed.split('\t');
+ const [
+ paneId = '',
+ rawPid = '',
+ currentCommand = '',
+ currentPath = '',
+ sessionName = '',
+ windowName = '',
+ ] = trimmed.split('\t');
const normalizedPaneId = paneId.trim();
if (!wanted.has(normalizedPaneId)) continue;
const pid = Number.parseInt(rawPid.trim(), 10);
if (Number.isFinite(pid) && pid > 0) {
- panePidById.set(normalizedPaneId, pid);
+ paneInfoById.set(normalizedPaneId, {
+ paneId: normalizedPaneId,
+ panePid: pid,
+ currentCommand: currentCommand.trim() || undefined,
+ currentPath: currentPath.trim() || undefined,
+ sessionName: sessionName.trim() || undefined,
+ windowName: windowName.trim() || undefined,
+ });
}
}
- return panePidById;
+ return paneInfoById;
+ }
+
+ async listPanePids(paneIds: readonly string[]): Promise> {
+ const info = await this.listPaneRuntimeInfo(paneIds);
+ return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid]));
+ }
+
+ async listRuntimeProcesses(): Promise {
+ const result =
+ process.platform === 'win32'
+ ? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command='])
+ : await this.#execNativePs();
+ if (result.exitCode !== 0) {
+ throw new Error(result.stderr || 'Failed to list runtime processes');
+ }
+ return parseRuntimeProcessTable(result.stdout);
}
killPaneSync(paneId: string): void {
@@ -125,6 +198,29 @@ export class TmuxPlatformCommandExecutor {
return [...candidates];
}
+ async #execNativePs(): Promise {
+ await resolveInteractiveShellEnv();
+ const env = buildEnrichedEnv();
+ return new Promise((resolve) => {
+ execFile(
+ 'ps',
+ ['-ax', '-o', 'pid=,ppid=,command='],
+ { env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
+ (error, stdout, stderr) => {
+ const errorCode =
+ typeof error === 'object' && error !== null && 'code' in error
+ ? (error as NodeJS.ErrnoException).code
+ : undefined;
+ resolve({
+ exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
+ stdout: String(stdout),
+ stderr: String(stderr) || (error instanceof Error ? error.message : ''),
+ });
+ }
+ );
+ });
+ }
+
async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise {
const platform =
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'
diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts
index 376afc79..ded52232 100644
--- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts
+++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts
@@ -78,7 +78,8 @@ describe('TmuxPlatformCommandExecutor', () => {
);
vi.spyOn(executor, 'execTmux').mockResolvedValue({
exitCode: 0,
- stdout: '%1\t111\n%2\t222\n%3\tnot-a-pid\n',
+ stdout:
+ '%1\t111\tzsh\t/tmp\tteam\tmain\n%2\t222\tnode\t/project\tteam\tworker\n%3\tnot-a-pid\tzsh\t/tmp\tteam\tmain\n',
stderr: '',
});
@@ -86,8 +87,35 @@ describe('TmuxPlatformCommandExecutor', () => {
new Map([['%2', 222]])
);
expect(executor.execTmux).toHaveBeenCalledWith(
- ['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
+ [
+ 'list-panes',
+ '-a',
+ '-F',
+ '#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}\t#{session_name}\t#{window_name}',
+ ],
3_000
);
});
+
+ it('lists runtime processes inside WSL on Windows instead of using host ps', async () => {
+ setPlatform('win32');
+ const execInPreferredDistro = vi.fn(async () => ({
+ exitCode: 0,
+ stdout: ' 42 1 opencode runtime --team-name demo\n',
+ stderr: '',
+ }));
+ const executor = new TmuxPlatformCommandExecutor(
+ {
+ execInPreferredDistro,
+ getPersistedPreferredDistroSync: () => 'Ubuntu',
+ } as never,
+ {} as never
+ );
+
+ await expect(executor.listRuntimeProcesses()).resolves.toEqual([
+ { pid: 42, ppid: 1, command: 'opencode runtime --team-name demo' },
+ ]);
+ expect(execInPreferredDistro).toHaveBeenCalledWith(['ps', '-ax', '-o', 'pid=,ppid=,command=']);
+ expect(childProcess.execFile).not.toHaveBeenCalled();
+ });
});
diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts
index 335a5e69..84cc73a1 100644
--- a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts
+++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts
@@ -268,6 +268,23 @@ export class TmuxWslService {
return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout);
}
+ async execInPreferredDistro(
+ args: string[],
+ preferredDistroName?: string | null,
+ timeout = 5_000
+ ): Promise {
+ const distroName = preferredDistroName ?? (await this.probe()).preference?.preferredDistroName;
+ if (!distroName) {
+ return {
+ exitCode: 1,
+ stdout: '',
+ stderr: 'No WSL distribution is available.',
+ };
+ }
+
+ return this.#run(['-d', distroName, '-e', ...args], timeout);
+ }
+
getPersistedPreferredDistroSync(): string | null {
return this.#preferenceStore.getPreferredDistroSync();
}
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..b02a12a0 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -16,7 +16,8 @@
// 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.
+// Sentry must stay near the top to capture early errors after storage migration.
import './sentry';
import {
@@ -53,6 +54,7 @@ import {
resolveAgentTeamsMcpLaunchSpec,
TeamMcpConfigBuilder,
} from '@main/services/team/TeamMcpConfigBuilder';
+import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import {
CONTEXT_CHANGED,
@@ -114,9 +116,6 @@ import {
OpenCodeBridgeCommandHandshakePort,
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
-import { resolveOpenCodeTeamLaunchModeFromEnv } from './services/team/opencode/config/OpenCodeLaunchModeEnv';
-import { resolveOpenCodeProductionE2EEvidencePath } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath';
-import { OpenCodeProductionE2EEvidenceStore } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore';
import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import {
buildTeamControlApiBaseUrl,
@@ -131,13 +130,19 @@ import {
} from './services/team/TeamReconcileDrainScheduler';
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
import { getAppIconPath } from './utils/appIcon';
-import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
+import {
+ getClaudeBasePath,
+ getProjectsBasePath,
+ getTeamsBasePath,
+ getTodosBasePath,
+} from './utils/pathDecoder';
import {
clearRendererAvailability,
markRendererReady,
markRendererUnavailable,
safeSendToRenderer,
} from './utils/safeWebContentsSend';
+import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
import { syncTelemetryFlag } from './sentry';
import {
ActiveTeamRegistry,
@@ -179,6 +184,21 @@ import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
+let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null;
+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
@@ -205,10 +225,12 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise {
+ if (!openCodeLifecycleBridge) {
+ return;
+ }
+ const result = await openCodeLifecycleBridge.cleanupOpenCodeHosts({
+ reason,
+ mode: reason === 'shutdown' ? 'force' : 'stale',
+ staleAgeMs: reason === 'startup' ? 5 * 60_000 : null,
+ leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null,
+ });
+ if (result.cleaned > 0) {
+ logger.info(
+ `[OpenCode] ${reason} host cleanup removed ${result.cleaned} registry host(s), ${result.remaining} remaining`
+ );
+ }
+ for (const diagnostic of result.diagnostics) {
+ logger.warn(`[OpenCode] ${reason} host cleanup: ${diagnostic}`);
+ }
}
// --- Team display name cache (avoid listTeams() on every notification) ---
@@ -533,6 +567,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.
@@ -686,22 +784,19 @@ function wireFileWatcherEvents(context: ServiceContext): void {
}
// Relay inbox changes into active runtime recipients.
- if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
+ if (detail.startsWith('inboxes/')) {
const match = /^inboxes\/(.+)\.json$/.exec(detail);
- if (match && teamDataService) {
+ if (match) {
const inboxName = match[1];
- void teamDataService
- .getLeadMemberName(teamName)
- .then((leadName) => {
- if (!leadName) return;
- if (inboxName === leadName) {
- return teamProvisioningService.relayLeadInboxMessages(teamName);
+ void teamProvisioningService
+ .relayInboxFileToLiveRecipient(teamName, inboxName)
+ .then((relay) => {
+ if (relay.diagnostics?.length) {
+ logger.warn(
+ `[FileWatcher] relay diagnostics for ${teamName}/${inboxName}: ${relay.diagnostics.join('; ')}`
+ );
}
- // Teammate inbox relay DISABLED (2026-03-23): teammates read their own
- // inbox files directly via fs.watch. See teams.ts handleSendMessage for details.
- // Lead relay is still needed (lead reads stdin only, not inbox files).
- return undefined;
})
.catch((e: unknown) =>
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`)
@@ -714,13 +809,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 +822,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 +1000,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();
@@ -927,6 +1033,9 @@ async function initializeServices(): Promise {
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
teamProvisioningService = new TeamProvisioningService();
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
+ await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
+ logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
+ );
// Startup GC: remove stale MCP config files from previous sessions (best-effort)
void new TeamMcpConfigBuilder().gcStaleConfigs();
void teamDataService
@@ -1177,6 +1286,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 +1313,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 +1330,120 @@ 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(
+ 'OpenCode host registry cleanup',
+ () => cleanupOpenCodeHostsForLifecycle('shutdown'),
+ 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 +1460,9 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
}
function scheduleRendererRecovery(win: BrowserWindow): void {
+ if (isShutdownStarted()) {
+ return;
+ }
if (rendererRecoveryTimer) {
return;
}
@@ -1336,6 +1477,9 @@ function scheduleRendererRecovery(win: BrowserWindow): void {
rendererRecoveryTimer = setTimeout(() => {
rendererRecoveryTimer = null;
+ if (isShutdownStarted()) {
+ return;
+ }
if (!mainWindow || mainWindow !== win || win.isDestroyed()) {
return;
}
@@ -1347,12 +1491,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 +1589,9 @@ function createWindow(): void {
});
mainWindow.webContents.on('did-start-loading', () => {
+ if (isShutdownStarted()) {
+ return;
+ }
markRendererUnavailable(mainWindow);
branchStatusService?.resetAllTracking();
});
@@ -1447,6 +1599,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 +1610,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 +1624,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 +1708,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 +1751,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 +1832,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 +1848,9 @@ void app.whenReady().then(async () => {
}
app.on('activate', () => {
+ if (isShutdownStarted()) {
+ return;
+ }
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
@@ -1689,7 +1861,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 +1872,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..9e98f8a4 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -64,6 +64,7 @@ import {
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SHOW_MESSAGE_NOTIFICATION,
+ TEAM_SKIP_MEMBER_FOR_LAUNCH,
TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK,
TEAM_START_TASK_BY_USER,
@@ -90,19 +91,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 +154,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 +191,11 @@ import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
+ TeamFastMode,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
- TeamFastMode,
TeamProviderBackendId,
TeamProviderId,
TeamProvisioningModelVerificationMode,
@@ -209,9 +211,9 @@ 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');
+const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
/**
* In-memory set of rate-limit message keys already processed.
@@ -221,6 +223,27 @@ const logger = createLogger('IPC:teams');
const seenRateLimitKeys = new Set();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
+async function withTimeoutValue(
+ promise: Promise,
+ timeoutMs: number,
+ timeoutValue: T
+): Promise {
+ let timer: ReturnType | null = null;
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(timeoutValue), timeoutMs);
+ timer.unref?.();
+ }),
+ ]);
+ } finally {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+}
+
function noteHeavyTeamDataWorkerFallback(operation: string): void {
if (!app.isPackaged) {
return;
@@ -599,6 +622,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
+ ipcMain.handle(TEAM_SKIP_MEMBER_FOR_LAUNCH, handleSkipMemberForLaunch);
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
@@ -676,6 +700,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
+ ipcMain.removeHandler(TEAM_SKIP_MEMBER_FOR_LAUNCH);
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
ipcMain.removeHandler(TEAM_RESTORE_TASK);
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
@@ -1018,7 +1043,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 +1242,7 @@ function parseOptionalTeamFastMode(
};
}
-type RuntimeRosterMutationMember = {
+interface RuntimeRosterMutationMember {
name: string;
role?: string;
workflow?: string;
@@ -1228,7 +1253,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.';
@@ -2538,16 +2563,24 @@ async function handleSendMessage(
// Inbox path: offline lead or regular members (no attachment support)
const baseText = payload.text!.trim();
+ const replyRecipient =
+ typeof payload.from === 'string' && payload.from.trim().length > 0
+ ? payload.from.trim()
+ : 'user';
+ const isOpenCodeRecipient =
+ !isLeadRecipient && (await provisioning.isOpenCodeRuntimeRecipient(tn, memberName));
const memberDeliveryText = buildMessageDeliveryText(baseText, {
actionMode,
isLeadRecipient,
- replyRecipient: typeof payload.from === 'string' ? payload.from : 'user',
+ replyRecipient,
});
+ const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText;
const result = await getTeamDataService().sendMessage(tn, {
member: memberName,
- text: memberDeliveryText,
+ text: inboxText,
summary: payload.summary,
from: payload.from,
+ actionMode,
source: 'user_sent',
taskRefs: validatedTaskRefs.value,
});
@@ -2570,28 +2603,63 @@ async function handleSendMessage(
// logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`);
// }
// }
- if (!isLeadRecipient && isAlive) {
- void provisioning
- .deliverOpenCodeMemberMessage(tn, {
- memberName,
- text: memberDeliveryText,
- messageId: result.messageId,
- })
- .then((delivery) => {
- if (delivery.delivered || delivery.reason === 'recipient_is_not_opencode') {
- return;
+ if (isOpenCodeRecipient) {
+ try {
+ const relay = await withTimeoutValue(
+ provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
+ onlyMessageId: result.messageId,
+ source: 'ui-send',
+ deliveryMetadata: {
+ replyRecipient,
+ actionMode,
+ taskRefs: validatedTaskRefs.value,
+ },
+ }),
+ OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS,
+ {
+ relayed: 0,
+ attempted: 1,
+ delivered: 0,
+ failed: 1,
+ lastDelivery: {
+ delivered: false,
+ reason: 'opencode_runtime_delivery_timeout',
+ diagnostics: ['opencode_runtime_delivery_timeout'],
+ },
}
+ );
+ const delivery = relay.lastDelivery ?? {
+ delivered: relay.relayed > 0,
+ reason: relay.relayed > 0 ? undefined : 'opencode_message_delivery_not_attempted',
+ diagnostics: undefined,
+ };
+ result.runtimeDelivery = {
+ providerId: 'opencode',
+ attempted: true,
+ delivered: delivery.delivered,
+ reason: delivery.reason,
+ diagnostics: delivery.diagnostics,
+ };
+ if (!delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') {
logger.warn(
`OpenCode runtime delivery after sendMessage failed for teammate "${memberName}": ${
delivery.reason ?? 'unknown error'
}`
);
- })
- .catch((e: unknown) =>
- logger.warn(
- `OpenCode runtime delivery after sendMessage crashed for teammate "${memberName}": ${String(e)}`
- )
+ }
+ } catch (e: unknown) {
+ const reason = e instanceof Error ? e.message : String(e);
+ result.runtimeDelivery = {
+ providerId: 'opencode',
+ attempted: true,
+ delivered: false,
+ reason,
+ diagnostics: [reason],
+ };
+ logger.warn(
+ `OpenCode runtime delivery after sendMessage crashed for teammate "${memberName}": ${reason}`
);
+ }
}
// Best-effort relay for lead via inbox
@@ -3391,6 +3459,27 @@ async function handleRestartMember(
);
}
+async function handleSkipMemberForLaunch(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown,
+ memberName: unknown
+): Promise> {
+ const validatedTeamName = validateTeamName(teamName);
+ if (!validatedTeamName.valid) {
+ return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
+ }
+ const validatedMemberName = validateMemberName(memberName);
+ if (!validatedMemberName.valid) {
+ return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
+ }
+ return wrapTeamHandler('skipMemberForLaunch', async () =>
+ getTeamProvisioningService().skipMemberForLaunch(
+ validatedTeamName.value!,
+ validatedMemberName.value!
+ )
+ );
+}
+
async function handleStopTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
@@ -3402,7 +3491,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 +3795,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 +4645,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..9e0d1b04 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;
+ }
}
// ===========================================================================
@@ -529,6 +570,7 @@ export class ConfigManager {
...DEFAULT_CONFIG.general,
...(loaded.general ?? {}),
};
+ mergedGeneral.multimodelEnabled = true;
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
// Merge triggers: preserve existing triggers, add missing builtin ones
diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts
index 30dcd444..1146cf47 100644
--- a/src/main/services/infrastructure/FileWatcher.ts
+++ b/src/main/services/infrastructure/FileWatcher.ts
@@ -52,6 +52,8 @@ const CATCH_UP_SESSION_RETENTION_MS = 20 * 60 * 1000; // 20 minutes
const CATCH_UP_SUBAGENT_RETENTION_MS = 5 * 60 * 1000; // 5 minutes
/** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */
const CATCH_UP_SCAN_BUDGET = 24;
+/** Retire one file from catch-up after repeated local stat timeouts. */
+const CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT = 3;
interface AppendedParseResult {
messages: ParsedMessage[];
@@ -93,6 +95,8 @@ export class FileWatcher extends EventEmitter {
private catchUpInProgress = false;
/** Round-robin cursor so catch-up work is spread across tracked files. */
private catchUpCursor = 0;
+ /** Consecutive catch-up stat timeouts per file. */
+ private catchUpStatFailures = new Map();
/** Timer for SSH polling mode (replaces fs.watch) */
private pollingTimer: NodeJS.Timeout | null = null;
/** Polling interval for SSH mode */
@@ -232,6 +236,7 @@ export class FileWatcher extends EventEmitter {
this.lastProcessedLineCount.clear();
this.lastProcessedSize.clear();
this.activeSessionFiles.clear();
+ this.catchUpStatFailures.clear();
this.processingInProgress.clear();
this.pendingReprocess.clear();
@@ -284,6 +289,7 @@ export class FileWatcher extends EventEmitter {
this.lastProcessedLineCount.clear();
this.lastProcessedSize.clear();
this.activeSessionFiles.clear();
+ this.catchUpStatFailures.clear();
this.polledFileSizes.clear();
this.processingInProgress.clear();
this.pendingReprocess.clear();
@@ -867,6 +873,7 @@ export class FileWatcher extends EventEmitter {
this.lastProcessedLineCount.delete(filePath);
this.lastProcessedSize.delete(filePath);
this.activeSessionFiles.delete(filePath);
+ this.catchUpStatFailures.delete(filePath);
}
/**
@@ -876,6 +883,7 @@ export class FileWatcher extends EventEmitter {
this.lastProcessedLineCount.clear();
this.lastProcessedSize.clear();
this.activeSessionFiles.clear();
+ this.catchUpStatFailures.clear();
this.catchUpCursor = 0;
this.catchUpInProgress = false;
}
@@ -1119,6 +1127,7 @@ export class FileWatcher extends EventEmitter {
}
const stats = await this.fsProvider.stat(filePath);
+ this.catchUpStatFailures.delete(filePath);
// Skip files not modified recently
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {
@@ -1145,6 +1154,8 @@ export class FileWatcher extends EventEmitter {
// File may have been deleted between iterations
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
this.clearErrorTracking(filePath);
+ } else if (this.isStatTimeoutError(err)) {
+ this.handleCatchUpStatTimeout(filePath);
} else {
logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err);
}
@@ -1156,6 +1167,32 @@ export class FileWatcher extends EventEmitter {
}
}
+ private isStatTimeoutError(err: unknown): boolean {
+ return err instanceof Error && err.message === 'stat timeout';
+ }
+
+ private handleCatchUpStatTimeout(filePath: string): void {
+ const failures = (this.catchUpStatFailures.get(filePath) ?? 0) + 1;
+
+ if (failures >= CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT) {
+ logger.warn(
+ `FileWatcher: Retiring ${filePath} from catch-up after ${failures} stat timeouts`
+ );
+ this.retireCatchUpFile(filePath);
+ return;
+ }
+
+ this.catchUpStatFailures.set(filePath, failures);
+ logger.debug(
+ `FileWatcher: Catch-up stat timeout for ${filePath} (${failures}/${CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT})`
+ );
+ }
+
+ private retireCatchUpFile(filePath: string): void {
+ this.activeSessionFiles.delete(filePath);
+ this.catchUpStatFailures.delete(filePath);
+ }
+
// ===========================================================================
// Debouncing
// ===========================================================================
diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts
index ba3ce0ab..3ebc49e6 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,153 @@ 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 {
+ return parseNotificationHistory(data) !== null;
+}
+
+interface NotificationHistoryParseResult {
+ notifications: StoredNotification[];
+ recovered: boolean;
+}
+
+function parseNotificationHistory(data: string): NotificationHistoryParseResult | null {
+ const parsed = parseNotificationHistoryArray(data);
+ if (parsed) {
+ return { notifications: parsed, recovered: false };
+ }
+
+ const firstArrayEnd = findFirstJsonArrayEnd(data);
+ if (firstArrayEnd === null) {
+ return null;
+ }
+
+ const recovered = parseNotificationHistoryArray(data.slice(0, firstArrayEnd));
+ return recovered ? { notifications: recovered, recovered: true } : null;
+}
+
+function parseNotificationHistoryArray(data: string): StoredNotification[] | null {
+ try {
+ const parsed = JSON.parse(data) as unknown;
+ return Array.isArray(parsed) ? (parsed as StoredNotification[]) : null;
+ } catch {
+ return null;
+ }
+}
+
+function findFirstJsonArrayEnd(data: string): number | null {
+ const start = data.search(/\S/u);
+ if (start === -1 || data[start] !== '[') {
+ return null;
+ }
+
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+
+ for (let index = start; index < data.length; index++) {
+ const char = data[index];
+
+ if (inString) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === '\\') {
+ escaped = true;
+ } else if (char === '"') {
+ inString = false;
+ }
+ continue;
+ }
+
+ if (char === '"') {
+ inString = true;
+ continue;
+ }
+
+ if (char === '[') {
+ depth += 1;
+ continue;
+ }
+
+ if (char === ']') {
+ depth -= 1;
+ if (depth === 0) {
+ return index + 1;
+ }
+ }
+ }
+
+ return null;
+}
+
+async function writeNotificationsFileAtomically(filePath: string, data: string): Promise {
+ const dir = path.dirname(filePath);
+ const tempPath = path.join(
+ dir,
+ `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random()
+ .toString(16)
+ .slice(2)}.tmp`
+ );
+
+ try {
+ await fsp.mkdir(dir, { recursive: true });
+ await fsp.writeFile(tempPath, data, 'utf8');
+ await fsp.rename(tempPath, filePath);
+ } catch (error) {
+ await fsp.rm(tempPath, { force: true }).catch(() => undefined);
+ throw error;
+ }
+}
+
// =============================================================================
// NotificationManager Class
// =============================================================================
@@ -133,6 +292,8 @@ 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;
+ private saveChain: Promise = Promise.resolve();
constructor(configManager?: ConfigManager) {
super();
@@ -183,6 +344,7 @@ export class NotificationManager extends EventEmitter {
return;
}
+ this.notificationsPath = await migrateLegacyNotificationPath();
await this.loadNotifications();
this.pruneNotifications();
this.isInitialized = true;
@@ -208,14 +370,19 @@ export class NotificationManager extends EventEmitter {
*/
private async loadNotifications(): Promise {
try {
- const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
- const parsed = JSON.parse(data) as unknown;
+ const data = await fsp.readFile(this.notificationsPath, 'utf8');
+ const parsed = parseNotificationHistory(data);
- if (Array.isArray(parsed)) {
- this.notifications = parsed as StoredNotification[];
- } else {
+ if (!parsed) {
logger.warn('Invalid notifications file format, starting fresh');
this.notifications = [];
+ return;
+ }
+
+ this.notifications = parsed.notifications;
+ if (parsed.recovered) {
+ logger.info('Recovered notifications from a corrupted history file, compacting storage');
+ this.saveNotifications();
}
} catch (error) {
// ENOENT is expected on first run — no file to load
@@ -233,11 +400,11 @@ export class NotificationManager extends EventEmitter {
*/
private saveNotifications(): void {
const data = JSON.stringify(this.notifications, null, 2);
- const dir = path.dirname(NOTIFICATIONS_PATH);
+ const notificationsPath = this.notificationsPath;
- fsp
- .mkdir(dir, { recursive: true })
- .then(() => fsp.writeFile(NOTIFICATIONS_PATH, data, 'utf8'))
+ this.saveChain = this.saveChain
+ .catch(() => undefined)
+ .then(() => writeNotificationsFileAtomically(notificationsPath, data))
.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/codexAppServer/JsonRpcStdioClient.ts b/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts
index fd7d29a6..5b09a217 100644
--- a/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts
+++ b/src/main/services/infrastructure/codexAppServer/JsonRpcStdioClient.ts
@@ -1,4 +1,3 @@
-import { once } from 'node:events';
import readline from 'node:readline';
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
@@ -49,6 +48,9 @@ function withTimeout(promise: Promise, timeoutMs: number, label: string):
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
+const DEFAULT_STDIN_CLOSE_TIMEOUT_MS = 250;
+const DEFAULT_CLOSE_TIMEOUT_MS = 1_000;
+const DEFAULT_FORCE_CLOSE_TIMEOUT_MS = 1_000;
export class JsonRpcRequestError extends Error {
readonly code: number | null;
@@ -76,6 +78,9 @@ export class JsonRpcStdioClient {
env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number;
totalTimeoutMs?: number;
+ stdinCloseTimeoutMs?: number;
+ closeTimeoutMs?: number;
+ forceCloseTimeoutMs?: number;
label: string;
},
handler: (session: JsonRpcSession) => Promise
@@ -95,8 +100,14 @@ export class JsonRpcStdioClient {
args: string[];
env?: NodeJS.ProcessEnv;
requestTimeoutMs?: number;
+ stdinCloseTimeoutMs?: number;
+ closeTimeoutMs?: number;
+ forceCloseTimeoutMs?: number;
}): Promise {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
+ const stdinCloseTimeoutMs = options.stdinCloseTimeoutMs ?? DEFAULT_STDIN_CLOSE_TIMEOUT_MS;
+ const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
+ const forceCloseTimeoutMs = options.forceCloseTimeoutMs ?? DEFAULT_FORCE_CLOSE_TIMEOUT_MS;
const child = spawnCli(options.binaryPath, options.args, {
env: options.env,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -194,6 +205,58 @@ export class JsonRpcStdioClient {
);
});
+ const waitForChildClose = (timeoutMs: number): Promise => {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return Promise.resolve(true);
+ }
+
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (closedByEvent: boolean): void => {
+ if (settled) {
+ return;
+ }
+
+ settled = true;
+ clearTimeout(timeoutId);
+ child.off('close', onClose);
+ resolve(closedByEvent);
+ };
+ const onClose = (): void => finish(true);
+ const timeoutId = setTimeout(() => finish(false), timeoutMs);
+ timeoutId.unref?.();
+
+ child.once('close', onClose);
+ });
+ };
+
+ const closeStdin = async (): Promise => {
+ if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) {
+ return;
+ }
+
+ await new Promise((resolve) => {
+ let settled = false;
+ const finish = (): void => {
+ if (settled) {
+ return;
+ }
+
+ settled = true;
+ clearTimeout(timeoutId);
+ resolve();
+ };
+ const timeoutId = setTimeout(finish, stdinCloseTimeoutMs);
+ timeoutId.unref?.();
+
+ try {
+ child.stdin!.end(() => finish());
+ } catch {
+ finish();
+ }
+ });
+ };
+
const close = async (): Promise => {
if (closed) {
return;
@@ -204,21 +267,26 @@ export class JsonRpcStdioClient {
notificationListeners.clear();
lineReader.close();
- if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) {
- await new Promise((resolve) => {
- try {
- child.stdin!.end(() => resolve());
- } catch {
- resolve();
- }
- });
+ await closeStdin();
+
+ const gracefulClose = waitForChildClose(closeTimeoutMs);
+ killProcessTree(child, 'SIGTERM');
+ if (await gracefulClose) {
+ return;
}
- killProcessTree(child);
- try {
- await once(child, 'close');
- } catch {
- this.logger.warn('json-rpc close wait failed');
+ this.logger.warn('json-rpc close timed out; force killing process', {
+ pid: child.pid,
+ timeoutMs: closeTimeoutMs,
+ });
+
+ const forcedClose = waitForChildClose(forceCloseTimeoutMs);
+ killProcessTree(child, 'SIGKILL');
+ if (!(await forcedClose)) {
+ this.logger.warn('json-rpc force close timed out', {
+ pid: child.pid,
+ timeoutMs: forceCloseTimeoutMs,
+ });
}
};
diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts
index 5e6e308b..cabac893 100644
--- a/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts
+++ b/src/main/services/infrastructure/codexAppServer/__tests__/JsonRpcStdioClient.test.ts
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
@@ -42,6 +42,35 @@ rl.on('line', (line) => {
return scriptPath;
}
+function createSignalIgnoringJsonRpcServerScript(): string {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-rpc-stdio-client-'));
+ tempDirs.push(tempDir);
+ const scriptPath = path.join(tempDir, 'server.cjs');
+ fs.writeFileSync(
+ scriptPath,
+ `
+const readline = require('node:readline');
+process.on('SIGTERM', () => {});
+setInterval(() => {}, 10_000);
+
+const rl = readline.createInterface({ input: process.stdin });
+rl.on('line', (line) => {
+ const message = JSON.parse(line);
+ if (message.jsonrpc !== '2.0') {
+ return;
+ }
+ process.stdout.write(JSON.stringify({
+ jsonrpc: '2.0',
+ id: message.id,
+ result: { ok: true },
+ }) + '\\n');
+});
+`,
+ 'utf8'
+ );
+ return scriptPath;
+}
+
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
@@ -76,4 +105,30 @@ describe('JsonRpcStdioClient', () => {
}
);
});
+
+ it('force kills the child when session close does not finish gracefully', async () => {
+ const scriptPath = createSignalIgnoringJsonRpcServerScript();
+ const warn = vi.fn();
+ const client = new JsonRpcStdioClient({ warn });
+
+ await client.withSession(
+ {
+ binaryPath: process.execPath,
+ args: [scriptPath],
+ label: 'stubborn json-rpc close',
+ requestTimeoutMs: 1_000,
+ totalTimeoutMs: 2_000,
+ closeTimeoutMs: 25,
+ forceCloseTimeoutMs: 1_000,
+ },
+ async (session) => {
+ await expect(session.request('ping')).resolves.toEqual({ ok: true });
+ }
+ );
+
+ expect(warn).toHaveBeenCalledWith('json-rpc close timed out; force killing process', {
+ pid: expect.any(Number),
+ timeoutMs: 25,
+ });
+ });
});
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..b4c44e0c 100644
--- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
+++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
@@ -1,11 +1,14 @@
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 { mkdtemp, readFile, rm } from 'fs/promises';
+import { tmpdir } from 'os';
+import path from 'path';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
@@ -843,17 +846,26 @@ export class ClaudeMultimodelBridgeService {
params.teamId,
'--member',
params.memberName,
+ '--projection-only',
];
if (typeof params.limit === 'number') {
args.push('--limit', String(params.limit));
}
- const { stdout } = await execCli(binaryPath, args, {
- timeout: PROVIDER_STATUS_TIMEOUT_MS,
- env,
- });
- const parsed = extractJsonObject