merge(main): integrate dev opencode launch gate

This commit is contained in:
777genius 2026-04-21 21:32:06 +03:00
commit fb20fa02bb
291 changed files with 45065 additions and 1196 deletions

View file

@ -98,3 +98,30 @@ jobs:
- name: Test
run: pnpm test:workspace
task-change-ledger-windows:
name: Task change ledger Windows smoke
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Enable Windows long paths
shell: pwsh
run: git config --global core.longpaths true
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Test task change ledger
run: pnpm test:task-change-ledger

View file

@ -25,6 +25,7 @@ Electron 40.x, React 19.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
## Commands
Always use pnpm (not npm/yarn) for this project.
Workspace membership is canonical in `pnpm-workspace.yaml`; do not re-add root `package.json.workspaces`, because npm subproject installs in Codex Cloud must treat nested packages as standalone projects.
Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel.
When running build/typecheck/test commands, pipe through `tail -20` to avoid flooding the context window (e.g. `pnpm typecheck 2>&1 | tail -20`).

View file

@ -0,0 +1,814 @@
const kanbanStore = require('./kanbanStore.js');
const taskStore = require('./taskStore.js');
const runtimeHelpers = require('./runtimeHelpers.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']);
function normalizeName(value) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
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';
}
function buildQueueRoster(paths) {
const resolved = runtimeHelpers.resolveTeamMembers(paths);
const membersByKey = new Map();
for (const member of resolved.members || []) {
const key = normalizeKey(member && member.name);
if (!key) continue;
membersByKey.set(key, member);
}
const leadCandidates = (resolved.members || []).filter(isLeadCandidate);
const uniqueLeadName = leadCandidates.length === 1 ? normalizeName(leadCandidates[0].name) : '';
const inferredLeadName = normalizeName(runtimeHelpers.inferLeadName(paths));
const canonicalLeadName =
uniqueLeadName ||
(membersByKey.get(normalizeKey(inferredLeadName)) &&
normalizeName(membersByKey.get(normalizeKey(inferredLeadName)).name)) ||
'';
const leadAliases = new Set(['team-lead']);
if (canonicalLeadName) {
leadAliases.add(normalizeKey(canonicalLeadName));
leadAliases.add('lead');
}
return {
membersByKey,
removedNames: resolved.removedNames || new Set(),
leadAliases,
leadCandidates: leadCandidates.map((member) => normalizeName(member.name)).filter(Boolean),
canonicalLeadName,
leadHeaderName: uniqueLeadName || '',
};
}
function resolveQueueActor(value, roster) {
const normalized = normalizeName(value);
if (!normalized) return null;
const key = normalizeKey(normalized);
if (roster.removedNames.has(key)) {
return null;
}
if (roster.leadAliases.has(key) && roster.canonicalLeadName) {
return { kind: 'lead', memberName: roster.canonicalLeadName };
}
const member = roster.membersByKey.get(key);
if (!member) return null;
if (roster.canonicalLeadName && normalizeKey(member.name) === normalizeKey(roster.canonicalLeadName)) {
return { kind: 'lead', memberName: roster.canonicalLeadName };
}
return { kind: 'member', memberName: normalizeName(member.name) };
}
function areSameActors(left, right) {
if (!left || !right || left.kind !== right.kind) return false;
if (left.kind === 'lead') return true;
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',
};
}
function resolveLegacyKanbanReviewer(task, roster, options = {}) {
const reviewState = normalizeName(options.reviewState);
const kanbanEntry = options.kanbanEntry;
if (reviewState !== 'review' || !kanbanEntry || kanbanEntry.column !== 'review') {
return null;
}
const legacyReviewer = normalizeName(kanbanEntry.reviewer);
if (!legacyReviewer) {
return null;
}
const actor = resolveQueueActor(legacyReviewer, roster);
if (actor) {
return { actor, source: 'legacy_kanban_reviewer', invalidValue: null };
}
return {
actor: null,
source: 'legacy_kanban_reviewer_invalid',
invalidValue: legacyReviewer,
};
}
function resolveCurrentCycleReviewer(task, roster, options = {}) {
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_started') {
const actor = resolveQueueActor(event.actor, roster);
if (actor) {
return { actor, source: 'history_review_started_actor', invalidValue: null };
}
return {
actor: null,
source: 'history_review_started_invalid',
invalidValue: normalizeName(event.actor) || null,
};
}
if (event.type === 'review_requested') {
const reviewer = resolveQueueActor(event.reviewer, roster);
if (reviewer) {
return { actor: reviewer, source: 'history_review_requested_reviewer', invalidValue: null };
}
return {
actor: null,
source: 'history_review_requested_invalid',
invalidValue: normalizeName(event.reviewer) || null,
};
}
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
break;
}
if (event.type === 'status_changed' && event.to === 'in_progress') {
break;
}
if (event.type === 'task_created') {
break;
}
}
const legacyFallback = resolveLegacyKanbanReviewer(task, roster, options);
if (legacyFallback) {
return legacyFallback;
}
return { actor: null, source: 'none', invalidValue: null };
}
function compareTasksByFreshness(left, right) {
const leftTs = Date.parse(normalizeName(left.updatedAt) || normalizeName(left.createdAt) || '') || 0;
const rightTs = Date.parse(normalizeName(right.updatedAt) || normalizeName(right.createdAt) || '') || 0;
if (leftTs !== rightTs) return rightTs - leftTs;
const byDisplay = String(left.displayId || left.id).localeCompare(String(right.displayId || right.id), undefined, {
numeric: true,
sensitivity: 'base',
});
if (byDisplay !== 0) return byDisplay;
return String(left.id).localeCompare(String(right.id), undefined, {
numeric: true,
sensitivity: 'base',
});
}
function buildBoardState(paths, teamName) {
const taskRows = taskStore.listTaskRows(paths);
const kanbanState = kanbanStore.readKanbanState(paths, teamName);
const roster = buildQueueRoster(paths);
const tasksById = new Map(taskRows.tasks.map((task) => [task.id, task]));
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 } : {}),
})),
};
}
function buildWatchers(ownerActor, reviewerActor, actionOwner) {
const watchers = new Set();
if (ownerActor && ownerActor.kind === 'member') {
watchers.add(ownerActor.memberName);
}
if (reviewerActor && reviewerActor.kind === 'member') {
watchers.add(reviewerActor.memberName);
}
if (actionOwner && actionOwner.kind === 'member') {
watchers.delete(actionOwner.memberName);
}
return [...watchers];
}
function getLastMeaningfulEventAt(task) {
const timestamps = [];
if (normalizeName(task.updatedAt)) timestamps.push(task.updatedAt);
if (normalizeName(task.createdAt)) timestamps.push(task.createdAt);
const comments = Array.isArray(task.comments) ? task.comments : [];
for (const comment of comments) {
if (normalizeName(comment && comment.createdAt)) {
timestamps.push(comment.createdAt);
}
}
timestamps.sort((left, right) => {
const leftTs = Date.parse(left) || 0;
const rightTs = Date.parse(right) || 0;
return rightTs - leftTs;
});
return timestamps[0] || undefined;
}
function buildAgendaItem(task, boardState) {
const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined;
const reviewStateResult = resolveEffectiveReviewState(task, kanbanEntry);
const reviewerResult = resolveCurrentCycleReviewer(task, boardState.roster, {
reviewState: reviewStateResult.state,
kanbanEntry,
});
const ownerActor = resolveQueueActor(task.owner, boardState.roster);
const hasOwnerField = normalizeName(task.owner).length > 0;
const hasMissingOwner = !hasOwnerField;
const hasInvalidOwner = hasOwnerField && !ownerActor;
const reviewActor = reviewerResult.actor;
const reviewActorIsInvalid = reviewStateResult.state === 'review' && !reviewActor;
const hasSelfReview = Boolean(ownerActor && reviewActor && areSameActors(ownerActor, reviewActor));
const brokenDependencyIds = [];
const waitingDependencyIds = [];
const blockedByIds = Array.isArray(task.blockedBy) ? task.blockedBy.map(String) : [];
for (const dependencyId of blockedByIds) {
const dependency = boardState.tasksById.get(dependencyId);
if (!dependency || dependency.status === 'deleted') {
brokenDependencyIds.push(dependencyId);
continue;
}
if (dependency.status !== 'completed') {
waitingDependencyIds.push(dependencyId);
}
}
let actionOwner = { kind: 'none' };
let nextAction = 'none';
let queueCategory = 'done';
let reasonCode = 'completed_no_followup';
const derivedFrom = [reviewStateResult.source, reviewerResult.source].filter(
(value) => value && value !== 'none'
);
if (task.status === 'deleted') {
reasonCode = 'terminal_deleted';
} else if (reviewStateResult.state === 'approved') {
reasonCode = 'terminal_approved';
} else if (task.needsClarification === 'user') {
actionOwner = { kind: 'user' };
nextAction = 'clarify_with_user';
queueCategory = 'waiting';
reasonCode = 'waiting_user_clarification';
derivedFrom.push('clarification_flag');
} else if (task.needsClarification === 'lead') {
actionOwner = { kind: 'lead' };
nextAction = 'clarify_with_lead';
queueCategory = 'oversight';
reasonCode = 'waiting_lead_clarification';
derivedFrom.push('clarification_flag');
} else if (hasMissingOwner) {
actionOwner = { kind: 'lead' };
nextAction = 'assign_owner';
queueCategory = 'oversight';
reasonCode = 'owner_missing';
derivedFrom.push('owner_status');
} else if (hasInvalidOwner) {
actionOwner = { kind: 'lead' };
nextAction = 'assign_owner';
queueCategory = 'oversight';
reasonCode = 'owner_invalid';
derivedFrom.push('owner_status');
} else if (reviewStateResult.state === 'review') {
if (hasSelfReview) {
actionOwner = { kind: 'lead' };
nextAction = 'assign_reviewer';
queueCategory = 'oversight';
reasonCode = 'self_review_invalid';
derivedFrom.push('self_review_invalid');
} else if (reviewActorIsInvalid) {
actionOwner = { kind: 'lead' };
nextAction = 'assign_reviewer';
queueCategory = 'oversight';
reasonCode = 'review_reviewer_missing';
derivedFrom.push('history_reviewer_invalid');
} else if (reviewActor) {
actionOwner = reviewActor.kind === 'lead'
? { kind: 'lead' }
: { kind: 'member', memberName: reviewActor.memberName };
nextAction = 'review';
queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable';
reasonCode =
reviewerResult.source === 'history_review_started_actor'
? 'review_in_progress'
: 'review_requested_waiting_pickup';
}
} else if (brokenDependencyIds.length > 0) {
actionOwner = { kind: 'lead' };
nextAction = 'repair_dependencies';
queueCategory = 'oversight';
reasonCode = 'dependency_broken';
derivedFrom.push('dependency_graph');
} else if (waitingDependencyIds.length > 0) {
actionOwner = { kind: 'none' };
nextAction = 'wait_dependency';
queueCategory = 'waiting';
reasonCode = 'dependency_waiting';
derivedFrom.push('dependency_graph');
} else if (reviewStateResult.state === 'needsFix') {
actionOwner =
ownerActor.kind === 'lead'
? { kind: 'lead' }
: { kind: 'member', memberName: ownerActor.memberName };
nextAction = 'apply_changes';
queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable';
reasonCode = 'needs_fix';
} else if (task.status === 'in_progress') {
actionOwner =
ownerActor.kind === 'lead'
? { kind: 'lead' }
: { kind: 'member', memberName: ownerActor.memberName };
nextAction = 'execute';
queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable';
reasonCode = 'owner_executing';
} else if (task.status === 'pending') {
actionOwner =
ownerActor.kind === 'lead'
? { kind: 'lead' }
: { kind: 'member', memberName: ownerActor.memberName };
nextAction = 'execute';
queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable';
reasonCode = 'owner_ready';
}
const watchers = buildWatchers(ownerActor, reviewActor, actionOwner);
return {
taskId: task.id,
displayId: task.displayId,
subject: task.subject,
status: task.status,
reviewState: reviewStateResult.state,
actionOwner,
nextAction,
queueCategory,
reasonCode,
...(normalizeName(task.owner) ? { owner: task.owner } : {}),
reviewer:
reviewActor && reviewActor.kind === 'member'
? reviewActor.memberName
: reviewActor && reviewActor.kind === 'lead'
? reviewActor.memberName
: null,
...(blockedByIds.length > 0 ? { blockedBy: blockedByIds } : {}),
...(watchers.length > 0 ? { watchers } : {}),
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
...(getLastMeaningfulEventAt(task) ? { lastMeaningfulEventAt: getLastMeaningfulEventAt(task) } : {}),
derivedFrom,
_fullTask: task,
};
}
function compareAgendaItems(left, right) {
const leftTs = Date.parse(normalizeName(left.lastMeaningfulEventAt)) || 0;
const rightTs = Date.parse(normalizeName(right.lastMeaningfulEventAt)) || 0;
if (leftTs !== rightTs) return rightTs - leftTs;
const byDisplay = String(left.displayId || left.taskId).localeCompare(String(right.displayId || right.taskId), undefined, {
numeric: true,
sensitivity: 'base',
});
if (byDisplay !== 0) return byDisplay;
return String(left.taskId).localeCompare(String(right.taskId), undefined, {
numeric: true,
sensitivity: 'base',
});
}
function buildAgendaSnapshot(paths, teamName, actor) {
return withTeamBoardLock(paths, () => {
const boardState = buildBoardState(paths, teamName);
const items = boardState.tasks.map((task) => buildAgendaItem(task, boardState));
const actionable = [];
const awareness = [];
for (const item of items) {
if (actor.kind === 'member') {
const memberKey = normalizeKey(actor.memberName);
const isActionable =
item.actionOwner.kind === 'member' &&
normalizeKey(item.actionOwner.memberName) === memberKey;
const isRelevant =
isActionable ||
normalizeKey(item.owner) === memberKey ||
normalizeKey(item.reviewer) === memberKey ||
(Array.isArray(item.watchers) && item.watchers.some((entry) => normalizeKey(entry) === memberKey));
if (isActionable) {
actionable.push(item);
} else if (isRelevant) {
awareness.push(item);
}
continue;
}
if (item.actionOwner.kind === 'lead') {
actionable.push(item);
} else if (
item.actionOwner.kind === 'user' ||
item.reasonCode === 'dependency_waiting' ||
item.reasonCode === 'review_in_progress' ||
item.reasonCode === 'review_requested_waiting_pickup'
) {
awareness.push(item);
}
}
actionable.sort(compareAgendaItems);
awareness.sort(compareAgendaItems);
return {
actor,
actionable,
awareness,
anomalies: boardState.anomalies,
counters: {
actionable: actionable.length,
awareness: awareness.length,
blocked: items.filter(
(item) =>
item.reasonCode === 'dependency_waiting' || item.reasonCode === 'dependency_broken'
).length,
waitingOnUser: items.filter((item) => item.reasonCode === 'waiting_user_clarification').length,
waitingOnLead: items.filter((item) => item.reasonCode === 'waiting_lead_clarification').length,
reviewNeeded: items.filter(
(item) =>
item.reasonCode === 'review_reviewer_missing' ||
item.reasonCode === 'review_requested_waiting_pickup' ||
item.reasonCode === 'review_in_progress' ||
item.reasonCode === 'self_review_invalid'
).length,
anomalies: boardState.anomalies.length,
},
};
});
}
function buildInventoryRow(task, reviewState) {
return {
id: task.id,
displayId: task.displayId,
subject: task.subject,
status: task.status,
...(normalizeName(task.owner) ? { owner: task.owner } : {}),
reviewState,
...(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 } : {}),
...(Array.isArray(task.related) && task.related.length > 0 ? { related: task.related } : {}),
commentCount: Array.isArray(task.comments) ? task.comments.length : 0,
...(normalizeName(task.createdAt) ? { createdAt: task.createdAt } : {}),
...(normalizeName(task.updatedAt) ? { updatedAt: task.updatedAt } : {}),
};
}
function matchesInventoryFilters(row, filters) {
if (normalizeName(filters.owner) && normalizeKey(row.owner) !== normalizeKey(filters.owner)) {
return false;
}
if (normalizeName(filters.status) && row.status !== filters.status) {
return false;
}
if (normalizeName(filters.reviewState) && row.reviewState !== filters.reviewState) {
return false;
}
if (normalizeName(filters.kanbanColumn)) {
const kanbanColumn = filters.kanbanColumn;
if (!INVENTORY_KANBAN_COLUMNS.has(kanbanColumn) || row.reviewState !== kanbanColumn) {
return false;
}
}
if (normalizeName(filters.relatedTo)) {
const related = Array.isArray(row.related) ? row.related : [];
if (!related.includes(filters.relatedTo)) {
return false;
}
}
if (normalizeName(filters.blockedBy)) {
const blockedBy = Array.isArray(row.blockedBy) ? row.blockedBy : [];
if (!blockedBy.includes(filters.blockedBy)) {
return false;
}
}
return true;
}
function listTaskInventory(paths, teamName, filters = {}) {
return withTeamBoardLock(paths, () => {
const boardState = buildBoardState(paths, teamName);
const resolvedRelatedTo = normalizeName(filters.relatedTo)
? taskStore.resolveTaskRef(paths, filters.relatedTo)
: '';
const resolvedBlockedBy = normalizeName(filters.blockedBy)
? taskStore.resolveTaskRef(paths, filters.blockedBy)
: '';
const limit =
typeof filters.limit === 'number' && Number.isFinite(filters.limit)
? 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 } : {}),
})
);
return limit == null ? rows : rows.slice(0, limit);
});
}
function formatActionOwner(actionOwner) {
if (actionOwner.kind === 'member') return `@${actionOwner.memberName}`;
if (actionOwner.kind === 'lead') return 'lead';
if (actionOwner.kind === 'user') return 'user';
return 'none';
}
function formatAgendaLine(item) {
const reviewSuffix = item.reviewState !== 'none' ? `, review=${item.reviewState}` : '';
const meta = [
`next=${item.nextAction}`,
`owner=${normalizeName(item.owner) || 'none'}`,
`actionOwner=${formatActionOwner(item.actionOwner)}`,
`reason=${item.reasonCode}`,
];
if (normalizeName(item.reviewer)) {
meta.push(`reviewer=${item.reviewer}`);
}
if (item.needsClarification) {
meta.push(`clarification=${item.needsClarification}`);
}
return `- ${formatTaskLabel(item)} [status=${item.status}${reviewSuffix}] ${item.subject} (${meta.join(', ')})`;
}
function appendExpandedTaskContext(lines, item) {
const task = item._fullTask;
if (!task || typeof task !== 'object') return;
if (normalizeName(task.description)) {
lines.push(` Description: ${task.description}`);
}
const comments = Array.isArray(task.comments) ? task.comments : [];
if (comments.length === 0) return;
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)';
lines.push(` - ${author}: ${text}`);
}
}
function formatAnomalyLine(anomaly) {
const ref = normalizeName(anomaly.taskId) ? ` (${anomaly.taskId})` : '';
return `- ${anomaly.code}${ref}: ${anomaly.detail}`;
}
function formatTaskBriefing(paths, teamName, memberName) {
const snapshot = buildAgendaSnapshot(paths, teamName, {
kind: 'member',
memberName: normalizeName(memberName),
});
const lines = [
`Task briefing for ${memberName}:`,
`Primary queue for ${memberName}. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.`,
`Use task_list only to search/browse inventory rows, not as your working queue.`,
];
if (snapshot.anomalies.length > 0) {
lines.push('', 'Board warnings:');
for (const anomaly of snapshot.anomalies) {
lines.push(formatAnomalyLine(anomaly));
}
}
if (snapshot.actionable.length === 0 && snapshot.awareness.length === 0) {
lines.push('', `No actionable or awareness tasks for ${memberName}.`);
return lines.join('\n');
}
if (snapshot.actionable.length > 0) {
lines.push('', 'Actionable:');
for (const item of snapshot.actionable) {
lines.push(formatAgendaLine(item));
if (item.status === 'in_progress' || item.reasonCode === 'needs_fix') {
appendExpandedTaskContext(lines, item);
}
}
}
if (snapshot.awareness.length > 0) {
lines.push('', 'Awareness:');
for (const item of snapshot.awareness) {
lines.push(formatAgendaLine(item));
}
}
lines.push(
'',
`Counters: actionable=${snapshot.counters.actionable}, awareness=${snapshot.counters.awareness}, blocked=${snapshot.counters.blocked}, waitingOnUser=${snapshot.counters.waitingOnUser}, waitingOnLead=${snapshot.counters.waitingOnLead}, reviewNeeded=${snapshot.counters.reviewNeeded}, anomalies=${snapshot.counters.anomalies}`
);
return lines.join('\n');
}
function bucketLeadItems(items) {
const buckets = {
assign_owner: [],
assign_reviewer: [],
clarify_with_lead: [],
repair_dependencies: [],
lead_owned: [],
};
for (const item of items) {
if (item.nextAction === 'assign_owner') {
buckets.assign_owner.push(item);
continue;
}
if (item.nextAction === 'assign_reviewer') {
buckets.assign_reviewer.push(item);
continue;
}
if (item.nextAction === 'clarify_with_lead') {
buckets.clarify_with_lead.push(item);
continue;
}
if (item.nextAction === 'repair_dependencies') {
buckets.repair_dependencies.push(item);
continue;
}
buckets.lead_owned.push(item);
}
return buckets;
}
function formatLeadBriefing(paths, teamName) {
const roster = buildQueueRoster(paths);
const leadHeaderName = roster.leadHeaderName ? ` for ${roster.leadHeaderName}` : '';
const snapshot = buildAgendaSnapshot(paths, teamName, { kind: 'lead' });
const buckets = bucketLeadItems(snapshot.actionable);
const lines = [
`Lead queue${leadHeaderName} on team "${teamName}":`,
`Primary lead queue. Sections below already represent lead-owned actions or watch-only context.`,
`Use task_list only for search, filtering, and drill-down inventory lookups.`,
];
if (snapshot.anomalies.length > 0) {
lines.push('', 'Board anomalies:');
for (const anomaly of snapshot.anomalies) {
lines.push(formatAnomalyLine(anomaly));
}
}
const sections = [
['Needs owner assignment:', buckets.assign_owner],
['Needs reviewer assignment:', buckets.assign_reviewer],
['Needs clarification from lead:', buckets.clarify_with_lead],
['Dependency repair:', buckets.repair_dependencies],
['Lead-owned follow-up:', buckets.lead_owned],
[
'Waiting on user:',
snapshot.awareness.filter((item) => item.reasonCode === 'waiting_user_clarification'),
],
[
'Watching:',
snapshot.awareness.filter((item) => item.reasonCode !== 'waiting_user_clarification'),
],
];
let renderedAnySection = false;
for (const [title, items] of sections) {
if (!items || items.length === 0) continue;
renderedAnySection = true;
lines.push('', title);
for (const item of items) {
lines.push(formatAgendaLine(item));
}
}
if (!renderedAnySection && snapshot.anomalies.length === 0) {
lines.push('', 'No lead action items.');
}
lines.push(
'',
`Counters: actionable=${snapshot.counters.actionable}, awareness=${snapshot.counters.awareness}, blocked=${snapshot.counters.blocked}, waitingOnUser=${snapshot.counters.waitingOnUser}, waitingOnLead=${snapshot.counters.waitingOnLead}, reviewNeeded=${snapshot.counters.reviewNeeded}, anomalies=${snapshot.counters.anomalies}`
);
return lines.join('\n');
}
module.exports = {
buildAgendaSnapshot,
formatLeadBriefing,
formatTaskBriefing,
listTaskInventory,
resolveCurrentCycleReviewer,
resolveEffectiveReviewState,
};

View file

@ -0,0 +1,42 @@
const path = require('path');
const { withFileLockSync } = require('./fileLock.js');
const reentrantLockDepthByScope = new Map();
function getTeamBoardLockScope(paths) {
return path.join(paths.teamDir, 'board-state');
}
function withTeamBoardLock(paths, fn) {
const scope = getTeamBoardLockScope(paths);
const currentDepth = reentrantLockDepthByScope.get(scope) || 0;
if (currentDepth > 0) {
reentrantLockDepthByScope.set(scope, currentDepth + 1);
try {
return fn();
} finally {
const nextDepth = (reentrantLockDepthByScope.get(scope) || 1) - 1;
if (nextDepth <= 0) {
reentrantLockDepthByScope.delete(scope);
} else {
reentrantLockDepthByScope.set(scope, nextDepth);
}
}
}
return withFileLockSync(scope, () => {
reentrantLockDepthByScope.set(scope, 1);
try {
return fn();
} finally {
reentrantLockDepthByScope.delete(scope);
}
});
}
module.exports = {
getTeamBoardLockScope,
withTeamBoardLock,
};

View file

@ -1,20 +1,25 @@
const kanbanStore = require('./kanbanStore.js');
const tasks = require('./tasks.js');
const { withTeamBoardLock } = require('./boardLock.js');
function getKanbanState(context) {
return kanbanStore.readKanbanState(context.paths, context.teamName);
}
function setKanbanColumn(context, taskId, column) {
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
return getKanbanState(context);
return withTeamBoardLock(context.paths, () => {
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
return getKanbanState(context);
});
}
function clearKanban(context, taskId, options) {
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options);
return getKanbanState(context);
return withTeamBoardLock(context.paths, () => {
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options);
return getKanbanState(context);
});
}
function listReviewers(context) {
@ -22,29 +27,35 @@ function listReviewers(context) {
}
function addReviewer(context, reviewer) {
const state = getKanbanState(context);
const next = new Set(state.reviewers);
next.add(String(reviewer));
kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state,
reviewers: [...next],
return withTeamBoardLock(context.paths, () => {
const state = getKanbanState(context);
const next = new Set(state.reviewers);
next.add(String(reviewer));
kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state,
reviewers: [...next],
});
return listReviewers(context);
});
return listReviewers(context);
}
function removeReviewer(context, reviewer) {
const state = getKanbanState(context);
const next = state.reviewers.filter((entry) => entry !== reviewer);
kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state,
reviewers: next,
return withTeamBoardLock(context.paths, () => {
const state = getKanbanState(context);
const next = state.reviewers.filter((entry) => entry !== reviewer);
kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state,
reviewers: next,
});
return listReviewers(context);
});
return listReviewers(context);
}
function updateColumnOrder(context, columnId, orderedTaskIds) {
const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId));
return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds);
return withTeamBoardLock(context.paths, () => {
const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId));
return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds);
});
}
module.exports = {

View file

@ -2,8 +2,16 @@ const kanban = require('./kanban.js');
const messages = require('./messages.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const tasks = require('./tasks.js');
const { withTeamBoardLock } = require('./boardLock.js');
const { wrapAgentBlock } = require('./agentBlocks.js');
function warnNonCritical(message, error) {
if (typeof console === 'undefined' || typeof console.warn !== 'function') {
return;
}
console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`);
}
function getReviewer(context, flags) {
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
return flags.reviewer.trim();
@ -32,76 +40,115 @@ function getCurrentReviewState(task) {
return 'none';
}
function startReview(context, taskId, flags = {}) {
const task = tasks.getTask(context, taskId);
if (task.status === 'deleted') {
throw new Error(`Task #${task.displayId || task.id} is deleted`);
}
const from =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer';
const prevReviewState = getCurrentReviewState(task);
// Idempotent: already in review → return ok without duplicate history event
if (prevReviewState === 'review') {
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
}
try {
kanban.setKanbanColumn(context, task.id, 'review');
tasks.updateTask(context, task.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_started',
from: prevReviewState,
to: 'review',
actor: from,
});
t.reviewState = 'review';
return t;
});
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
} catch (error) {
try {
kanban.clearKanban(context, task.id);
} catch {
// Best-effort rollback
function getLatestReviewLifecycleEvent(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;
}
if (e.type === 'status_changed' && e.to === 'in_progress') {
return e;
}
if (e.type === 'task_created') {
return e;
}
throw error;
}
return null;
}
function startReview(context, taskId, flags = {}) {
return withTeamBoardLock(context.paths, () => {
const task = tasks.getTask(context, taskId);
if (task.status === 'deleted') {
throw new Error(`Task #${task.displayId || task.id} is deleted`);
}
const from =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer';
const latestReviewEvent = getLatestReviewLifecycleEvent(task);
const prevReviewState = getCurrentReviewState(task);
if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
}
try {
kanban.setKanbanColumn(context, task.id, 'review');
tasks.updateTask(context, task.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_started',
from: prevReviewState,
to: 'review',
actor: from,
});
t.reviewState = 'review';
return t;
});
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
} catch (error) {
try {
kanban.clearKanban(context, task.id);
} catch (rollbackError) {
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError);
}
throw error;
}
});
}
function requestReview(context, taskId, flags = {}) {
const task = tasks.getTask(context, taskId);
if (task.status !== 'completed') {
throw new Error(`Task #${task.displayId || task.id} must be completed before review`);
}
const from =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
const reviewer = getReviewer(context, flags);
const leadSessionId = resolveLeadSessionId(context, flags);
const prevReviewState = getCurrentReviewState(task);
try {
kanban.setKanbanColumn(context, task.id, 'review');
// Append review_requested event
tasks.updateTask(context, task.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_requested',
from: prevReviewState,
to: 'review',
...(reviewer ? { reviewer } : {}),
actor: from,
});
t.reviewState = 'review';
return t;
});
if (!reviewer) {
return tasks.getTask(context, task.id);
const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => {
const currentTask = tasks.getTask(context, taskId);
if (currentTask.status !== 'completed') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`);
}
const nextFrom =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
const nextReviewer = getReviewer(context, flags);
const prevReviewState = getCurrentReviewState(currentTask);
try {
kanban.setKanbanColumn(context, currentTask.id, 'review');
tasks.updateTask(context, currentTask.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_requested',
from: prevReviewState,
to: 'review',
...(nextReviewer ? { reviewer: nextReviewer } : {}),
actor: nextFrom,
});
t.reviewState = 'review';
return t;
});
} catch (error) {
try {
kanban.clearKanban(context, currentTask.id);
} catch (rollbackError) {
warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError);
}
throw error;
}
return {
task: tasks.getTask(context, currentTask.id),
reviewer: nextReviewer,
from: nextFrom,
leadSessionId: resolveLeadSessionId(context, flags),
};
});
if (!reviewer) {
return task;
}
try {
messages.sendMessage(context, {
to: reviewer,
from,
@ -119,122 +166,158 @@ function requestReview(context, taskId, flags = {}) {
source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}),
});
return tasks.getTask(context, task.id);
} catch (error) {
try {
kanban.clearKanban(context, task.id);
} catch {
// Best-effort rollback: keep the original error.
}
throw error;
warnNonCritical(`[review] reviewer notification failed for task ${task.id}`, error);
}
return task;
}
function approveReview(context, taskId, flags = {}) {
const task = tasks.getTask(context, taskId);
const from =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
const suppressTaskComment = flags.suppressTaskComment === true;
const leadSessionId = resolveLeadSessionId(context, flags);
const prevReviewState = getCurrentReviewState(task);
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 nextNote =
typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
const suppressTaskComment = flags.suppressTaskComment === true;
const prevReviewState = getCurrentReviewState(currentTask);
// Idempotent: already approved → skip duplicate comment/event, only add note if new
if (prevReviewState === 'approved') {
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'approved', alreadyApproved: true };
}
if (prevReviewState === 'approved') {
return {
alreadyApproved: true,
payload: {
ok: true,
taskId: currentTask.id,
displayId: currentTask.displayId,
column: 'approved',
alreadyApproved: true,
},
};
}
kanban.setKanbanColumn(context, task.id, 'approved');
// Append review_approved event
tasks.updateTask(context, task.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_approved',
from: prevReviewState,
to: 'approved',
...(note ? { note } : {}),
actor: from,
kanban.setKanbanColumn(context, currentTask.id, 'approved');
tasks.updateTask(context, currentTask.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_approved',
from: prevReviewState,
to: 'approved',
...(nextNote ? { note: nextNote } : {}),
actor: nextFrom,
});
t.reviewState = 'approved';
return t;
});
t.reviewState = 'approved';
return t;
if (!suppressTaskComment) {
tasks.addTaskComment(context, currentTask.id, {
text: nextNote,
from: nextFrom,
type: 'review_approved',
notifyOwner: false,
});
}
return {
alreadyApproved: false,
payload: tasks.getTask(context, currentTask.id),
from: nextFrom,
note: nextNote,
leadSessionId: resolveLeadSessionId(context, flags),
shouldNotifyOwner:
(flags.notify === true || flags['notify-owner'] === true) && Boolean(currentTask.owner),
};
});
if (!suppressTaskComment) {
tasks.addTaskComment(context, task.id, {
text: note,
from,
type: 'review_approved',
notifyOwner: false,
});
if (result.alreadyApproved) {
return result.payload;
}
if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
const { payload: task, from, note, leadSessionId, shouldNotifyOwner } = result;
if (shouldNotifyOwner && task.owner) {
try {
messages.sendMessage(context, {
to: task.owner,
from,
text:
note && note !== 'Approved'
? `@${from} **approved** task #${task.displayId || task.id}\n\n${note}`
: `@${from} **approved** task #${task.displayId || task.id}`,
summary: `Approved #${task.displayId || task.id}`,
source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}),
});
} catch (error) {
warnNonCritical(`[review] owner approval notification failed for task ${task.id}`, error);
}
}
return task;
}
function requestChanges(context, taskId, flags = {}) {
const { task, from, comment, leadSessionId } = withTeamBoardLock(context.paths, () => {
const currentTask = tasks.getTask(context, taskId);
if (!currentTask.owner) {
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 nextComment =
typeof flags.comment === 'string' && flags.comment.trim()
? flags.comment.trim()
: 'Reviewer requested changes.';
const prevReviewState = getCurrentReviewState(currentTask);
tasks.updateTask(context, currentTask.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_changes_requested',
from: prevReviewState,
to: 'needsFix',
...(nextComment ? { note: nextComment } : {}),
actor: nextFrom,
});
t.reviewState = 'needsFix';
return t;
});
kanban.clearKanban(context, currentTask.id, { nextReviewState: 'needsFix' });
tasks.setTaskStatus(context, currentTask.id, 'pending', nextFrom);
tasks.addTaskComment(context, currentTask.id, {
text: nextComment,
from: nextFrom,
type: 'review_request',
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
notifyOwner: false,
});
return {
task: tasks.getTask(context, currentTask.id),
from: nextFrom,
comment: nextComment,
leadSessionId: resolveLeadSessionId(context, flags),
};
});
try {
messages.sendMessage(context, {
to: task.owner,
from,
text:
note && note !== 'Approved'
? `@${from} **approved** task #${task.displayId || task.id}\n\n${note}`
: `@${from} **approved** task #${task.displayId || task.id}`,
summary: `Approved #${task.displayId || task.id}`,
`@${from} **requested changes** for task #${task.displayId || task.id}\n\n${comment}\n\n` +
'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.',
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
summary: `Fix request for #${task.displayId || task.id}`,
source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}),
});
} catch (error) {
warnNonCritical(`[review] owner fix-request notification failed for task ${task.id}`, error);
}
return tasks.getTask(context, task.id);
}
function requestChanges(context, taskId, flags = {}) {
const task = tasks.getTask(context, taskId);
if (!task.owner) {
throw new Error(`No owner found for task ${String(taskId)}`);
}
const from =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
const comment =
typeof flags.comment === 'string' && flags.comment.trim()
? flags.comment.trim()
: 'Reviewer requested changes.';
const leadSessionId = resolveLeadSessionId(context, flags);
const prevReviewState = getCurrentReviewState(task);
// Append review_changes_requested event before status change
tasks.updateTask(context, task.id, (t) => {
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_changes_requested',
from: prevReviewState,
to: 'needsFix',
...(comment ? { note: comment } : {}),
actor: from,
});
t.reviewState = 'needsFix';
return t;
});
kanban.clearKanban(context, task.id, { nextReviewState: 'needsFix' });
tasks.setTaskStatus(context, task.id, 'pending', from);
tasks.addTaskComment(context, task.id, {
text: comment,
from,
type: 'review_request',
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
notifyOwner: false,
});
messages.sendMessage(context, {
to: task.owner,
from,
text:
`@${from} **requested changes** for task #${task.displayId || task.id}\n\n${comment}\n\n` +
'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.',
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
summary: `Fix request for #${task.displayId || task.id}`,
source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}),
});
return tasks.getTask(context, task.id);
return task;
}
module.exports = {

View file

@ -218,6 +218,29 @@ function shouldWaitForStop(flags = {}) {
return true;
}
function compactRuntimeToolBody(context, flags = {}, fields) {
const body = { teamName: context.teamName };
for (const field of fields) {
if (flags[field] !== undefined) {
body[field] = flags[field];
}
}
return body;
}
async function postRuntimeTool(context, flags = {}, toolPath, body) {
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(
baseUrls,
`/api/teams/${encodeURIComponent(context.teamName)}/opencode/runtime/${toolPath}`,
{
method: 'POST',
body,
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000),
}
);
}
async function waitForProvisioningState(baseUrls, teamName, runId, timeoutMs) {
const startedAt = Date.now();
let lastProgress = null;
@ -331,8 +354,82 @@ async function getRuntimeState(context, flags = {}) {
return requestJsonWithFallback(baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`);
}
async function runtimeBootstrapCheckin(context, flags = {}) {
return postRuntimeTool(
context,
flags,
'bootstrap-checkin',
compactRuntimeToolBody(context, flags, [
'runId',
'memberName',
'runtimeSessionId',
'observedAt',
'diagnostics',
'metadata',
])
);
}
async function runtimeDeliverMessage(context, flags = {}) {
return postRuntimeTool(
context,
flags,
'deliver-message',
compactRuntimeToolBody(context, flags, [
'idempotencyKey',
'runId',
'fromMemberName',
'runtimeSessionId',
'to',
'text',
'createdAt',
'summary',
'taskRefs',
])
);
}
async function runtimeTaskEvent(context, flags = {}) {
return postRuntimeTool(
context,
flags,
'task-event',
compactRuntimeToolBody(context, flags, [
'idempotencyKey',
'runId',
'memberName',
'runtimeSessionId',
'taskId',
'event',
'createdAt',
'summary',
'metadata',
])
);
}
async function runtimeHeartbeat(context, flags = {}) {
return postRuntimeTool(
context,
flags,
'heartbeat',
compactRuntimeToolBody(context, flags, [
'runId',
'memberName',
'runtimeSessionId',
'observedAt',
'status',
'metadata',
])
);
}
module.exports = {
launchTeam,
stopTeam,
getRuntimeState,
runtimeBootstrapCheckin,
runtimeDeliverMessage,
runtimeTaskEvent,
runtimeHeartbeat,
};

View file

@ -72,10 +72,12 @@ function normalizeTaskReviewState(value) {
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
}
function listRawTasks(paths) {
function listTaskRows(paths, options = {}) {
ensureDir(paths.tasksDir);
const entries = fs.readdirSync(paths.tasksDir);
const out = [];
const includeDeleted = options.includeDeleted === true;
const tasks = [];
const anomalies = [];
for (const fileName of entries) {
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
@ -84,13 +86,25 @@ function listRawTasks(paths) {
if (!rawTask) continue;
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
try {
out.push(normalizeTask(rawTask, filePath));
} catch {
// Skip unreadable task rows.
const task = normalizeTask(rawTask, filePath);
if (includeDeleted || task.status !== 'deleted') {
tasks.push(task);
}
} catch (error) {
const taskId =
typeof rawTask?.id === 'string' || typeof rawTask?.id === 'number'
? String(rawTask.id)
: path.basename(fileName, '.json');
anomalies.push({
code: 'unreadable_task',
taskId,
filePath,
detail: error instanceof Error ? error.message : 'Unreadable task row',
});
}
}
out.sort((a, b) => {
tasks.sort((a, b) => {
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
numeric: true,
sensitivity: 'base',
@ -102,12 +116,15 @@ function listRawTasks(paths) {
});
});
return out;
return { tasks, anomalies };
}
function listRawTasks(paths) {
return listTaskRows(paths, { includeDeleted: true }).tasks;
}
function listTasks(paths, options = {}) {
const includeDeleted = options.includeDeleted === true;
return listRawTasks(paths).filter((task) => includeDeleted || task.status !== 'deleted');
return listTaskRows(paths, options).tasks;
}
function resolveTaskRef(paths, taskRef, options = {}) {
@ -479,30 +496,18 @@ function addTaskComment(paths, taskRef, text, options = {}) {
};
let inserted = false;
let clarificationCleared = false;
const task = updateTask(paths, taskRef, (currentTask) => {
const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
if (comments.some((entry) => entry.id === comment.id)) {
return currentTask;
}
const authorName = normalizeMemberName(comment.author);
const ownerName = normalizeMemberName(currentTask.owner);
if (currentTask.needsClarification === 'lead' && authorName && authorName !== ownerName) {
delete currentTask.needsClarification;
clarificationCleared = true;
}
if (currentTask.needsClarification === 'user' && authorName === 'user') {
delete currentTask.needsClarification;
clarificationCleared = true;
}
currentTask.comments = comments.concat([comment]);
inserted = true;
return currentTask;
});
return { comment, task, inserted, clarificationCleared };
return { comment, task, inserted, clarificationCleared: false };
}
function setNeedsClarification(paths, taskRef, value) {
@ -808,6 +813,7 @@ module.exports = {
deriveDisplayId,
formatTaskBriefing,
linkTask,
listTaskRows,
listTasks,
readTask,
removeTaskAttachment,

View file

@ -2,6 +2,9 @@ const taskStore = require('./taskStore.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const messages = require('./messages.js');
const processStore = require('./processStore.js');
const kanbanStore = require('./kanbanStore.js');
const agenda = require('./agenda.js');
const { withTeamBoardLock } = require('./boardLock.js');
const { wrapAgentBlock } = require('./agentBlocks.js');
function normalizeActorName(value) {
@ -42,6 +45,13 @@ function quoteMarkdown(text) {
.join('\n');
}
function warnNonCritical(message, error) {
if (typeof console === 'undefined' || typeof console.warn !== 'function') {
return;
}
console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`);
}
function buildAssignmentMessage(context, task, options = {}) {
const description =
typeof options.description === 'string' && options.description.trim() ?
@ -112,15 +122,19 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
}
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
messages.sendMessage(context, {
member: owner,
from: sender,
text: buildAssignmentMessage(context, task, options),
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
summary,
source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}),
});
try {
messages.sendMessage(context, {
member: owner,
from: sender,
text: buildAssignmentMessage(context, task, options),
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
summary,
source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}),
});
} catch (error) {
warnNonCritical(`[tasks] assignment notification failed for task ${task.id}`, error);
}
}
function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
@ -157,7 +171,7 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
}
function createTask(context, input) {
const task = taskStore.createTask(context.paths, input);
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
if (input && input.notifyOwner !== false) {
maybeNotifyAssignedOwner(context, task, {
description: input.description,
@ -219,23 +233,21 @@ function resolveTaskId(context, taskRef) {
}
function setTaskStatus(context, taskId, status, actor) {
return taskStore.setTaskStatus(context.paths, taskId, status, actor);
return withTeamBoardLock(context.paths, () =>
taskStore.setTaskStatus(context.paths, taskId, status, actor)
);
}
function startTask(context, taskId, actor) {
const task = setTaskStatus(context, taskId, 'in_progress', actor);
// Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
try {
const kanbanStore = require('./kanbanStore.js');
return withTeamBoardLock(context.paths, () => {
const 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);
}
} catch {
// Best-effort: task status already updated, kanban cleanup failure is non-fatal
}
return task;
return task;
});
}
function notifyUnblockedOwners(context, completedTask) {
@ -303,8 +315,8 @@ function completeTask(context, taskId, actor) {
const task = setTaskStatus(context, taskId, 'completed', actor);
try {
notifyUnblockedOwners(context, task);
} catch {
// Best-effort: task completion succeeded, notification failure is non-fatal
} catch (error) {
warnNonCritical(`[tasks] dependency-resolution follow-up failed for task ${task.id}`, error);
}
return task;
}
@ -318,8 +330,14 @@ function restoreTask(context, taskId, actor) {
}
function setTaskOwner(context, taskId, owner) {
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
const updatedTask = taskStore.setTaskOwner(context.paths, 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);
return {
previousTask: before,
updatedTask: after,
};
});
if (
owner != null &&
@ -335,19 +353,23 @@ function setTaskOwner(context, taskId, owner) {
}
function updateTaskFields(context, taskId, fields) {
return taskStore.updateTaskFields(context.paths, taskId, fields);
return withTeamBoardLock(context.paths, () =>
taskStore.updateTaskFields(context.paths, taskId, fields)
);
}
function addTaskComment(context, taskId, flags) {
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
author: typeof flags.from === 'string' && flags.from.trim() ?
flags.from.trim() : runtimeHelpers.inferLeadName(context.paths),
...(flags.id ? { id: flags.id } : {}),
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
...(flags.type ? { type: flags.type } : {}),
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
});
const result = withTeamBoardLock(context.paths, () =>
taskStore.addTaskComment(context.paths, taskId, flags.text, {
author: typeof flags.from === 'string' && flags.from.trim() ?
flags.from.trim() : runtimeHelpers.inferLeadName(context.paths),
...(flags.id ? { id: flags.id } : {}),
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
...(flags.type ? { type: flags.type } : {}),
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
})
);
try {
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
@ -355,12 +377,7 @@ function addTaskComment(context, taskId, flags) {
notifyOwner: flags.notifyOwner,
});
} catch (notifyError) {
// Best-effort: comment is already persisted, notification failure must not fail the call
if (typeof console !== 'undefined' && console.warn) {
console.warn(
`[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}`
);
}
warnNonCritical(`[tasks] owner notification failed for task ${taskId}`, notifyError);
}
return {
@ -376,7 +393,9 @@ function addTaskComment(context, taskId, flags) {
function attachTaskFile(context, taskId, flags) {
const canonicalTaskId = resolveTaskId(context, taskId);
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
const task = withTeamBoardLock(context.paths, () =>
taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta)
);
return {
...saved.meta,
task,
@ -386,7 +405,9 @@ function attachTaskFile(context, taskId, flags) {
function attachCommentFile(context, taskId, commentId, flags) {
const canonicalTaskId = resolveTaskId(context, taskId);
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
const task = withTeamBoardLock(context.paths, () =>
taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta)
);
return {
...saved.meta,
task,
@ -394,27 +415,45 @@ function attachCommentFile(context, taskId, commentId, flags) {
}
function addTaskAttachmentMeta(context, taskId, meta) {
return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
return withTeamBoardLock(context.paths, () =>
taskStore.addTaskAttachmentMeta(context.paths, taskId, meta)
);
}
function removeTaskAttachment(context, taskId, attachmentId) {
return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
return withTeamBoardLock(context.paths, () =>
taskStore.removeTaskAttachment(context.paths, taskId, attachmentId)
);
}
function setNeedsClarification(context, taskId, value) {
return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value));
return withTeamBoardLock(context.paths, () =>
taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value))
);
}
function linkTask(context, taskId, targetId, linkType) {
return taskStore.linkTask(context.paths, taskId, targetId, String(linkType));
return withTeamBoardLock(context.paths, () =>
taskStore.linkTask(context.paths, taskId, targetId, String(linkType))
);
}
function unlinkTask(context, taskId, targetId, linkType) {
return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType));
return withTeamBoardLock(context.paths, () =>
taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType))
);
}
async function taskBriefing(context, memberName) {
return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
return agenda.formatTaskBriefing(context.paths, context.teamName, String(memberName));
}
async function leadBriefing(context) {
return agenda.formatLeadBriefing(context.paths, context.teamName);
}
function listTaskInventory(context, filters = {}) {
return agenda.listTaskInventory(context.paths, context.teamName, filters);
}
function getSystemLocale() {
@ -505,10 +544,11 @@ function buildMemberTaskProtocol(teamName) {
- 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, if the task needs review AND the team has a member whose role includes reviewing (e.g. "reviewer", "tech-lead", "qa"), IMMEDIATELY call review_request to move it to the review column and notify the reviewer:
- 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: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
Do NOT leave a completed task without sending it to review when review is expected and a reviewer exists.
If no team member has a reviewer role, skip review_request the task stays completed.
Do NOT infer mandatory review just from free-form teammate roles like "reviewer", "qa", or "tech-lead".
If review is not explicitly requested yet or the reviewer is still undecided, leave the task completed and wait.
3b. When you BEGIN reviewing a task, FIRST call review_start to ensure it appears in the REVIEW column:
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>" }
This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review.
@ -543,16 +583,19 @@ function buildMemberTaskProtocol(teamName) {
{ teamName: "${teamName}", taskId: "<taskId>", text: "question / blocker / missing info", from: "<your-name>" }
c) STEP 3 THEN, send a message to your team lead via SendMessage 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 flag is auto-cleared when the lead adds a task comment on your task.
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
d) The clarification flag is durable until it is cleared explicitly.
When the blocker is truly resolved, clear the flag yourself with:
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
e) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
13. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting.
When you complete a task that blocks others, blocked task owners are notified automatically via a task comment.
14. TASK QUEUE DISCIPLINE:
- Use task_briefing as a compact queue view of your assigned tasks.
- task_briefing is your primary working queue for assigned tasks.
- Use task_list only to search/browse inventory rows. Do NOT use task_list as your working queue.
- task_briefing may include full description/comments only for in_progress tasks; needsFix/pending/review/completed entries may be minimal on purpose.
- Act only on Actionable items from task_briefing.
- Awareness items are watch-only context. Do NOT start work from Awareness unless the lead reroutes the task or you become the actionOwner first.
- Finish existing in_progress tasks first.
- 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 still busy on another task, blocked, or still need more context, immediately add a short task comment on that waiting task with the reason and your best ETA or what you are waiting on.
- Keep any task you have not actually started in pending/TODO (use task_set_status pending if it was moved too early).
@ -710,10 +753,11 @@ async function memberBriefing(context, memberName) {
'',
`Bootstrap flow:`,
`1. Use this briefing as your durable rules source.`,
`2. Use task_briefing as your compact queue view whenever you need to see assigned work.`,
`3. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. 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 another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`,
`4. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`,
`5. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.`
`2. Use task_briefing as your primary working queue whenever you need to see assigned work. Use task_list only to search/browse inventory rows, not as your working queue.`,
`3. Act only on Actionable items in task_briefing. 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.`,
`4. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. 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 another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`,
`5. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`,
`6. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.`
);
lines.push(
@ -754,7 +798,9 @@ module.exports = {
getTaskComment,
linkTask,
listDeletedTasks,
listTaskInventory,
listTasks,
leadBriefing,
removeTaskAttachment,
resolveTaskId,
restoreTask,
@ -770,6 +816,6 @@ module.exports = {
taskBriefing,
unlinkTask,
updateTask: (context, taskRef, updater) =>
taskStore.updateTask(context.paths, taskRef, updater),
withTeamBoardLock(context.paths, () => taskStore.updateTask(context.paths, taskRef, updater)),
updateTaskFields,
};

View file

@ -18,6 +18,8 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
'task_unlink',
];
const AGENT_TEAMS_LEAD_TOOL_NAMES = ['lead_briefing'];
const AGENT_TEAMS_REVIEW_TOOL_NAMES = [
'review_approve',
'review_request',
@ -49,7 +51,14 @@ const AGENT_TEAMS_KANBAN_TOOL_NAMES = [
'kanban_set_column',
];
const AGENT_TEAMS_RUNTIME_TOOL_NAMES = ['team_launch', 'team_stop'];
const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [
'team_launch',
'team_stop',
'runtime_bootstrap_checkin',
'runtime_deliver_message',
'runtime_task_event',
'runtime_heartbeat',
];
const AGENT_TEAMS_MCP_TOOL_GROUPS = [
{
@ -57,6 +66,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [
teammateOperational: true,
toolNames: AGENT_TEAMS_TASK_TOOL_NAMES,
},
{
id: 'lead',
teammateOperational: false,
toolNames: AGENT_TEAMS_LEAD_TOOL_NAMES,
},
{
id: 'kanban',
teammateOperational: false,
@ -100,8 +114,17 @@ const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.
const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES =
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`);
const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES = [
...AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
...AGENT_TEAMS_LEAD_TOOL_NAMES,
];
const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES =
AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`);
module.exports = {
AGENT_TEAMS_TASK_TOOL_NAMES,
AGENT_TEAMS_LEAD_TOOL_NAMES,
AGENT_TEAMS_REVIEW_TOOL_NAMES,
AGENT_TEAMS_MESSAGE_TOOL_NAMES,
AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES,
@ -112,4 +135,6 @@ module.exports = {
AGENT_TEAMS_REGISTERED_TOOL_NAMES,
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES,
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
};

View file

@ -146,6 +146,11 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain('Implement carefully');
expect(briefing).toContain('Working directory: /tmp/project-x');
expect(briefing).toContain('Task briefing for bob:');
expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.');
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
expect(briefing).toContain(
'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.'
);
});
it('resolves member briefing from members.meta.json when config members are missing', async () => {
@ -280,7 +285,7 @@ describe('agent-teams-controller API', () => {
expect(rows[1].id).toBe(registered.id);
});
it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and groups briefing by review-aware sections', async () => {
it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and groups briefing into actionable and awareness queues', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -355,24 +360,29 @@ describe('agent-teams-controller API', () => {
expect(ownerInbox[3].text).toContain('task_add_comment');
const briefing = await controller.tasks.taskBriefing('bob');
expect(briefing).toContain('In progress:');
expect(briefing).toContain(
'Primary queue for bob. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
);
expect(briefing).toContain(
'Use task_list only to search/browse inventory rows, not as your working queue.'
);
expect(briefing).toContain('Actionable:');
expect(briefing).toContain(`#${activeTask.displayId}`);
expect(briefing).toContain('Description: Resume immediately');
expect(briefing).toContain('Resumed work with latest context.');
expect(briefing).toContain('Needs fixes after review:');
expect(briefing).toContain(`#${needsFixTask.displayId}`);
expect(briefing).toContain('Pending:');
expect(briefing).toContain('reason=needs_fix');
expect(briefing).toContain(`#${pendingTask.displayId}`);
expect(briefing).not.toContain('Description: Do this later');
expect(briefing).toContain('Review:');
expect(briefing).toContain('Awareness:');
expect(briefing).toContain(`#${reviewTask.displayId}`);
expect(briefing).toContain('Completed:');
expect(briefing).toContain('reason=review_reviewer_missing');
expect(briefing).toContain(`#${completedTask.displayId}`);
expect(briefing).not.toContain(
'Completed task description should stay out of compact rows'
);
expect(briefing).toContain('Approved (last 10):');
expect(briefing).toContain(`#${approvedTask.displayId}`);
expect(briefing).toContain('Counters: actionable=4, awareness=3');
});
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
@ -584,6 +594,31 @@ describe('agent-teams-controller API', () => {
expect(startedEvents).toHaveLength(1);
});
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Queued for review', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
const started = controller.review.startReview(task.id, { from: 'alice' });
expect(started.ok).toBe(true);
const reloaded = controller.tasks.getTask(task.id);
const requestedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_requested');
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
expect(requestedEvents).toHaveLength(1);
expect(startedEvents).toHaveLength(1);
expect(startedEvents[0].from).toBe('review');
expect(startedEvents[0].to).toBe('review');
expect(startedEvents[0].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('throws when starting review on a deleted task', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -639,7 +674,7 @@ describe('agent-teams-controller API', () => {
expect(rows[0].leadSessionId).toBe('lead-session-1');
});
it('does not wake owner for self-comments and clears user clarification when user replies', () => {
it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
@ -662,12 +697,16 @@ describe('agent-teams-controller API', () => {
text: 'Please use the safer option.',
});
expect(replied.task.needsClarification).toBeUndefined();
expect(replied.task.needsClarification).toBe('user');
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.needsClarification).toBeUndefined();
expect(reloaded.needsClarification).toBe('user');
const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].text).toContain('Please use the safer option.');
const cleared = controller.tasks.setNeedsClarification(task.id, 'clear');
expect(cleared.needsClarification).toBeUndefined();
expect(controller.tasks.getTask(task.id).needsClarification).toBeUndefined();
});
it('wakes lead owner on comment from another member', () => {
@ -757,7 +796,7 @@ describe('agent-teams-controller API', () => {
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
});
it('limits approved briefing section to the latest 10 tasks by freshness', async () => {
it('keeps approved tasks in awareness ordered by freshness', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -772,11 +811,112 @@ describe('agent-teams-controller API', () => {
);
const briefing = await controller.tasks.taskBriefing('bob');
expect(briefing).toContain('Approved (last 10):');
expect(briefing).toContain('Awareness:');
expect(briefing).toContain(`#${approvedTasks[11].displayId}`);
expect(briefing).toContain(`#${approvedTasks[2].displayId}`);
expect(briefing).not.toContain(`#${approvedTasks[1].displayId}`);
expect(briefing).not.toContain(`#${approvedTasks[0].displayId}`);
expect(briefing).toContain(`#${approvedTasks[1].displayId}`);
expect(briefing).toContain(`#${approvedTasks[0].displayId}`);
expect(briefing.indexOf(`#${approvedTasks[11].displayId}`)).toBeLessThan(
briefing.indexOf(`#${approvedTasks[0].displayId}`)
);
});
it('builds derived lead briefing and filtered task inventory', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const queuedTask = controller.tasks.createTask({
subject: 'Queued implementation',
owner: 'bob',
notifyOwner: false,
});
const unassignedTask = controller.tasks.createTask({
subject: 'Needs owner',
notifyOwner: false,
});
const reviewTask = controller.tasks.createTask({
subject: 'Needs review pickup',
owner: 'bob',
notifyOwner: false,
});
controller.tasks.completeTask(reviewTask.id, 'bob');
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'alice' });
const leadBriefing = await controller.tasks.leadBriefing();
expect(leadBriefing).toContain('Lead queue for alice on team "my-team":');
expect(leadBriefing).toContain(
'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.'
);
expect(leadBriefing).toContain(
'Use task_list only for search, filtering, and drill-down inventory lookups.'
);
expect(leadBriefing).toContain('Needs owner assignment:');
expect(leadBriefing).toContain(`#${unassignedTask.displayId}`);
expect(leadBriefing).toContain('Lead-owned follow-up:');
expect(leadBriefing).toContain(`#${reviewTask.displayId}`);
const reviewInventory = controller.tasks.listTaskInventory({ reviewState: 'review' });
expect(reviewInventory).toHaveLength(1);
expect(reviewInventory[0].id).toBe(reviewTask.id);
const ownerPendingInventory = controller.tasks.listTaskInventory({
owner: 'bob',
status: 'pending',
});
expect(ownerPendingInventory.map((task) => task.id)).toEqual([queuedTask.id]);
});
it('uses legacy kanban reviewer as a migration fallback for active review tasks', 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 reviewTask = controller.tasks.createTask({
subject: 'Legacy review assignment',
owner: 'bob',
status: 'completed',
reviewState: 'review',
notifyOwner: false,
});
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json'),
JSON.stringify(
{
teamName: 'my-team',
reviewers: [],
tasks: {
[reviewTask.id]: {
column: 'review',
reviewer: 'carol',
movedAt: '2026-01-01T00:00:00.000Z',
},
},
},
null,
2
)
);
const reviewerBriefing = await controller.tasks.taskBriefing('carol');
expect(reviewerBriefing).toContain(
'Primary queue for carol. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
);
expect(reviewerBriefing).toContain('Actionable:');
expect(reviewerBriefing).toContain(`#${reviewTask.displayId}`);
expect(reviewerBriefing).toContain('reviewer=carol');
const leadBriefing = await controller.tasks.leadBriefing();
expect(leadBriefing).toContain(
'Use task_list only for search, filtering, and drill-down inventory lookups.'
);
expect(leadBriefing).toContain('Watching:');
expect(leadBriefing).toContain(`#${reviewTask.displayId}`);
expect(leadBriefing).not.toContain('review_reviewer_missing');
});
it('marks stale processes stopped during listing and supports unregister', () => {
@ -929,6 +1069,77 @@ describe('agent-teams-controller API', () => {
}
});
it('forwards OpenCode runtime MCP calls to the app control API', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/bootstrap-checkin') {
return { body: { ok: true, state: 'accepted' } };
}
if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/deliver-message') {
return { body: { ok: true, state: 'delivered' } };
}
if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/task-event') {
return { body: { ok: true, state: 'recorded' } };
}
if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/heartbeat') {
return { body: { ok: true, state: 'accepted' } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
await controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
await controller.runtime.runtimeDeliverMessage({
controlUrl: server.baseUrl,
idempotencyKey: 'idem-1',
runId: 'run-oc',
fromMemberName: 'bob',
runtimeSessionId: 'ses-1',
to: 'user',
text: 'hello',
});
await controller.runtime.runtimeTaskEvent({
controlUrl: server.baseUrl,
idempotencyKey: 'idem-task-1',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
taskId: 'task-1',
event: 'started',
});
await controller.runtime.runtimeHeartbeat({
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
expect(calls.map((call) => call.url)).toEqual([
'/api/teams/my-team/opencode/runtime/bootstrap-checkin',
'/api/teams/my-team/opencode/runtime/deliver-message',
'/api/teams/my-team/opencode/runtime/task-event',
'/api/teams/my-team/opencode/runtime/heartbeat',
]);
expect(calls[0].body).toEqual({
teamName: 'my-team',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
} finally {
await server.close();
}
});
it('prefers the published control endpoint over a stale env URL', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,7 @@
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json |
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification |
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
## Ключевые решения

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ declare module 'agent-teams-controller' {
getTask(taskId: string): unknown;
getTaskComment(taskId: string, commentId: string): { comment: Record<string, unknown>; task: { id: string; displayId: string; subject: string; status: string; owner: string | null; commentCount: number } };
listTasks(): unknown[];
listTaskInventory(filters?: Record<string, unknown>): unknown[];
listDeletedTasks(): unknown[];
resolveTaskId(taskRef: string): string;
setTaskStatus(taskId: string, status: string, actor?: string): unknown;
@ -27,6 +28,7 @@ declare module 'agent-teams-controller' {
linkTask(taskId: string, targetId: string, linkType: string): unknown;
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
memberBriefing(memberName: string): Promise<string>;
leadBriefing(): Promise<string>;
taskBriefing(memberName: string): Promise<string>;
}
@ -74,6 +76,10 @@ declare module 'agent-teams-controller' {
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;
runtimeBootstrapCheckin(flags: Record<string, unknown>): Promise<unknown>;
runtimeDeliverMessage(flags: Record<string, unknown>): Promise<unknown>;
runtimeTaskEvent(flags: Record<string, unknown>): Promise<unknown>;
runtimeHeartbeat(flags: Record<string, unknown>): Promise<unknown>;
}
export interface AgentTeamsController {
@ -111,6 +117,7 @@ declare module 'agent-teams-controller' {
export type AgentTeamsMcpToolGroupId =
| 'task'
| 'lead'
| 'kanban'
| 'review'
| 'message'
@ -125,6 +132,7 @@ declare module 'agent-teams-controller' {
}
export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_LEAD_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[];
@ -135,4 +143,6 @@ declare module 'agent-teams-controller' {
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[];
}

View file

@ -7,6 +7,7 @@ const { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } =
import { registerCrossTeamTools } from './crossTeamTools';
import { registerKanbanTools } from './kanbanTools';
import { registerLeadTools } from './leadTools';
import { registerMessageTools } from './messageTools';
import { registerProcessTools } from './processTools';
import { registerReviewTools } from './reviewTools';
@ -15,6 +16,7 @@ import { registerTaskTools } from './taskTools';
const REGISTRATION_BY_GROUP = {
task: registerTaskTools,
lead: registerLeadTools,
kanban: registerKanbanTools,
review: registerReviewTools,
message: registerMessageTools,

View file

@ -0,0 +1,32 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
const toolContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
};
const ALWAYS_LOAD_META = {
'anthropic/alwaysLoad': true,
} as const;
export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'lead_briefing',
description: 'Get the compact operational lead queue for a team',
_meta: ALWAYS_LOAD_META,
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) => ({
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
},
],
}),
});
}

View file

@ -11,6 +11,22 @@ const toolContextSchema = {
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
};
const runtimeMetadataSchema = z.record(z.string(), z.unknown()).optional();
const runtimeDiagnosticsSchema = z.array(z.string().min(1)).optional();
const runtimeIdentitySchema = {
...toolContextSchema,
runId: z.string().min(1),
memberName: z.string().min(1),
runtimeSessionId: z.string().min(1),
};
const runtimeDeliveryTargetSchema = z.union([
z.literal('user'),
z.object({
memberName: z.string().min(1),
teamName: z.string().min(1).optional(),
}),
]);
export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'team_launch',
@ -75,4 +91,168 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
})
),
});
server.addTool({
name: 'runtime_bootstrap_checkin',
description: 'Confirm that an OpenCode team member runtime reached the app MCP bootstrap boundary',
parameters: z.object({
...runtimeIdentitySchema,
observedAt: z.string().min(1).optional(),
diagnostics: runtimeDiagnosticsSchema,
metadata: runtimeMetadataSchema,
}),
execute: async ({
teamName,
claudeDir,
controlUrl,
waitTimeoutMs,
runId,
memberName,
runtimeSessionId,
observedAt,
diagnostics,
metadata,
}) =>
jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
runId,
memberName,
runtimeSessionId,
...(observedAt ? { observedAt } : {}),
...(diagnostics ? { diagnostics } : {}),
...(metadata ? { metadata } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
),
});
server.addTool({
name: 'runtime_deliver_message',
description: 'Deliver an OpenCode runtime message to the app-owned team journal and destination',
parameters: z.object({
...toolContextSchema,
idempotencyKey: z.string().min(1),
runId: z.string().min(1),
fromMemberName: z.string().min(1),
runtimeSessionId: z.string().min(1),
to: runtimeDeliveryTargetSchema,
text: z.string().min(1),
createdAt: z.string().min(1).optional(),
summary: z.string().optional(),
taskRefs: z.array(z.unknown()).optional(),
}),
execute: async ({
teamName,
claudeDir,
controlUrl,
waitTimeoutMs,
idempotencyKey,
runId,
fromMemberName,
runtimeSessionId,
to,
text,
createdAt,
summary,
taskRefs,
}) =>
jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
idempotencyKey,
runId,
fromMemberName,
runtimeSessionId,
to,
text,
...(createdAt ? { createdAt } : {}),
...(summary ? { summary } : {}),
...(taskRefs ? { taskRefs } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
),
});
server.addTool({
name: 'runtime_task_event',
description: 'Record an idempotent OpenCode runtime task event for app-side attribution',
parameters: z.object({
...toolContextSchema,
idempotencyKey: z.string().min(1),
runId: z.string().min(1),
memberName: z.string().min(1),
runtimeSessionId: z.string().min(1).optional(),
taskId: z.string().min(1),
event: z.string().min(1),
createdAt: z.string().min(1).optional(),
summary: z.string().optional(),
metadata: runtimeMetadataSchema,
}),
execute: async ({
teamName,
claudeDir,
controlUrl,
waitTimeoutMs,
idempotencyKey,
runId,
memberName,
runtimeSessionId,
taskId,
event,
createdAt,
summary,
metadata,
}) =>
jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
idempotencyKey,
runId,
memberName,
...(runtimeSessionId ? { runtimeSessionId } : {}),
taskId,
event,
...(createdAt ? { createdAt } : {}),
...(summary ? { summary } : {}),
...(metadata ? { metadata } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
),
});
server.addTool({
name: 'runtime_heartbeat',
description: 'Refresh OpenCode member runtime liveness in the app-owned launch state',
parameters: z.object({
...runtimeIdentitySchema,
observedAt: z.string().min(1).optional(),
status: z.enum(['alive', 'idle', 'busy']).optional(),
metadata: runtimeMetadataSchema,
}),
execute: async ({
teamName,
claudeDir,
controlUrl,
waitTimeoutMs,
runId,
memberName,
runtimeSessionId,
observedAt,
status,
metadata,
}) =>
jsonTextContent(
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
runId,
memberName,
runtimeSessionId,
...(observedAt ? { observedAt } : {}),
...(status ? { status } : {}),
...(metadata ? { metadata } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
),
});
}

View file

@ -4,7 +4,7 @@ import path from 'node:path';
import { z } from 'zod';
import { agentBlocks, getController } from '../controller';
import { jsonTextContent, taskWriteResult, slimTask, slimTaskForList } from '../utils/format';
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
const stripAgentBlocksFn = (text: string): string => agentBlocks.stripAgentBlocks(text);
@ -19,6 +19,18 @@ const ALWAYS_LOAD_META = {
} as const;
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
const inventoryTaskStatusSchema = z.enum(['pending', 'in_progress', 'completed']);
const reviewStateSchema = z.enum(['none', 'review', 'needsFix', 'approved']);
const inventoryKanbanColumnSchema = z.enum(['review', 'approved']);
const DEFAULT_TASK_LIST_LIMIT = 50;
const MAX_TASK_LIST_LIMIT = 200;
function normalizeTaskListLimit(limit: number | undefined): number {
if (limit == null) {
return DEFAULT_TASK_LIST_LIMIT;
}
return Math.min(Math.max(1, Math.floor(limit)), MAX_TASK_LIST_LIMIT);
}
/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */
const USER_ORIGINATED_SOURCES = new Set(['user_sent']);
@ -299,14 +311,40 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'task_list',
description: 'List tasks for a team',
description:
'List compact active task inventory/search rows for a team. Deleted tasks are excluded. Use it to browse, filter, and drill into inventory, not as a primary working queue. Defaults to 50 rows and caps at 200 rows; use filters or a smaller limit to narrow results. Supports stable conjunctive filters for owner, active status, reviewState, review overlay column, and task relationships.',
parameters: z.object({
...toolContextSchema,
owner: z.string().min(1).optional(),
status: inventoryTaskStatusSchema.optional(),
reviewState: reviewStateSchema.optional(),
kanbanColumn: inventoryKanbanColumnSchema.optional(),
relatedTo: z.string().min(1).optional(),
blockedBy: z.string().min(1).optional(),
limit: z.number().int().positive().optional(),
}),
execute: async ({ teamName, claudeDir }) =>
execute: async ({
teamName,
claudeDir,
owner,
status,
reviewState,
kanbanColumn,
relatedTo,
blockedBy,
limit,
}) =>
await Promise.resolve(
jsonTextContent(
(getController(teamName, claudeDir).tasks.listTasks() as Record<string, unknown>[]).map(slimTaskForList)
getController(teamName, claudeDir).tasks.listTaskInventory({
...(owner ? { owner } : {}),
...(status ? { status } : {}),
...(reviewState ? { reviewState } : {}),
...(kanbanColumn ? { kanbanColumn } : {}),
...(relatedTo ? { relatedTo } : {}),
...(blockedBy ? { blockedBy } : {}),
limit: normalizeTaskListLimit(limit),
})
)
),
});

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import path from 'path';
import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools';
type RegisteredTool = {
description?: string;
name: string;
parameters?: { safeParse: (value: unknown) => { success: boolean } };
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown;
@ -214,6 +215,69 @@ describe('agent-teams-mcp tools', () => {
}
});
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
return { body: { ok: true, state: 'accepted' } };
});
try {
await getTool('runtime_bootstrap_checkin').execute({
teamName: 'alpha',
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'alice',
runtimeSessionId: 'ses-1',
});
await getTool('runtime_deliver_message').execute({
teamName: 'alpha',
controlUrl: server.baseUrl,
idempotencyKey: 'idem-1',
runId: 'run-oc',
fromMemberName: 'alice',
runtimeSessionId: 'ses-1',
to: 'user',
text: 'hello',
});
await getTool('runtime_task_event').execute({
teamName: 'alpha',
controlUrl: server.baseUrl,
idempotencyKey: 'idem-task-1',
runId: 'run-oc',
memberName: 'alice',
runtimeSessionId: 'ses-1',
taskId: 'task-1',
event: 'started',
});
await getTool('runtime_heartbeat').execute({
teamName: 'alpha',
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'alice',
runtimeSessionId: 'ses-1',
});
expect(calls.map((call) => call.url)).toEqual([
'/api/teams/alpha/opencode/runtime/bootstrap-checkin',
'/api/teams/alpha/opencode/runtime/deliver-message',
'/api/teams/alpha/opencode/runtime/task-event',
'/api/teams/alpha/opencode/runtime/heartbeat',
]);
expect(calls[1].body).toEqual({
teamName: 'alpha',
idempotencyKey: 'idem-1',
runId: 'run-oc',
fromMemberName: 'alice',
runtimeSessionId: 'ses-1',
to: 'user',
text: 'hello',
});
} finally {
await server.close();
}
});
it('discovers the control endpoint from the published state file', async () => {
const claudeDir = makeClaudeDir();
const statePath = path.join(claudeDir, 'team-control-api.json');
@ -484,7 +548,12 @@ describe('agent-teams-mcp tools', () => {
const memberBriefingText = (memberBriefing as { content: Array<{ text: string }> }).content[0]
?.text;
expect(memberBriefingText).toContain('Member briefing for alice on team "alpha" (alpha).');
expect(memberBriefingText).toContain('Use task_briefing as your compact queue view');
expect(memberBriefingText).toContain(
'Use task_briefing as your primary working queue whenever you need to see assigned work.'
);
expect(memberBriefingText).toContain(
'Use task_list only to search/browse inventory rows, not as your working queue.'
);
expect(memberBriefingText).toContain('Review MCP adapter');
});
@ -593,14 +662,19 @@ describe('agent-teams-mcp tools', () => {
memberName: 'alice',
})) as { content: Array<{ text: string }> };
const briefingText = briefing.content[0]?.text ?? '';
expect(briefingText).toContain('In progress:');
expect(briefingText).toContain(
'Primary queue for alice. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
);
expect(briefingText).toContain(
'Use task_list only to search/browse inventory rows, not as your working queue.'
);
expect(briefingText).toContain('Actionable:');
expect(briefingText).toContain(`#${activeTask.displayId}`);
expect(briefingText).toContain('Description: This one is already in progress');
expect(briefingText).toContain('Investigating the active task now.');
expect(briefingText).toContain('Pending:');
expect(briefingText).toContain(`#${queuedTask.displayId}`);
expect(briefingText).not.toContain('Pending description should stay out of briefing details');
expect(briefingText).toContain('Completed:');
expect(briefingText).toContain('Awareness:');
expect(briefingText).toContain(`#${completedTask.displayId}`);
expect(briefingText).not.toContain('Completed description should also stay compact');
@ -618,6 +692,9 @@ describe('agent-teams-mcp tools', () => {
);
expect(memberBriefingText).toContain('reason and your best ETA or what you are waiting on');
expect(memberBriefingText).toContain('IMPORTANT: Communicate in English.');
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('Task briefing for alice:');
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
@ -662,6 +739,95 @@ describe('agent-teams-mcp tools', () => {
);
});
it('returns compact lead_briefing output and filtered task_list inventory', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'lead-queue';
writeTeamConfig(claudeDir, teamName, {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
{ name: 'bob', role: 'reviewer' },
],
});
const queuedTask = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Queued work',
owner: 'alice',
})
);
const unassignedTask = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Unassigned work',
})
);
const reviewTask = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Awaiting reviewer',
owner: 'alice',
})
);
await getTool('task_complete').execute({
claudeDir,
teamName,
taskId: reviewTask.id,
actor: 'alice',
});
await getTool('review_request').execute({
claudeDir,
teamName,
taskId: reviewTask.id,
from: 'lead',
reviewer: 'bob',
});
const leadBriefing = (await getTool('lead_briefing').execute({
claudeDir,
teamName,
})) as { content: Array<{ text: string }> };
const leadBriefingText = leadBriefing.content[0]?.text ?? '';
expect(leadBriefingText).toContain('Lead queue for lead on team "lead-queue":');
expect(leadBriefingText).toContain(
'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.'
);
expect(leadBriefingText).toContain(
'Use task_list only for search, filtering, and drill-down inventory lookups.'
);
expect(leadBriefingText).toContain('Needs owner assignment:');
expect(leadBriefingText).toContain(`#${unassignedTask.displayId}`);
expect(leadBriefingText).toContain('Watching:');
expect(leadBriefingText).toContain(`#${reviewTask.displayId}`);
const reviewInventory = parseJsonToolResult(
await getTool('task_list').execute({
claudeDir,
teamName,
reviewState: 'review',
kanbanColumn: 'review',
})
);
expect(reviewInventory).toHaveLength(1);
expect(reviewInventory[0].id).toBe(reviewTask.id);
const ownerPendingInventory = parseJsonToolResult(
await getTool('task_list').execute({
claudeDir,
teamName,
owner: 'alice',
status: 'pending',
})
);
expect(ownerPendingInventory).toHaveLength(1);
expect(ownerPendingInventory[0].id).toBe(queuedTask.id);
});
it('covers review_request_changes and full process lifecycle tools', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'beta';
@ -915,7 +1081,13 @@ describe('agent-teams-mcp tools', () => {
expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
});
it('write operations return slim task (no comments/historyEvents arrays)', async () => {
it('write operations return slim task and task_list returns allowlisted inventory rows', async () => {
expect(getTool('task_list').description).toContain(
'Use it to browse, filter, and drill into inventory, not as a primary working queue.'
);
expect(getTool('task_list').description).toContain('Deleted tasks are excluded.');
expect(getTool('task_list').description).toContain('Defaults to 50 rows and caps at 200 rows');
const claudeDir = makeClaudeDir();
const teamName = 'slim-check';
@ -980,20 +1152,27 @@ describe('agent-teams-mcp tools', () => {
expect(completed.status).toBe('completed');
expect(completed.comments).toBeUndefined();
// task_list: uses blocklist, includes description but not comments array
// task_list: explicit inventory shape only
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.subject).toBe('Slim task test');
expect(listedTask.commentCount).toBe(1);
expect(listedTask).toEqual({
id: task.id,
displayId: task.displayId,
subject: 'Slim task test',
status: 'completed',
owner: 'lead',
reviewState: 'none',
commentCount: 1,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(listedTask.description).toBeUndefined();
expect(listedTask.comments).toBeUndefined();
expect(listedTask.historyEvents).toBeUndefined();
expect(listedTask.workIntervals).toBeUndefined();
// task_list preserves non-heavy fields
expect(listedTask.status).toBeDefined();
expect(listedTask.id).toBeDefined();
// task_get: still returns full task with comments
const full = parseJsonToolResult(

View file

@ -21,6 +21,7 @@
"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",
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "electron-vite build",
"dist": "electron-builder --mac --win --linux",
@ -48,6 +49,7 @@
"test:noise": "tsx test/test-noise-filtering.ts",
"test:task-filtering": "tsx test/test-task-filtering.ts",
"test": "vitest run",
"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",
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
@ -344,11 +346,5 @@
"ignoreBinaries": [
"pkg"
]
},
"workspaces": [
"agent-teams-controller",
"mcp-server",
"landing",
"packages/agent-graph"
]
}
}

View file

@ -0,0 +1,72 @@
#!/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');
}

View file

@ -642,7 +642,8 @@ export class TeamGraphAdapter {
reviewerName: isReviewCycle ? reviewerName : null,
reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined,
reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined,
changePresence: task.changePresence,
changePresence:
task.changePresence === 'needs_attention' ? 'has_changes' : task.changePresence,
displayId: task.displayId ?? undefined,
ownerId: ownerMemberId,
needsClarification: task.needsClarification ?? null,

View file

@ -0,0 +1,212 @@
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import type {
CliProviderModelCatalog,
CliProviderModelCatalogItem,
CliProviderRuntimeCapabilities,
EffortLevel,
TeamFastMode,
} from '@shared/types';
export interface AnthropicRuntimeProfileSource {
modelCatalog?: CliProviderModelCatalog | null;
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
}
export interface AnthropicRuntimeSelection {
resolvedLaunchModel: string | null;
catalogModel: CliProviderModelCatalogItem | null;
displayName: string | null;
catalogSource: CliProviderModelCatalog['source'] | 'unavailable';
catalogStatus: CliProviderModelCatalog['status'] | 'unavailable';
catalogFetchedAt: string | null;
supportedEfforts: EffortLevel[];
defaultEffort: EffortLevel | null;
supportsFastMode: boolean;
providerFastModeSupported: boolean;
providerFastModeAvailable: boolean;
providerFastModeReason: string | null;
}
export interface AnthropicFastModeResolution {
selectedFastMode: TeamFastMode;
requestedFastMode: boolean;
resolvedFastMode: boolean;
showFastModeControl: boolean;
selectable: boolean;
disabledReason: string | null;
}
export interface AnthropicRuntimeReconciliation {
nextEffort: EffortLevel | '';
effortResetReason: string | null;
nextFastMode: TeamFastMode;
fastModeResetReason: string | null;
}
function getAnthropicCatalog(
source: AnthropicRuntimeProfileSource
): CliProviderModelCatalog | null {
return source.modelCatalog?.providerId === 'anthropic' ? source.modelCatalog : null;
}
function normalizeEffortLevel(value: string | null | undefined): EffortLevel | null {
return value === 'none' ||
value === 'minimal' ||
value === 'low' ||
value === 'medium' ||
value === 'high' ||
value === 'xhigh' ||
value === 'max'
? value
: null;
}
function normalizeEffortLevels(values: readonly string[] | undefined): EffortLevel[] {
const normalized = new Set<EffortLevel>();
for (const value of values ?? []) {
const effort = normalizeEffortLevel(value);
if (effort) {
normalized.add(effort);
}
}
return Array.from(normalized);
}
function hasCatalogTruth(selection: AnthropicRuntimeSelection): boolean {
return selection.catalogSource !== 'unavailable' && selection.catalogStatus !== 'unavailable';
}
export function resolveAnthropicRuntimeSelection(params: {
source: AnthropicRuntimeProfileSource;
selectedModel?: string | null;
limitContext: boolean;
}): AnthropicRuntimeSelection {
const catalog = getAnthropicCatalog(params.source);
const resolvedLaunchModel =
resolveAnthropicLaunchModel({
selectedModel: params.selectedModel,
limitContext: params.limitContext,
availableLaunchModels: catalog?.models.map((model) => model.launchModel),
defaultLaunchModel: catalog?.defaultLaunchModel ?? null,
}) ?? null;
const catalogModel =
resolvedLaunchModel && catalog
? (catalog.models.find(
(model) =>
model.launchModel.trim() === resolvedLaunchModel ||
model.id.trim() === resolvedLaunchModel
) ?? null)
: null;
return {
resolvedLaunchModel,
catalogModel,
displayName: catalogModel?.displayName?.trim() ?? null,
catalogSource: catalog?.source ?? 'unavailable',
catalogStatus: catalog?.status ?? 'unavailable',
catalogFetchedAt: catalog?.fetchedAt ?? null,
supportedEfforts: normalizeEffortLevels(catalogModel?.supportedReasoningEfforts),
defaultEffort: normalizeEffortLevel(catalogModel?.defaultReasoningEffort ?? null),
supportsFastMode: catalogModel?.supportsFastMode === true,
providerFastModeSupported: params.source.runtimeCapabilities?.fastMode?.supported === true,
providerFastModeAvailable: params.source.runtimeCapabilities?.fastMode?.available === true,
providerFastModeReason: params.source.runtimeCapabilities?.fastMode?.reason ?? null,
};
}
export function resolveAnthropicFastMode(params: {
selection: AnthropicRuntimeSelection;
selectedFastMode?: TeamFastMode | null;
providerFastModeDefault?: boolean;
}): AnthropicFastModeResolution {
const selectedFastMode = params.selectedFastMode ?? 'inherit';
const requestedFastMode =
selectedFastMode === 'on'
? true
: selectedFastMode === 'off'
? false
: params.providerFastModeDefault === true;
const selectable =
params.selection.providerFastModeSupported &&
params.selection.providerFastModeAvailable &&
params.selection.supportsFastMode;
let disabledReason: string | null = null;
if (!hasCatalogTruth(params.selection) && !params.selection.providerFastModeSupported) {
disabledReason = 'Anthropic runtime capability data is still loading.';
} else if (!params.selection.providerFastModeSupported) {
disabledReason =
params.selection.providerFastModeReason ??
'Fast mode is not supported by this Anthropic runtime.';
} else if (!params.selection.supportsFastMode) {
disabledReason = params.selection.displayName
? `Fast mode is available only for Opus 4.6. Selected model resolves to ${params.selection.displayName}.`
: 'Fast mode is available only for Opus 4.6.';
} else if (!params.selection.providerFastModeAvailable) {
disabledReason =
params.selection.providerFastModeReason ?? 'Fast mode is currently unavailable.';
}
return {
selectedFastMode,
requestedFastMode,
resolvedFastMode: requestedFastMode && selectable,
showFastModeControl:
params.selection.providerFastModeSupported ||
selectedFastMode !== 'inherit' ||
params.providerFastModeDefault === true,
selectable,
disabledReason,
};
}
export function reconcileAnthropicRuntimeSelections(params: {
selection: AnthropicRuntimeSelection;
selectedEffort?: string | null;
selectedFastMode?: TeamFastMode | null;
providerFastModeDefault?: boolean;
}): AnthropicRuntimeReconciliation {
const selectedEffort = normalizeEffortLevel(params.selectedEffort ?? null);
if (!hasCatalogTruth(params.selection)) {
return {
nextEffort: selectedEffort ?? '',
effortResetReason: null,
nextFastMode: params.selectedFastMode ?? 'inherit',
fastModeResetReason: null,
};
}
const nextEffort =
selectedEffort && !params.selection.supportedEfforts.includes(selectedEffort)
? ''
: (selectedEffort ?? '');
const effortResetReason =
selectedEffort && nextEffort === ''
? `${selectedEffort} effort is not available for the currently selected Anthropic model. Reset to Default.`
: null;
const fastResolution = resolveAnthropicFastMode({
selection: params.selection,
selectedFastMode: params.selectedFastMode,
providerFastModeDefault: params.providerFastModeDefault,
});
const nextFastMode =
fastResolution.selectedFastMode === 'on' && !fastResolution.selectable
? 'inherit'
: fastResolution.selectedFastMode;
const fastModeResetReason =
fastResolution.selectedFastMode === 'on' && nextFastMode !== 'on'
? (fastResolution.disabledReason ??
'Fast mode is not available for the currently selected Anthropic model. Reset to Default.')
: null;
return {
nextEffort,
effortResetReason,
nextFastMode,
fastModeResetReason,
};
}

View file

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

View file

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

View file

@ -0,0 +1,13 @@
import type {
CliProviderModelCatalog,
CliProviderModelCatalogItem,
CliProviderModelCatalogSource,
CliProviderModelCatalogStatus,
CliProviderReasoningEffort,
} from '@shared/types';
export type CodexModelCatalogDto = CliProviderModelCatalog;
export type CodexModelCatalogItemDto = CliProviderModelCatalogItem;
export type CodexModelCatalogSourceDto = CliProviderModelCatalogSource;
export type CodexModelCatalogStatusDto = CliProviderModelCatalogStatus;
export type CodexModelReasoningEffortDto = CliProviderReasoningEffort;

View file

@ -0,0 +1,7 @@
export type {
CodexModelCatalogDto,
CodexModelCatalogItemDto,
CodexModelCatalogSourceDto,
CodexModelCatalogStatusDto,
CodexModelReasoningEffortDto,
} from './dto';

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { normalizeCodexAppServerModels } from '../normalizeCodexAppServerModel';
describe('normalizeCodexAppServerModels', () => {
it('keeps app-server model metadata required by the UI picker', () => {
const result = normalizeCodexAppServerModels([
{
id: 'gpt-5.5',
displayName: 'GPT-5.5',
supportedReasoningEfforts: [
{ reasoningEffort: 'low' },
{ reasoningEffort: 'medium' },
{ reasoningEffort: 'high' },
{ reasoningEffort: 'xhigh' },
],
defaultReasoningEffort: 'xhigh',
inputModalities: ['text', 'image'],
supportsPersonality: true,
isDefault: true,
},
]);
expect(result.defaultModelId).toBe('gpt-5.5');
expect(result.models).toEqual([
expect.objectContaining({
id: 'gpt-5.5',
launchModel: 'gpt-5.5',
displayName: 'GPT-5.5',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
defaultReasoningEffort: 'xhigh',
inputModalities: ['text', 'image'],
supportsPersonality: true,
isDefault: true,
source: 'app-server',
}),
]);
});
it('filters hidden models unless the caller explicitly asks for them', () => {
const result = normalizeCodexAppServerModels([
{ id: 'gpt-visible', hidden: false },
{ id: 'gpt-hidden', hidden: true },
]);
expect(result.models.map((model) => model.id)).toEqual(['gpt-visible']);
const withHidden = normalizeCodexAppServerModels(
[
{ id: 'gpt-visible', hidden: false },
{ id: 'gpt-hidden', hidden: true },
],
{ includeHidden: true }
);
expect(withHidden.models.map((model) => model.id)).toEqual(['gpt-visible', 'gpt-hidden']);
});
it('drops unknown effort values instead of leaking them into launch options', () => {
const result = normalizeCodexAppServerModels([
{
id: 'gpt-5.4',
supportedReasoningEfforts: ['none', 'medium', { reasoningEffort: 'future-effort' }],
defaultReasoningEffort: 'future-effort',
},
]);
expect(result.models[0]?.supportedReasoningEfforts).toEqual(['medium']);
expect(result.models[0]?.defaultReasoningEffort).toBe('medium');
});
it('uses model as the launch value and de-duplicates duplicate launch models', () => {
const result = normalizeCodexAppServerModels([
{
id: 'catalog-alias',
model: 'gpt-5.5',
displayName: 'GPT-5.5 Alias',
},
{
id: 'catalog-duplicate',
model: 'gpt-5.5',
displayName: 'Duplicate GPT-5.5 Alias',
},
]);
expect(result.models).toEqual([
expect.objectContaining({
id: 'catalog-alias',
launchModel: 'gpt-5.5',
displayName: 'GPT-5.5 Alias',
}),
]);
expect(result.diagnostics).toContain('model/list returned duplicate launch model gpt-5.5.');
});
});

View file

@ -0,0 +1,61 @@
import type { CliProviderModelCatalogItem, CliProviderReasoningEffort } from '@shared/types';
const DEFAULT_CODEX_EFFORTS = ['low', 'medium', 'high', 'xhigh'] as const;
const MINI_CODEX_EFFORTS = ['medium', 'high'] as const;
function createFallbackModel(options: {
id: string;
displayName: string;
badgeLabel: string;
isDefault?: boolean;
efforts?: readonly CliProviderReasoningEffort[];
defaultEffort?: CliProviderReasoningEffort;
}): CliProviderModelCatalogItem {
const efforts = [...(options.efforts ?? DEFAULT_CODEX_EFFORTS)];
return {
id: options.id,
launchModel: options.id,
displayName: options.displayName,
hidden: false,
supportedReasoningEfforts: efforts,
defaultReasoningEffort: options.defaultEffort ?? 'medium',
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: options.isDefault === true,
upgrade: false,
source: 'static-fallback',
badgeLabel: options.badgeLabel,
};
}
export function createStaticCodexModelCatalogModels(): CliProviderModelCatalogItem[] {
return [
createFallbackModel({
id: 'gpt-5.4',
displayName: 'GPT-5.4',
badgeLabel: '5.4',
isDefault: true,
}),
createFallbackModel({
id: 'gpt-5.4-mini',
displayName: 'GPT-5.4 Mini',
badgeLabel: '5.4-mini',
}),
createFallbackModel({
id: 'gpt-5.3-codex',
displayName: 'GPT-5.3 Codex',
badgeLabel: '5.3-codex',
}),
createFallbackModel({
id: 'gpt-5.2',
displayName: 'GPT-5.2',
badgeLabel: '5.2',
}),
createFallbackModel({
id: 'gpt-5.1-codex-mini',
displayName: 'GPT-5.1 Codex Mini',
badgeLabel: '5.1-codex-mini',
efforts: MINI_CODEX_EFFORTS,
}),
];
}

View file

@ -0,0 +1,24 @@
import type { CliProviderReasoningEffort } from '@shared/types';
export const CODEX_REASONING_EFFORTS = [
'minimal',
'low',
'medium',
'high',
'xhigh',
] as const satisfies readonly CliProviderReasoningEffort[];
const CODEX_REASONING_EFFORT_SET = new Set<string>(CODEX_REASONING_EFFORTS);
export function isCodexReasoningEffort(value: unknown): value is CliProviderReasoningEffort {
return typeof value === 'string' && CODEX_REASONING_EFFORT_SET.has(value);
}
export function normalizeCodexReasoningEffort(value: unknown): CliProviderReasoningEffort | null {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim().toLowerCase();
return isCodexReasoningEffort(normalized) ? normalized : null;
}

View file

@ -0,0 +1,163 @@
import { CODEX_REASONING_EFFORTS, normalizeCodexReasoningEffort } from './codexReasoningEffort';
import type { CliProviderModelCatalogItem, CliProviderReasoningEffort } from '@shared/types';
export interface CodexAppServerModelLike {
id?: string;
model?: string;
displayName?: string;
hidden?: boolean;
supportedReasoningEfforts?: unknown[];
defaultReasoningEffort?: unknown;
inputModalities?: unknown;
supportsPersonality?: boolean;
isDefault?: boolean;
upgrade?: unknown;
}
export interface NormalizedCodexModelCatalogResult {
models: CliProviderModelCatalogItem[];
defaultModelId: string | null;
diagnostics: string[];
}
function normalizeModelId(model: CodexAppServerModelLike): string | null {
const id = model.id?.trim() || model.model?.trim() || null;
return id && id.length > 0 ? id : null;
}
function normalizeEffortOption(option: unknown): CliProviderReasoningEffort | null {
if (typeof option === 'string') {
return normalizeCodexReasoningEffort(option);
}
if (option && typeof option === 'object' && 'reasoningEffort' in option) {
return normalizeCodexReasoningEffort((option as { reasoningEffort?: unknown }).reasoningEffort);
}
return null;
}
function normalizeEfforts(model: CodexAppServerModelLike): CliProviderReasoningEffort[] {
const efforts = model.supportedReasoningEfforts?.flatMap((option) => {
const normalized = normalizeEffortOption(option);
return normalized ? [normalized] : [];
});
if (!efforts || efforts.length === 0) {
return ['low', 'medium', 'high'];
}
return CODEX_REASONING_EFFORTS.filter((effort) => efforts.includes(effort));
}
function normalizeDefaultEffort(
defaultEffort: unknown,
supportedEfforts: readonly CliProviderReasoningEffort[]
): CliProviderReasoningEffort | null {
const normalized = normalizeCodexReasoningEffort(defaultEffort);
if (!normalized) {
return supportedEfforts.includes('medium') ? 'medium' : (supportedEfforts[0] ?? null);
}
return supportedEfforts.includes(normalized)
? normalized
: supportedEfforts.includes('medium')
? 'medium'
: (supportedEfforts[0] ?? null);
}
function normalizeModalities(value: unknown): string[] {
if (!Array.isArray(value)) {
return ['text', 'image'];
}
const seen = new Set<string>();
const modalities: string[] = [];
for (const item of value) {
if (typeof item !== 'string') {
continue;
}
const normalized = item.trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
modalities.push(normalized);
}
return modalities.length > 0 ? modalities : ['text', 'image'];
}
function asBadgeLabel(modelId: string): string {
return modelId.replace(/^gpt-/, '');
}
export function normalizeCodexAppServerModels(
models: readonly CodexAppServerModelLike[] | undefined,
options: {
includeHidden?: boolean;
} = {}
): NormalizedCodexModelCatalogResult {
const diagnostics: string[] = [];
const seen = new Set<string>();
const seenLaunchModels = new Set<string>();
const normalizedModels: CliProviderModelCatalogItem[] = [];
for (const model of models ?? []) {
const id = normalizeModelId(model);
if (!id) {
diagnostics.push('model/list returned a model without id/model.');
continue;
}
if (seen.has(id)) {
diagnostics.push(`model/list returned duplicate model id ${id}.`);
continue;
}
seen.add(id);
const hidden = model.hidden === true;
if (hidden && options.includeHidden !== true) {
continue;
}
const launchModel = model.model?.trim() || id;
if (seenLaunchModels.has(launchModel)) {
diagnostics.push(`model/list returned duplicate launch model ${launchModel}.`);
continue;
}
seenLaunchModels.add(launchModel);
const supportedReasoningEfforts = normalizeEfforts(model);
normalizedModels.push({
id,
launchModel,
displayName: model.displayName?.trim() || id,
hidden,
supportedReasoningEfforts,
defaultReasoningEffort: normalizeDefaultEffort(
model.defaultReasoningEffort,
supportedReasoningEfforts
),
inputModalities: normalizeModalities(model.inputModalities),
supportsPersonality: model.supportsPersonality === true,
isDefault: model.isDefault === true,
upgrade: Boolean(model.upgrade),
source: 'app-server',
badgeLabel: asBadgeLabel(id),
});
}
const defaultModel =
normalizedModels.find((model) => model.isDefault) ??
normalizedModels.find((model) => !model.hidden) ??
normalizedModels[0] ??
null;
return {
models: normalizedModels,
defaultModelId: defaultModel?.id ?? null,
diagnostics,
};
}

View file

@ -0,0 +1,9 @@
export type {
CodexModelCatalogDto,
CodexModelCatalogItemDto,
CodexModelCatalogSourceDto,
CodexModelCatalogStatusDto,
CodexModelReasoningEffortDto,
} from './contracts';
export type { CodexModelCatalogFeatureFacade, CodexModelCatalogRequest } from './main';
export { createCodexModelCatalogFeature } from './main';

View file

@ -0,0 +1,357 @@
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';
import {
CodexAppServerSessionFactory,
CodexBinaryResolver,
JsonRpcRequestError,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
import { CodexModelCatalogAppServerClient } from '../infrastructure/CodexModelCatalogAppServerClient';
import { InMemoryCodexModelCatalogCache } from '../infrastructure/InMemoryCodexModelCatalogCache';
import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts';
import type { Logger } from '@shared/utils/logger';
type LoggerPort = Pick<Logger, 'warn'>;
const CATALOG_CACHE_TTL_MS = 10 * 60_000;
const CATALOG_STALE_TTL_MS = 24 * 60 * 60_000;
const HASH_SALT = randomBytes(16).toString('hex');
export interface CodexModelCatalogRequest {
cwd?: string | null;
profile?: string | null;
includeHidden?: boolean;
forceRefresh?: boolean;
}
export interface CodexModelCatalogFeatureFacade {
getCatalog(options?: CodexModelCatalogRequest): Promise<CodexModelCatalogDto>;
invalidate(): void;
dispose(): Promise<void>;
}
function nowIso(): string {
return new Date().toISOString();
}
function staleAtIso(): string {
return new Date(Date.now() + CATALOG_CACHE_TTL_MS).toISOString();
}
function hashValue(value: unknown): string {
return createHash('sha256')
.update(HASH_SALT)
.update(JSON.stringify(value ?? null))
.digest('hex')
.slice(0, 16);
}
function classifyAppServerFailure(error: unknown): {
appServerState: CodexModelCatalogDto['diagnostics']['appServerState'];
message: string;
code: string | null;
} {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
const rpcCode =
error instanceof JsonRpcRequestError && error.code !== null ? String(error.code) : null;
if (
lower.includes('unknown method') ||
lower.includes('method not found') ||
lower.includes('unknown command') ||
lower.includes('no such command') ||
rpcCode === '-32601'
) {
return {
appServerState: 'incompatible',
message: 'The installed Codex binary does not support app-server model/list yet.',
code: rpcCode ?? 'method-not-found',
};
}
return {
appServerState: 'degraded',
message,
code: rpcCode,
};
}
function createCacheKey(options: {
binaryPath: string | null;
binaryVersion: string | null;
accountSnapshot: CodexAccountSnapshotDto;
cwd?: string | null;
profile?: string | null;
configFingerprint?: string | null;
includeHidden?: boolean;
}): string {
return hashValue({
binaryPath: options.binaryPath,
binaryVersion: options.binaryVersion,
preferredAuthMode: options.accountSnapshot.preferredAuthMode,
effectiveAuthMode: options.accountSnapshot.effectiveAuthMode,
managedAccount: options.accountSnapshot.managedAccount
? {
type: options.accountSnapshot.managedAccount.type,
planType: options.accountSnapshot.managedAccount.planType,
emailHash: hashValue(options.accountSnapshot.managedAccount.email),
}
: null,
apiKeySource: options.accountSnapshot.apiKey.source,
cwd: options.cwd?.trim() || null,
profile: options.profile?.trim() || null,
configFingerprint: options.configFingerprint ?? null,
includeHidden: options.includeHidden === true,
codexHome: process.env.CODEX_HOME?.trim() || null,
});
}
function setCatalogCacheEntries(
cache: InMemoryCodexModelCatalogCache,
keys: readonly string[],
catalog: CodexModelCatalogDto
): void {
const seen = new Set<string>();
for (const key of keys) {
if (seen.has(key)) {
continue;
}
seen.add(key);
cache.set(key, catalog);
}
}
function createFallbackCatalog(options: {
sourceMessage: string;
appServerState: CodexModelCatalogDto['diagnostics']['appServerState'];
status?: CodexModelCatalogDto['status'];
code?: string | null;
}): CodexModelCatalogDto {
const models = createStaticCodexModelCatalogModels();
const defaultModel = models.find((model) => model.isDefault) ?? models[0] ?? null;
return {
schemaVersion: 1,
providerId: 'codex',
source: 'static-fallback',
status: options.status ?? 'degraded',
fetchedAt: nowIso(),
staleAt: staleAtIso(),
defaultModelId: defaultModel?.id ?? null,
defaultLaunchModel: defaultModel?.launchModel ?? null,
models,
diagnostics: {
configReadState: 'skipped',
appServerState: options.appServerState,
message: options.sourceMessage,
code: options.code ?? null,
},
};
}
function markCatalogStale(
catalog: CodexModelCatalogDto,
diagnostics: CodexModelCatalogDto['diagnostics']
): CodexModelCatalogDto {
return {
...catalog,
status: 'stale',
diagnostics,
};
}
export function createCodexModelCatalogFeature(options: {
logger: LoggerPort;
codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'>;
}): CodexModelCatalogFeatureFacade {
const envBuilder = new CodexAccountEnvBuilder();
const cache = new InMemoryCodexModelCatalogCache();
const inFlightRefreshes = new Map<string, Promise<CodexModelCatalogDto>>();
let cacheGeneration = 0;
const client = new CodexModelCatalogAppServerClient(
new CodexAppServerSessionFactory(new JsonRpcStdioClient(options.logger))
);
async function getCatalog(request: CodexModelCatalogRequest = {}): Promise<CodexModelCatalogDto> {
const accountSnapshot = await options.codexAccountFeature.getSnapshot();
const binaryPath = await CodexBinaryResolver.resolve();
const binaryVersion = await CodexBinaryResolver.resolveVersion(binaryPath);
if (!binaryPath) {
return createFallbackCatalog({
sourceMessage: 'Codex CLI was not found. Showing static fallback model list.',
appServerState: 'runtime-missing',
status: 'unavailable',
});
}
const env = envBuilder.buildControlPlaneEnv({ binaryPath });
const preflightCacheKey = createCacheKey({
binaryPath,
binaryVersion,
accountSnapshot,
cwd: request.cwd,
profile: request.profile,
configFingerprint: null,
includeHidden: request.includeHidden,
});
if (request.forceRefresh !== true) {
const cached = cache.get(preflightCacheKey, CATALOG_CACHE_TTL_MS);
if (cached) {
return cached;
}
}
const existingRefresh = inFlightRefreshes.get(preflightCacheKey);
if (existingRefresh) {
return existingRefresh;
}
const refreshGeneration = cacheGeneration;
const refreshPromise = (async (): Promise<CodexModelCatalogDto> => {
let configFingerprint: string | null = null;
let configReadState: CodexModelCatalogDto['diagnostics']['configReadState'] = 'skipped';
let configReadMessage: string | null = null;
let cacheKey = preflightCacheKey;
try {
const payload = await client.readModelCatalogWithConfig({
binaryPath,
env,
includeHidden: request.includeHidden,
cwd: request.cwd,
profile: request.profile,
});
if (payload.config.ok) {
configReadState = 'ready';
configFingerprint = hashValue(payload.config.value);
} else {
configReadState =
payload.config.error instanceof JsonRpcRequestError &&
payload.config.error.code === -32601
? 'unsupported'
: 'failed';
configReadMessage =
payload.config.error instanceof Error
? payload.config.error.message
: String(payload.config.error);
}
cacheKey = createCacheKey({
binaryPath,
binaryVersion,
accountSnapshot,
cwd: request.cwd,
profile: request.profile,
configFingerprint,
includeHidden: request.includeHidden,
});
const normalized = normalizeCodexAppServerModels(
payload.modelCatalog.models ?? payload.modelCatalog.data,
{
includeHidden: request.includeHidden,
}
);
const defaultModel =
normalized.models.find((model) => model.id === normalized.defaultModelId) ??
normalized.models.find((model) => model.isDefault) ??
normalized.models[0] ??
null;
const diagnostics = [
...normalized.diagnostics,
configReadMessage ? `config/read: ${configReadMessage}` : null,
payload.modelCatalog.truncated
? 'model/list pagination reached the safety page limit; some Codex models may be omitted.'
: null,
].filter(Boolean);
const catalog: CodexModelCatalogDto = {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: nowIso(),
staleAt: staleAtIso(),
defaultModelId: defaultModel?.id ?? null,
defaultLaunchModel: defaultModel?.launchModel ?? null,
models: normalized.models,
diagnostics: {
configReadState,
appServerState: 'healthy',
message: diagnostics.length > 0 ? diagnostics.join(' ') : null,
code: null,
},
};
if (normalized.models.length === 0) {
throw new Error('Codex app-server model/list returned no visible models.');
}
if (refreshGeneration === cacheGeneration) {
setCatalogCacheEntries(cache, [preflightCacheKey, cacheKey], catalog);
}
return catalog;
} catch (error) {
const failure = classifyAppServerFailure(error);
const stale =
cache.getLatest(cacheKey) ??
(cacheKey === preflightCacheKey ? null : cache.getLatest(preflightCacheKey));
if (stale && Date.parse(stale.fetchedAt) + CATALOG_STALE_TTL_MS > Date.now()) {
return markCatalogStale(stale, {
configReadState,
appServerState: failure.appServerState,
message: failure.message,
code: failure.code,
});
}
options.logger.warn('codex model catalog refresh failed', {
error: failure.message,
code: failure.code,
});
const fallback = createFallbackCatalog({
sourceMessage: failure.message,
appServerState: failure.appServerState,
code: failure.code,
});
if (refreshGeneration === cacheGeneration) {
setCatalogCacheEntries(cache, [preflightCacheKey, cacheKey], fallback);
}
return fallback;
}
})();
inFlightRefreshes.set(preflightCacheKey, refreshPromise);
try {
return await refreshPromise;
} finally {
if (inFlightRefreshes.get(preflightCacheKey) === refreshPromise) {
inFlightRefreshes.delete(preflightCacheKey);
}
}
}
return {
getCatalog,
invalidate: () => {
cacheGeneration += 1;
cache.clear();
inFlightRefreshes.clear();
},
dispose: async () => {
cacheGeneration += 1;
cache.clear();
inFlightRefreshes.clear();
},
};
}

View file

@ -0,0 +1,5 @@
export type {
CodexModelCatalogFeatureFacade,
CodexModelCatalogRequest,
} from './composition/createCodexModelCatalogFeature';
export { createCodexModelCatalogFeature } from './composition/createCodexModelCatalogFeature';

View file

@ -0,0 +1,159 @@
import type {
CodexAppServerListModelsParams,
CodexAppServerListModelsResponse,
CodexAppServerReadConfigParams,
CodexAppServerReadConfigResponse,
CodexAppServerSession,
CodexAppServerSessionFactory,
} from '@main/services/infrastructure/codexAppServer';
const MODEL_LIST_PAGE_LIMIT = 100;
const MODEL_LIST_MAX_PAGES = 5;
const MODEL_LIST_TIMEOUT_MS = 4_500;
const CONFIG_READ_TIMEOUT_MS = 3_500;
const INITIALIZE_TIMEOUT_MS = 6_000;
const TOTAL_TIMEOUT_MS = 9_000;
export class CodexModelCatalogAppServerClient {
constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {}
async readModelCatalogWithConfig(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
includeHidden?: boolean;
cwd?: string | null;
profile?: string | null;
}): Promise<{
modelCatalog: CodexAppServerListModelsResponse;
config: { ok: true; value: CodexAppServerReadConfigResponse } | { ok: false; error: unknown };
}> {
const configParams = this.buildConfigReadParams(options);
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: MODEL_LIST_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server model/list with config/read',
experimentalApi: false,
},
async (session) => {
const configPromise = session
.request<CodexAppServerReadConfigResponse>(
'config/read',
configParams,
CONFIG_READ_TIMEOUT_MS
)
.then((value) => ({ ok: true as const, value }))
.catch((error: unknown) => ({ ok: false as const, error }));
const modelCatalogPromise = this.readModelCatalogPages(session, {
includeHidden: options.includeHidden,
});
const [config, modelCatalog] = await Promise.all([configPromise, modelCatalogPromise]);
return {
config,
modelCatalog,
};
}
);
}
async readModelCatalog(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
includeHidden?: boolean;
}): Promise<CodexAppServerListModelsResponse> {
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: MODEL_LIST_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server model/list',
experimentalApi: false,
},
async (session) =>
this.readModelCatalogPages(session, {
includeHidden: options.includeHidden,
})
);
}
async readConfig(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
cwd?: string | null;
profile?: string | null;
}): Promise<CodexAppServerReadConfigResponse> {
const params = this.buildConfigReadParams(options);
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: CONFIG_READ_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server config/read',
experimentalApi: false,
},
async (session) =>
session.request<CodexAppServerReadConfigResponse>(
'config/read',
params,
CONFIG_READ_TIMEOUT_MS
)
);
}
private buildConfigReadParams(options: {
cwd?: string | null;
profile?: string | null;
}): CodexAppServerReadConfigParams {
const params: CodexAppServerReadConfigParams = {};
if (options.cwd?.trim()) {
params.cwd = options.cwd.trim();
}
if (options.profile?.trim()) {
params.profile = options.profile.trim();
}
return params;
}
private async readModelCatalogPages(
session: CodexAppServerSession,
options: { includeHidden?: boolean }
): Promise<CodexAppServerListModelsResponse> {
const data: NonNullable<CodexAppServerListModelsResponse['data']> = [];
let cursor: string | null = null;
let nextCursor: string | null = null;
for (let page = 0; page < MODEL_LIST_MAX_PAGES; page += 1) {
const payload: CodexAppServerListModelsResponse =
await session.request<CodexAppServerListModelsResponse>(
'model/list',
{
cursor,
limit: MODEL_LIST_PAGE_LIMIT,
includeHidden: options.includeHidden === true,
} satisfies CodexAppServerListModelsParams,
MODEL_LIST_TIMEOUT_MS
);
data.push(...(payload.data ?? payload.models ?? []));
nextCursor = payload.nextCursor ?? null;
if (!nextCursor) {
break;
}
cursor = nextCursor;
}
return {
data,
nextCursor,
truncated: nextCursor !== null,
};
}
}

View file

@ -0,0 +1,37 @@
import type { CodexModelCatalogDto } from '@features/codex-model-catalog/contracts';
interface CacheEntry {
value: CodexModelCatalogDto;
observedAt: number;
}
export class InMemoryCodexModelCatalogCache {
private readonly entries = new Map<string, CacheEntry>();
get(key: string, maxAgeMs: number): CodexModelCatalogDto | null {
const entry = this.entries.get(key);
if (!entry) {
return null;
}
if (Date.now() - entry.observedAt > maxAgeMs) {
return null;
}
return structuredClone(entry.value);
}
getLatest(key: string): CodexModelCatalogDto | null {
const entry = this.entries.get(key);
return entry ? structuredClone(entry.value) : null;
}
set(key: string, value: CodexModelCatalogDto): void {
this.entries.set(key, {
value: structuredClone(value),
observedAt: Date.now(),
});
}
clear(): void {
this.entries.clear();
}
}

View file

@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { CodexModelCatalogAppServerClient } from '../CodexModelCatalogAppServerClient';
import type {
CodexAppServerSession,
CodexAppServerSessionFactory,
} from '@main/services/infrastructure/codexAppServer';
describe('CodexModelCatalogAppServerClient', () => {
it('reads config and paginated model/list in one app-server session', async () => {
const requests: Array<{ method: string; params: unknown }> = [];
let sessionCount = 0;
const session: CodexAppServerSession = {
initializeResponse: {
userAgent: 'codex-cli 0.117.0',
codexHome: '/Users/me/.codex',
platformFamily: 'macos',
platformOs: 'darwin',
},
request: async <TResult>(method: string, params?: unknown): Promise<TResult> => {
requests.push({ method, params });
if (method === 'config/read') {
return { config: { model: 'gpt-5.4' }, origins: {} } as TResult;
}
if (method === 'model/list') {
const cursor = (params as { cursor?: string | null }).cursor ?? null;
if (cursor === null) {
return {
data: [{ id: 'gpt-5.4', model: 'gpt-5.4' }],
nextCursor: 'page-2',
} as TResult;
}
return {
data: [{ id: 'gpt-5.5', model: 'gpt-5.5' }],
nextCursor: null,
} as TResult;
}
throw new Error(`Unexpected method ${method}`);
},
notify: async () => undefined,
onNotification: () => () => undefined,
close: async () => undefined,
};
const factory = {
withSession: async <TResult>(
_options: unknown,
handler: (session: CodexAppServerSession) => Promise<TResult>
): Promise<TResult> => {
sessionCount += 1;
return handler(session);
},
} as unknown as CodexAppServerSessionFactory;
const client = new CodexModelCatalogAppServerClient(factory);
const result = await client.readModelCatalogWithConfig({
binaryPath: '/usr/local/bin/codex',
env: {},
cwd: '/repo',
profile: 'work',
});
expect(sessionCount).toBe(1);
expect(result.config).toEqual({
ok: true,
value: { config: { model: 'gpt-5.4' }, origins: {} },
});
expect(result.modelCatalog).toEqual({
data: [
{ id: 'gpt-5.4', model: 'gpt-5.4' },
{ id: 'gpt-5.5', model: 'gpt-5.5' },
],
nextCursor: null,
truncated: false,
});
expect(requests).toEqual([
{ method: 'config/read', params: { cwd: '/repo', profile: 'work' } },
{
method: 'model/list',
params: { cursor: null, limit: 100, includeHidden: false },
},
{
method: 'model/list',
params: { cursor: 'page-2', limit: 100, includeHidden: false },
},
]);
});
});

View file

@ -1,19 +1,22 @@
import { validateTeamName } from '@main/ipc/guards';
import { getErrorMessage } from '@shared/utils/errorHandling';
import {
formatEffortLevelListForProvider,
isTeamEffortLevelForProvider,
} from '@shared/utils/effortLevels';
import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isTeamProviderId } from '@shared/utils/teamProvider';
import { isAbsolute } from 'path';
import type { HttpServices } from './index';
import type { EffortLevel, TeamLaunchRequest } from '@shared/types/team';
import type { EffortLevel, TeamFastMode, TeamLaunchRequest } from '@shared/types/team';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:teams');
type LaunchBody = Omit<TeamLaunchRequest, 'teamName'>;
const EFFORT_LEVELS = new Set<EffortLevel>(['low', 'medium', 'high']);
class HttpBadRequestError extends Error {}
class HttpFeatureUnavailableError extends Error {}
@ -31,6 +34,9 @@ function getStatusCode(error: unknown, fallback: number = 500): number {
if (error instanceof HttpFeatureUnavailableError) {
return 501;
}
if (error instanceof Error && error.name === 'RuntimeStaleEvidenceError') {
return 409;
}
return fallback;
}
@ -76,30 +82,47 @@ function assertOptionalBoolean(value: unknown, fieldName: string): boolean | und
return value;
}
function assertOptionalEffort(value: unknown): EffortLevel | undefined {
function assertOptionalEffort(
value: unknown,
providerId: TeamLaunchRequest['providerId']
): EffortLevel | undefined {
if (value == null) {
return undefined;
}
if (typeof value !== 'string' || !EFFORT_LEVELS.has(value as EffortLevel)) {
throw new HttpBadRequestError('effort must be one of: low, medium, high');
if (!isTeamEffortLevelForProvider(value, providerId)) {
throw new HttpBadRequestError(
`effort must be one of: ${formatEffortLevelListForProvider(providerId)}`
);
}
return value as EffortLevel;
return value;
}
function assertOptionalFastMode(value: unknown): TeamFastMode | undefined {
if (value == null) {
return undefined;
}
if (value !== 'inherit' && value !== 'on' && value !== 'off') {
throw new HttpBadRequestError('fastMode must be one of: inherit, on, off');
}
return value;
}
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const providerId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: payload.providerId == null || payload.providerId === 'anthropic'
? 'anthropic'
: (() => {
throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini');
})();
payload.providerId == null
? 'anthropic'
: isTeamProviderId(payload.providerId)
? payload.providerId
: (() => {
throw new HttpBadRequestError(
'providerId must be anthropic, codex, gemini, or opencode'
);
})();
const prompt = assertOptionalString(payload.prompt, 'prompt');
const rawProviderBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId');
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
@ -109,7 +132,8 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
);
}
const model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort);
const effort = assertOptionalEffort(payload.effort, providerId);
const fastMode = assertOptionalFastMode(payload.fastMode);
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
const worktree = assertOptionalString(payload.worktree, 'worktree');
@ -131,6 +155,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
...(effort && {
effort,
}),
...(fastMode && {
fastMode,
}),
...(clearContext !== undefined && {
clearContext,
}),
@ -146,6 +173,18 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
};
}
function withRuntimeTeamName(teamName: string, body: unknown): Record<string, unknown> {
const payload =
body && typeof body === 'object' && !Array.isArray(body)
? (body as Record<string, unknown>)
: {};
const bodyTeamName = typeof payload.teamName === 'string' ? payload.teamName.trim() : '';
if (bodyTeamName && bodyTeamName !== teamName) {
throw new HttpBadRequestError('runtime body teamName must match route teamName');
}
return { ...payload, teamName };
}
export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void {
app.post<{ Params: { teamName: string }; Body: LaunchBody }>(
'/api/teams/:teamName/launch',
@ -260,4 +299,104 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
});
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
'/api/teams/:teamName/opencode/runtime/bootstrap-checkin',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
return reply.send(
await getTeamProvisioningService(services).recordOpenCodeRuntimeBootstrapCheckin(
withRuntimeTeamName(validatedTeamName.value!, request.body)
)
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/opencode/runtime/bootstrap-checkin:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
'/api/teams/:teamName/opencode/runtime/deliver-message',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
return reply.send(
await getTeamProvisioningService(services).deliverOpenCodeRuntimeMessage(
withRuntimeTeamName(validatedTeamName.value!, request.body)
)
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/opencode/runtime/deliver-message:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
'/api/teams/:teamName/opencode/runtime/task-event',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
return reply.send(
await getTeamProvisioningService(services).recordOpenCodeRuntimeTaskEvent(
withRuntimeTeamName(validatedTeamName.value!, request.body)
)
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/opencode/runtime/task-event:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
'/api/teams/:teamName/opencode/runtime/heartbeat',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
return reply.send(
await getTeamProvisioningService(services).recordOpenCodeRuntimeHeartbeat(
withRuntimeTeamName(validatedTeamName.value!, request.body)
)
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/opencode/runtime/heartbeat:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
}

View file

@ -20,17 +20,22 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
import './sentry';
import {
createCodexAccountFeature,
type CodexAccountFeatureFacade,
createCodexAccountFeature,
registerCodexAccountIpc,
removeCodexAccountIpc,
} from '@features/codex-account/main';
import {
type CodexModelCatalogFeatureFacade,
createCodexModelCatalogFeature,
} from '@features/codex-model-catalog/main';
import {
createRecentProjectsFeature,
type RecentProjectsFeatureFacade,
registerRecentProjectsIpc,
removeRecentProjectsIpc,
} from '@features/recent-projects/main';
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
import { SchedulerService } from '@main/services/schedule/SchedulerService';
@ -43,9 +48,11 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import { TeamBackupService } from '@main/services/team/TeamBackupService';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
import {
resolveAgentTeamsMcpLaunchSpec,
TeamMcpConfigBuilder,
} from '@main/services/team/TeamMcpConfigBuilder';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
import {
CONTEXT_CHANGED,
SCHEDULE_CHANGE,
@ -96,6 +103,19 @@ import {
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
import { HttpServer } from './services/infrastructure/HttpServer';
import { clearAutoResumeService } from './services/team/AutoResumeService';
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import {
createOpenCodeBridgeCommandLeaseStore,
createOpenCodeBridgeCommandLedgerStore,
} from './services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore';
import {
createOpenCodeBridgeClientIdentity,
OpenCodeBridgeCommandHandshakePort,
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
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,
clearTeamControlApiState,
@ -126,11 +146,14 @@ import {
BoardTaskExactLogsService,
BoardTaskLogStreamService,
BranchStatusService,
ClaudeBinaryResolver,
CliInstallerService,
configManager,
LocalFileSystemProvider,
MemberStatsComputer,
NotificationManager,
OpenCodeReadinessBridge,
OpenCodeTeamRuntimeAdapter,
PtyTerminalService,
ServiceContext,
ServiceContextRegistry,
@ -138,17 +161,19 @@ import {
TaskBoundaryParser,
TeamDataService,
TeamLogSourceTracker,
TeammateToolTracker,
TeamMemberLogsFinder,
TeamProvisioningService,
TeamRuntimeAdapterRegistry,
TeamTaskStallJournal,
TeamTaskStallMonitor,
TeamTaskStallNotifier,
TeamTaskStallPolicy,
TeamTaskStallSnapshotSource,
TeammateToolTracker,
TeamMemberLogsFinder,
TeamProvisioningService,
UpdaterService,
} from './services';
import type { OpenCodeTeamLaunchMode } from './services/team';
import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types';
@ -175,6 +200,83 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500;
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
const suppressedSources = new Set(['user_sent']);
function resolveOpenCodeTeamLaunchModeFromEnv(): OpenCodeTeamLaunchMode {
const raw = process.env.CLAUDE_TEAM_OPENCODE_LAUNCH_MODE?.trim().toLowerCase();
if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') {
return raw;
}
if (process.env.CLAUDE_TEAM_OPENCODE_DOGFOOD === '1') {
return 'dogfood';
}
return 'disabled';
}
async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapterRegistry> {
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved');
return new TeamRuntimeAdapterRegistry();
}
const bridgeEnv = { ...process.env };
try {
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
const mcpEntry = mcpLaunchSpec.args[0];
if (mcpEntry) {
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
}
} catch (error) {
logger.warn(
`[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${
error instanceof Error ? error.message : String(error)
}`
);
}
const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath,
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
env: bridgeEnv,
});
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const clientIdentity = createOpenCodeBridgeClientIdentity({
appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0',
gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null,
buildId: process.env.VITE_BUILD_ID ?? process.env.BUILD_ID ?? null,
});
const stateChangingCommands = new OpenCodeStateChangingBridgeCommandService({
expectedClientIdentity: clientIdentity,
handshakePort: new OpenCodeBridgeCommandHandshakePort({
bridge: bridgeClient,
clientIdentity,
}),
leaseStore: createOpenCodeBridgeCommandLeaseStore({
filePath: join(bridgeControlDir, 'command-leases.json'),
}),
ledger: createOpenCodeBridgeCommandLedgerStore({
filePath: join(bridgeControlDir, 'command-ledger.json'),
}),
bridge: bridgeClient,
manifestReader: new OpenCodeRuntimeManifestEvidenceReader({
teamsBasePath: getTeamsBasePath(),
}),
});
return new TeamRuntimeAdapterRegistry([
new OpenCodeTeamRuntimeAdapter(
new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands,
productionE2eEvidence: new OpenCodeProductionE2EEvidenceStore({
filePath: resolveOpenCodeProductionE2EEvidencePath({ bridgeControlDir }),
}),
}),
{
launchMode: resolveOpenCodeTeamLaunchModeFromEnv(),
}
),
]);
}
// --- Team display name cache (avoid listTeams() on every notification) ---
const TEAM_DISPLAY_NAME_TTL_MS = 30_000;
const teamDisplayNameCache = new Map<string, { value: string; expiresAt: number }>();
@ -422,6 +524,7 @@ let notificationManager: NotificationManager;
let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager;
let codexAccountFeature: CodexAccountFeatureFacade | null = null;
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
let recentProjectsFeature: RecentProjectsFeatureFacade;
let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService;
@ -833,6 +936,7 @@ async function initializeServices(): Promise<void> {
teamDataService = new TeamDataService();
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
teamProvisioningService = new TeamProvisioningService();
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
// Startup GC: remove stale MCP config files from previous sessions (best-effort)
void new TeamMcpConfigBuilder().gcStaleConfigs();
void teamDataService
@ -988,6 +1092,11 @@ async function initializeServices(): Promise<void> {
configManager,
});
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
codexModelCatalogFeature = createCodexModelCatalogFeature({
logger: createLogger('Feature:CodexModelCatalog'),
codexAccountFeature,
});
providerConnectionService.setCodexModelCatalogFeature(codexModelCatalogFeature);
// startProcessHealthPolling() is deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
@ -1185,7 +1294,10 @@ function shutdownServices(): void {
}
void skillsWatcherService?.stopAll();
providerConnectionService.setCodexModelCatalogFeature(null);
providerConnectionService.setCodexAccountFeature(null);
void codexModelCatalogFeature?.dispose();
codexModelCatalogFeature = null;
void codexAccountFeature?.dispose();
codexAccountFeature = null;

View file

@ -497,25 +497,37 @@ function validateProviderConnectionsSection(
const anthropicUpdate: Partial<ProviderConnectionsConfig['anthropic']> = {};
for (const [connectionKey, connectionValue] of Object.entries(value)) {
if (connectionKey !== 'authMode') {
if (connectionKey !== 'authMode' && connectionKey !== 'fastModeDefault') {
return {
valid: false,
error: `providerConnections.anthropic.${connectionKey} is not a valid setting`,
};
}
if (
connectionValue !== 'auto' &&
connectionValue !== 'oauth' &&
connectionValue !== 'api_key'
) {
if (connectionKey === 'authMode') {
if (
connectionValue !== 'auto' &&
connectionValue !== 'oauth' &&
connectionValue !== 'api_key'
) {
return {
valid: false,
error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key',
};
}
anthropicUpdate.authMode = connectionValue;
continue;
}
if (typeof connectionValue !== 'boolean') {
return {
valid: false,
error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key',
error: 'providerConnections.anthropic.fastModeDefault must be a boolean',
};
}
anthropicUpdate.authMode = connectionValue;
anthropicUpdate.fastModeDefault = connectionValue;
}
result.anthropic = anthropicUpdate as ProviderConnectionsConfig['anthropic'];

View file

@ -454,7 +454,8 @@ async function handleGetGitFileLog(
async function handleLoadDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string
scopeKey: string,
scopeToken: string | null = null
): Promise<
IpcResult<{
hunkDecisions: Record<string, HunkDecision>;
@ -462,19 +463,23 @@ async function handleLoadDecisions(
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null>
> {
return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey));
return wrapReviewHandler('loadDecisions', () =>
reviewDecisionStore.load(teamName, scopeKey, scopeToken ?? undefined)
);
}
async function handleSaveDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string,
scopeToken: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>,
hunkContextHashesByFile: Record<string, Record<number, string>> | null = null
): Promise<IpcResult<void>> {
return wrapReviewHandler('saveDecisions', () =>
reviewDecisionStore.save(teamName, scopeKey, {
scopeToken,
hunkDecisions,
fileDecisions,
hunkContextHashesByFile: hunkContextHashesByFile ?? undefined,
@ -485,7 +490,10 @@ async function handleSaveDecisions(
async function handleClearDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string
scopeKey: string,
scopeToken: string | null = null
): Promise<IpcResult<void>> {
return wrapReviewHandler('clearDecisions', () => reviewDecisionStore.clear(teamName, scopeKey));
return wrapReviewHandler('clearDecisions', () =>
reviewDecisionStore.clear(teamName, scopeKey, scopeToken ?? undefined)
);
}

View file

@ -91,8 +91,13 @@ import {
PROTECTED_CLI_FLAGS,
} from '@shared/utils/cliArgsParser';
import { createLogger } from '@shared/utils/logger';
import {
formatEffortLevelListForProvider,
isTeamEffortLevelForProvider,
} from '@shared/utils/effortLevels';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { isTeamProviderId } from '@shared/utils/teamProvider';
import {
buildStandaloneSlashCommandMeta,
parseStandaloneSlashCommand,
@ -173,6 +178,7 @@ import type {
SendMessageRequest,
SendMessageResult,
TaskAttachmentMeta,
TaskChangePresenceState,
TaskComment,
TaskRef,
TeamAgentRuntimeSnapshot,
@ -186,6 +192,7 @@ import type {
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamFastMode,
TeamProviderBackendId,
TeamProviderId,
TeamProvisioningPrepareResult,
@ -912,7 +919,7 @@ async function handleGetData(
async function handleGetTaskChangePresence(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<Record<string, 'has_changes' | 'no_changes' | 'unknown'>>> {
): Promise<IpcResult<Record<string, TaskChangePresenceState>>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
@ -1112,24 +1119,20 @@ function isProvisioningTeamName(teamName: string): boolean {
return parts.every((p) => /^[a-z0-9]+$/.test(p));
}
const VALID_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high'];
function isValidEffort(value: unknown): value is EffortLevel {
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
function isValidEffort(value: unknown, providerId?: TeamProviderId | null): value is EffortLevel {
return isTeamEffortLevelForProvider(value, providerId);
}
function parseOptionalMemberProviderId(
value: unknown
):
| { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined }
| { valid: false; error: string } {
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
}
if (value === 'anthropic' || value === 'codex' || value === 'gemini') {
if (isTeamProviderId(value)) {
return { valid: true, value };
}
return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' };
return { valid: false, error: 'member providerId must be anthropic, codex, gemini, or opencode' };
}
function parseOptionalProviderBackendId(
@ -1165,15 +1168,50 @@ function parseOptionalProviderBackendId(
}
function parseOptionalMemberEffort(
value: unknown
value: unknown,
providerId?: TeamProviderId | null
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
}
if (isValidEffort(value)) {
if (isValidEffort(value, providerId)) {
return { valid: true, value };
}
return { valid: false, error: 'member effort must be low, medium, or high' };
return {
valid: false,
error: `member effort must be one of ${formatEffortLevelListForProvider(providerId)}`,
};
}
function parseOptionalTeamEffort(
value: unknown,
providerId?: TeamProviderId | null
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
}
if (isValidEffort(value, providerId)) {
return { valid: true, value };
}
return {
valid: false,
error: `effort must be one of ${formatEffortLevelListForProvider(providerId)}`,
};
}
function parseOptionalTeamFastMode(
value: unknown
): { valid: true; value: TeamFastMode | undefined } | { valid: false; error: string } {
if (value === undefined || value === null || value === '') {
return { valid: true, value: undefined };
}
if (value === 'inherit' || value === 'on' || value === 'off') {
return { valid: true, value };
}
return {
valid: false,
error: 'fastMode must be one of inherit, on, or off',
};
}
async function validateProvisioningRequest(
@ -1202,6 +1240,15 @@ async function validateProvisioningRequest(
if (!Array.isArray(payload.members)) {
return { valid: false, error: 'members must be an array' };
}
const explicitProviderId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: payload.providerId === 'anthropic'
? 'anthropic'
: undefined;
const providerId = explicitProviderId ?? 'anthropic';
const seenNames = new Set<string>();
const members: TeamCreateRequest['members'] = [];
@ -1237,12 +1284,20 @@ async function validateProvisioningRequest(
if (model !== undefined && typeof model !== 'string') {
return { valid: false, error: 'member model must be string' };
}
const effortValidation = parseOptionalMemberEffort(
(member as { effort?: unknown }).effort,
providerValidation.value ?? providerId
);
if (!effortValidation.valid) {
return { valid: false, error: effortValidation.error };
}
members.push({
name: memberName,
role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
providerId: providerValidation.value,
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
});
}
@ -1257,12 +1312,6 @@ async function validateProvisioningRequest(
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { valid: false, error: 'prompt must be a string' };
}
const providerId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const providerBackendValidation = parseOptionalProviderBackendId(
payload.providerBackendId,
providerId
@ -1270,6 +1319,14 @@ async function validateProvisioningRequest(
if (!providerBackendValidation.valid) {
return { valid: false, error: providerBackendValidation.error };
}
const effortValidation = parseOptionalTeamEffort(payload.effort, providerId);
if (!effortValidation.valid) {
return { valid: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
if (!fastModeValidation.valid) {
return { valid: false, error: fastModeValidation.error };
}
try {
await fs.promises.mkdir(cwd, { recursive: true });
@ -1324,7 +1381,8 @@ async function validateProvisioningRequest(
providerId,
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
effort: effortValidation.value,
fastMode: fastModeValidation.value,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
worktree:
@ -1432,12 +1490,15 @@ async function handleLaunchTeam(
if (payload.model !== undefined && typeof payload.model !== 'string') {
return { success: false, error: 'model must be a string' };
}
const providerId =
const explicitProviderId =
payload.providerId === 'codex'
? 'codex'
: payload.providerId === 'gemini'
? 'gemini'
: 'anthropic';
: payload.providerId === 'anthropic'
? 'anthropic'
: undefined;
const providerId = explicitProviderId ?? 'anthropic';
const providerBackendValidation = parseOptionalProviderBackendId(
payload.providerBackendId,
providerId
@ -1474,6 +1535,14 @@ async function handleLaunchTeam(
: meta?.providerId === 'gemini'
? 'gemini'
: 'anthropic';
const effortValidation = parseOptionalTeamEffort(payload.effort, resolvedProviderId);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
const createRequest: TeamCreateRequest = {
teamName: tn,
@ -1488,7 +1557,8 @@ async function handleLaunchTeam(
providerBackendValidation.value ?? meta?.providerBackendId ?? membersMeta?.providerBackendId
),
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
effort: effortValidation.value,
fastMode: fastModeValidation.value ?? meta?.fastMode,
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
@ -1520,6 +1590,45 @@ async function handleLaunchTeam(
);
}
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
const launchProviderId = explicitProviderId ?? persistedMeta?.providerId ?? providerId;
const rawLaunchProviderBackendId =
payload.providerBackendId ??
persistedMeta?.providerBackendId ??
persistedMeta?.launchIdentity?.providerBackendId ??
undefined;
const launchProviderBackendValidation = parseOptionalProviderBackendId(
rawLaunchProviderBackendId,
launchProviderId
);
if (!launchProviderBackendValidation.valid) {
return { success: false, error: launchProviderBackendValidation.error };
}
const rawLaunchEffort =
payload.effort ??
persistedMeta?.effort ??
persistedMeta?.launchIdentity?.selectedEffort ??
undefined;
const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const rawLaunchFastMode =
payload.fastMode ??
persistedMeta?.fastMode ??
persistedMeta?.launchIdentity?.selectedFastMode ??
undefined;
const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
const rawLaunchModel =
typeof payload.model === 'string' && payload.model.trim().length > 0
? payload.model.trim()
: (persistedMeta?.model ?? persistedMeta?.launchIdentity?.selectedModel ?? undefined);
const launchLimitContext =
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
return wrapTeamHandler('launch', () => {
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
return getTeamProvisioningService().launchTeam(
@ -1527,10 +1636,12 @@ async function handleLaunchTeam(
teamName: validatedTeamName.value!,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
providerId,
providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
providerId: launchProviderId,
providerBackendId: launchProviderBackendValidation.value,
model: rawLaunchModel,
effort: effortValidation.value,
fastMode: fastModeValidation.value,
limitContext: launchLimitContext,
clearContext: payload.clearContext === true ? true : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
@ -1589,7 +1700,7 @@ async function handlePrepareProvisioning(
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
let validatedCwd: string | undefined;
let validatedProviderId: TeamLaunchRequest['providerId'];
let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined;
let validatedProviderIds: TeamProviderId[] | undefined;
let validatedSelectedModels: string[] | undefined;
let validatedLimitContext: boolean | undefined;
if (cwd !== undefined) {
@ -1602,8 +1713,8 @@ async function handlePrepareProvisioning(
}
}
if (providerId !== undefined) {
if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') {
return { success: false, error: 'providerId must be anthropic, codex, or gemini' };
if (!isTeamProviderId(providerId)) {
return { success: false, error: 'providerId must be anthropic, codex, gemini, or opencode' };
}
validatedProviderId = providerId;
}
@ -1611,10 +1722,13 @@ async function handlePrepareProvisioning(
if (!Array.isArray(providerIds)) {
return { success: false, error: 'providerIds must be an array when provided' };
}
const normalized: ('anthropic' | 'codex' | 'gemini')[] = [];
const normalized: TeamProviderId[] = [];
for (const entry of providerIds) {
if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') {
return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' };
if (!isTeamProviderId(entry)) {
return {
success: false,
error: 'providerIds entries must be anthropic, codex, gemini, or opencode',
};
}
if (!normalized.includes(entry)) {
normalized.push(entry);
@ -2617,6 +2731,10 @@ async function handleCreateConfig(
if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
const seenNames = new Set<string>();
const members: TeamCreateConfigRequest['members'] = [];
@ -2652,7 +2770,10 @@ async function handleCreateConfig(
if (model !== undefined && typeof model !== 'string') {
return { success: false, error: 'member model must be string' };
}
const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort);
const effortValidation = parseOptionalMemberEffort(
(member as { effort?: unknown }).effort,
providerValidation.value
);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
@ -2675,6 +2796,7 @@ async function handleCreateConfig(
members,
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
providerBackendId: providerBackendValidation.value,
fastMode: fastModeValidation.value,
})
);
}
@ -3090,7 +3212,10 @@ async function handleAddMember(
if (model !== undefined && typeof model !== 'string') {
return { success: false, error: 'model must be a string' };
}
const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort);
const effortValidation = parseOptionalMemberEffort(
(payload as { effort?: unknown }).effort,
providerValidation.value
);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
@ -3160,9 +3285,9 @@ async function handleReplaceMembers(
name: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerId?: TeamProviderId;
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: EffortLevel;
}[] = [];
for (const item of payload.members) {
if (!item || typeof item !== 'object') {
@ -3196,7 +3321,10 @@ async function handleReplaceMembers(
if (m.model !== undefined && typeof m.model !== 'string') {
return { success: false, error: 'member model must be string' };
}
const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort);
const effortValidation = parseOptionalMemberEffort(
(m as { effort?: unknown }).effort,
providerValidation.value
);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
@ -3971,6 +4099,7 @@ async function handleGetSavedRequest(
),
model: meta.model,
effort: meta.effort as TeamCreateRequest['effort'],
fastMode: meta.fastMode as TeamCreateRequest['fastMode'],
skipPermissions: meta.skipPermissions,
worktree: meta.worktree,
extraCliArgs: meta.extraCliArgs,

View file

@ -152,7 +152,12 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
providers: status.providers.map((provider) => ({
...provider,
modelVerificationState: provider.modelVerificationState ?? 'idle',
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
detailMessage: provider.detailMessage ?? null,
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
runtimeCapabilities: provider.runtimeCapabilities
? structuredClone(provider.runtimeCapabilities)
: null,
capabilities: {
...provider.capabilities,
extensions: {
@ -759,15 +764,18 @@ export class CliInstallerService {
return null;
}
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
binaryPath,
providerId
);
const nextProviderStatus = this.applyProviderModelAvailabilityToProvider(
binaryPath,
versionProbe.version,
providerStatus
);
const providerStatus =
providerId === 'opencode'
? await this.multimodelBridgeService.verifyProviderStatus(binaryPath, providerId)
: await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
const nextProviderStatus =
providerId === 'opencode'
? await this.multimodelBridgeService.verifyOpenCodeModels(binaryPath, providerStatus)
: this.applyProviderModelAvailabilityToProvider(
binaryPath,
versionProbe.version,
providerStatus
);
this.updateLatestProviderStatus(nextProviderStatus);
if (this.latestStatusSnapshot) {
this.publishStatusSnapshot(this.latestStatusSnapshot);

View file

@ -237,6 +237,7 @@ export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key';
export interface ProviderConnectionsConfig {
anthropic: {
authMode: ProviderConnectionAuthMode;
fastModeDefault: boolean;
};
codex: {
preferredAuthMode: CodexAccountAuthMode;
@ -333,6 +334,7 @@ const DEFAULT_CONFIG: AppConfig = {
providerConnections: {
anthropic: {
authMode: 'auto',
fastModeDefault: false,
},
codex: {
preferredAuthMode: 'auto',

View file

@ -18,6 +18,7 @@ import {
getTeamsBasePath,
getTodosBasePath,
} from '@main/utils/pathDecoder';
import { OPENCODE_TASK_LOG_ATTRIBUTION_FILE } from '@shared/constants/opencodeTaskLogAttribution';
import { createLogger } from '@shared/utils/logger';
import { EventEmitter } from 'events';
import * as fs from 'fs';
@ -1007,6 +1008,16 @@ export class FileWatcher extends EventEmitter {
detail: relative,
};
this.emit('team-change', event);
return;
}
if (relative === OPENCODE_TASK_LOG_ATTRIBUTION_FILE) {
const event: TeamChangeEvent = {
type: 'log-source-change',
teamName,
detail: relative,
};
this.emit('team-change', event);
}
}

View file

@ -2,11 +2,15 @@ import { constants as fsConstants } from 'node:fs';
import * as fsp from 'node:fs/promises';
import path from 'node:path';
import { execCli } from '@main/utils/childProcess';
const CACHE_VERIFY_TTL_MS = 30_000;
const VERSION_CACHE_TTL_MS = 30_000;
let cachedBinaryPath: string | null | undefined;
let cacheVerifiedAt = 0;
let resolveInFlight: Promise<string | null> | null = null;
const versionCache = new Map<string, { version: string | null; observedAt: number }>();
async function fileExists(filePath: string): Promise<boolean> {
try {
@ -69,6 +73,7 @@ export class CodexBinaryResolver {
cachedBinaryPath = undefined;
cacheVerifiedAt = 0;
resolveInFlight = null;
versionCache.clear();
}
static async resolve(): Promise<string | null> {
@ -117,4 +122,34 @@ export class CodexBinaryResolver {
cacheVerifiedAt = Date.now();
return null;
}
static async resolveVersion(binaryPath: string | null | undefined): Promise<string | null> {
const normalizedPath = binaryPath?.trim();
if (!normalizedPath) {
return null;
}
const cached = versionCache.get(normalizedPath);
if (cached && Date.now() - cached.observedAt <= VERSION_CACHE_TTL_MS) {
return cached.version;
}
try {
const result = await execCli(normalizedPath, ['--version'], {
timeout: 3_000,
});
const version = result.stdout.trim().split(/\s+/).filter(Boolean).at(-1) ?? null;
versionCache.set(normalizedPath, {
version,
observedAt: Date.now(),
});
return version;
} catch {
versionCache.set(normalizedPath, {
version: null,
observedAt: Date.now(),
});
return null;
}
}
}

View file

@ -10,6 +10,7 @@ interface JsonRpcLogger {
interface JsonRpcErrorPayload {
code?: number;
message?: string;
data?: unknown;
}
interface JsonRpcResponse<T> {
@ -49,6 +50,22 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
export class JsonRpcRequestError extends Error {
readonly code: number | null;
readonly data: unknown;
readonly details: unknown;
readonly method: string;
constructor(method: string, payload: JsonRpcErrorPayload) {
super(payload.message ?? 'Unknown JSON-RPC error');
this.name = 'JsonRpcRequestError';
this.method = method;
this.code = typeof payload.code === 'number' ? payload.code : null;
this.data = payload.data;
this.details = payload.data;
}
}
export class JsonRpcStdioClient {
constructor(private readonly logger: JsonRpcLogger) {}
@ -93,6 +110,7 @@ export class JsonRpcStdioClient {
const pending = new Map<
number,
{
method: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
@ -149,7 +167,7 @@ export class JsonRpcStdioClient {
pending.delete(message.id);
if (message.error) {
entry.reject(new Error(message.error.message ?? 'Unknown JSON-RPC error'));
entry.reject(new JsonRpcRequestError(entry.method, message.error));
return;
}
@ -222,17 +240,25 @@ export class JsonRpcStdioClient {
reject(new Error(`JSON-RPC request timed out: ${method}`));
}, timeoutMs);
pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timeoutId });
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => {
if (!error) {
return;
}
clearTimeout(timeoutId);
pending.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
pending.set(id, {
method,
resolve: resolve as (value: unknown) => void,
reject,
timeoutId,
});
child.stdin.write(
`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`,
(error) => {
if (!error) {
return;
}
clearTimeout(timeoutId);
pending.delete(id);
reject(error instanceof Error ? error : new Error(String(error)));
}
);
}),
notify: async (method: string, params?: unknown): Promise<void> => {
@ -241,7 +267,7 @@ export class JsonRpcStdioClient {
}
await new Promise<void>((resolve, reject) => {
child.stdin!.write(`${JSON.stringify({ method, params })}\n`, (error) => {
child.stdin!.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`, (error) => {
if (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;

View file

@ -0,0 +1,79 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
const tempDirs: string[] = [];
function createStrictJsonRpcServerScript(): 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');
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
const message = JSON.parse(line);
if (message.jsonrpc !== '2.0') {
return;
}
if (message.method === 'fail') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: message.id,
error: { code: -32601, message: 'No such method', data: { method: message.method } },
}) + '\\n');
return;
}
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: { ok: true, params: message.params },
}) + '\\n');
});
`,
'utf8'
);
return scriptPath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('JsonRpcStdioClient', () => {
it('sends JSON-RPC 2.0 framed requests and preserves structured errors', async () => {
const scriptPath = createStrictJsonRpcServerScript();
const client = new JsonRpcStdioClient({ warn: () => undefined });
await client.withSession(
{
binaryPath: process.execPath,
args: [scriptPath],
label: 'strict json-rpc smoke',
requestTimeoutMs: 1_000,
totalTimeoutMs: 2_000,
},
async (session) => {
await expect(session.request('ping', { value: 1 })).resolves.toEqual({
ok: true,
params: { value: 1 },
});
await expect(session.request('fail')).rejects.toMatchObject({
method: 'fail',
code: -32601,
data: { method: 'fail' },
details: { method: 'fail' },
});
}
);
});
});

View file

@ -5,7 +5,7 @@ export {
} from './CodexAppServerSessionFactory';
export { CodexBinaryResolver } from './CodexBinaryResolver';
export type { JsonRpcSession } from './JsonRpcStdioClient';
export { JsonRpcStdioClient } from './JsonRpcStdioClient';
export { JsonRpcRequestError, JsonRpcStdioClient } from './JsonRpcStdioClient';
export type {
CodexAppServerAccount,
CodexAppServerAccountLoginCompletedNotification,
@ -20,10 +20,17 @@ export type {
CodexAppServerGetAccountRateLimitsResponse,
CodexAppServerGetAccountResponse,
CodexAppServerInitializeResponse,
CodexAppServerListModelsParams,
CodexAppServerListModelsResponse,
CodexAppServerLoginAccountParams,
CodexAppServerLoginAccountResponse,
CodexAppServerLogoutAccountResponse,
CodexAppServerModel,
CodexAppServerPlanType,
CodexAppServerRateLimitSnapshot,
CodexAppServerRateLimitWindow,
CodexAppServerReadConfigParams,
CodexAppServerReadConfigResponse,
CodexAppServerReasoningEffort,
CodexAppServerReasoningEffortOption,
} from './protocol';

View file

@ -111,3 +111,53 @@ export type CodexAppServerCancelLoginAccountStatus = 'canceled' | 'notFound';
export interface CodexAppServerCancelLoginAccountResponse {
status: CodexAppServerCancelLoginAccountStatus;
}
export type CodexAppServerReasoningEffort =
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh';
export interface CodexAppServerReasoningEffortOption {
reasoningEffort?: string;
description?: string | null;
}
export interface CodexAppServerModel {
id?: string;
model?: string;
displayName?: string;
hidden?: boolean;
supportedReasoningEfforts?: (string | CodexAppServerReasoningEffortOption)[];
defaultReasoningEffort?: string | null;
inputModalities?: string[] | null;
supportsPersonality?: boolean;
isDefault?: boolean;
upgrade?: boolean | string | null;
upgradeInfo?: unknown;
}
export interface CodexAppServerListModelsParams {
cursor?: string | null;
limit?: number | null;
includeHidden?: boolean;
}
export interface CodexAppServerListModelsResponse {
data?: CodexAppServerModel[];
models?: CodexAppServerModel[];
nextCursor?: string | null;
truncated?: boolean;
}
export interface CodexAppServerReadConfigParams {
cwd?: string;
profile?: string;
}
export interface CodexAppServerReadConfigResponse {
config?: Record<string, unknown>;
origins?: Record<string, unknown>;
}

View file

@ -1,6 +1,7 @@
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,
@ -10,12 +11,18 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
import { providerConnectionService } from './ProviderConnectionService';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
import type {
CliProviderId,
CliProviderModelAvailability,
CliProviderReasoningEffort,
CliProviderStatus,
} from '@shared/types';
const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
const OPENCODE_MODEL_VERIFY_TIMEOUT_MS = 60_000;
interface RuntimeExtensionCapabilityResponse {
status?: 'supported' | 'read-only' | 'unsupported';
@ -30,6 +37,59 @@ interface RuntimeExtensionCapabilitiesResponse {
apiKeys?: RuntimeExtensionCapabilityResponse;
}
interface RuntimeProviderCapabilitiesResponse {
modelCatalog?: {
dynamic?: boolean;
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback' | 'runtime';
};
reasoningEffort?: {
supported?: boolean;
values?: string[];
configPassthrough?: boolean;
};
fastMode?: {
supported?: boolean;
available?: boolean;
reason?: string | null;
source?: 'runtime';
};
}
interface RuntimeProviderModelCatalogItemResponse {
id?: string;
launchModel?: string;
displayName?: string;
hidden?: boolean;
supportedReasoningEfforts?: string[];
defaultReasoningEffort?: string | null;
supportsFastMode?: boolean;
inputModalities?: string[];
supportsPersonality?: boolean;
isDefault?: boolean;
upgrade?: boolean;
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
badgeLabel?: string | null;
statusMessage?: string | null;
}
interface RuntimeProviderModelCatalogResponse {
schemaVersion?: number;
providerId?: CliProviderId;
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
status?: 'ready' | 'stale' | 'degraded' | 'unavailable';
fetchedAt?: string;
staleAt?: string;
defaultModelId?: string | null;
defaultLaunchModel?: string | null;
models?: RuntimeProviderModelCatalogItemResponse[];
diagnostics?: {
configReadState?: 'ready' | 'unsupported' | 'failed' | 'skipped';
appServerState?: 'healthy' | 'degraded' | 'runtime-missing' | 'incompatible';
message?: string | null;
code?: string | null;
};
}
interface ProviderStatusCommandResponse {
schemaVersion?: number;
providers?: Record<
@ -41,6 +101,7 @@ interface ProviderStatusCommandResponse {
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
canLoginFromUi?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
@ -53,6 +114,7 @@ interface ProviderStatusCommandResponse {
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
}
>;
}
@ -107,6 +169,7 @@ interface UnifiedRuntimeStatusResponse {
detailMessage?: string | null;
}[];
models?: (string | { id?: string; label?: string; description?: string })[];
modelCatalog?: RuntimeProviderModelCatalogResponse | null;
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
@ -119,11 +182,146 @@ interface UnifiedRuntimeStatusResponse {
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
}
>;
}
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
interface OpenCodeRuntimeVerifyResponse {
schemaVersion?: number;
providerId?: 'opencode';
snapshot?: {
detected?: boolean;
hostHealthy?: boolean;
probeError?: string | null;
diagnostics?: string[];
host?: {
version?: string | null;
resolvedConfigFingerprint?: string | null;
} | null;
profile?: {
profileRootKey?: string;
projectBehaviorFingerprint?: string;
managedConfigFingerprint?: string;
} | null;
config?: {
default_agent?: string;
share?: string | null;
snapshot?: boolean;
autoupdate?: boolean | string;
} | null;
} | null;
}
export interface OpenCodeRuntimeTranscriptResponse {
schemaVersion?: number;
providerId?: 'opencode';
transcript?: {
sessionId?: string;
durableState?: string;
staleReason?: string | null;
messageCount?: number;
toolCallCount?: number;
errorCount?: number;
latestAssistantText?: string | null;
latestAssistantPreview?: string | null;
messages?: unknown[];
diagnostics?: string[];
logProjection?: {
sessionId?: string;
durableState?: string;
sourceMessageCount?: number;
projectedMessageCount?: number;
syntheticMessageCount?: number;
toolCallCount?: number;
errorCount?: number;
diagnostics?: string[];
messages?: OpenCodeRuntimeTranscriptLogMessage[];
} | null;
} | null;
}
export type OpenCodeRuntimeTranscriptLogContentBlock =
| {
type: 'text';
text: string;
}
| {
type: 'thinking';
thinking: string;
signature: string;
}
| {
type: 'tool_use';
id: string;
name: string;
input: Record<string, unknown>;
}
| {
type: 'tool_result';
tool_use_id: string;
content: string | OpenCodeRuntimeTranscriptLogContentBlock[];
is_error?: boolean;
};
export interface OpenCodeRuntimeTranscriptLogToolCall {
id: string;
name: string;
input: Record<string, unknown>;
isTask: boolean;
taskDescription?: string;
taskSubagentType?: string;
}
export interface OpenCodeRuntimeTranscriptLogToolResult {
toolUseId: string;
content: string | OpenCodeRuntimeTranscriptLogContentBlock[];
isError: boolean;
}
export interface OpenCodeRuntimeTranscriptLogMessage {
uuid: string;
parentUuid: string | null;
type: 'assistant' | 'user' | 'system';
timestamp: string;
role?: string;
content: OpenCodeRuntimeTranscriptLogContentBlock[] | string;
model?: string;
agentName?: string;
isMeta: boolean;
sessionId: string;
toolCalls: OpenCodeRuntimeTranscriptLogToolCall[];
toolResults: OpenCodeRuntimeTranscriptLogToolResult[];
sourceToolUseID?: string;
sourceToolAssistantUUID?: string;
subtype?: string;
level?: string;
}
interface OpenCodeRuntimeVerifyModelResponse {
schemaVersion?: number;
providerId?: 'opencode';
result?: {
modelId?: string;
outcome?: 'available' | 'unavailable' | 'unknown';
reason?: string | null;
} | null;
}
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'];
function getProviderDisplayName(providerId: CliProviderId): string {
switch (providerId) {
case 'anthropic':
return 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode';
}
}
function extractJsonObject<T>(raw: string): T {
const trimmed = raw.trim();
@ -142,17 +340,17 @@ function extractJsonObject<T>(raw: string): T {
function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStatus {
return {
providerId,
displayName:
providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini',
displayName: getProviderDisplayName(providerId),
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
statusMessage: null,
detailMessage: null,
models: [],
modelAvailability: [],
canLoginFromUi: true,
canLoginFromUi: providerId !== 'opencode',
capabilities: {
teamLaunch: false,
oneShot: false,
@ -164,6 +362,8 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
modelCatalog: null,
runtimeCapabilities: null,
};
}
@ -220,6 +420,114 @@ function extractModelIds(
});
}
function normalizeRuntimeReasoningEffort(
value: string | null | undefined
): CliProviderReasoningEffort | null {
return value === 'none' ||
value === 'minimal' ||
value === 'low' ||
value === 'medium' ||
value === 'high' ||
value === 'xhigh' ||
value === 'max'
? value
: null;
}
function collectRuntimeReasoningEfforts(values?: string[]): CliProviderReasoningEffort[] {
return (
values?.flatMap((value) => {
const normalized = normalizeRuntimeReasoningEffort(value);
return normalized ? [normalized] : [];
}) ?? []
);
}
function mapRuntimeProviderModelCatalog(
providerId: CliProviderId,
modelCatalog?: RuntimeProviderModelCatalogResponse | null
): CliProviderStatus['modelCatalog'] {
if (modelCatalog?.providerId !== providerId) {
return null;
}
const fetchedAt = modelCatalog.fetchedAt?.trim();
const staleAt = modelCatalog.staleAt?.trim();
const source = modelCatalog.source;
const status = modelCatalog.status;
if (
modelCatalog.schemaVersion !== 1 ||
!fetchedAt ||
!staleAt ||
(source !== 'anthropic-models-api' &&
source !== 'app-server' &&
source !== 'static-fallback') ||
(status !== 'ready' && status !== 'stale' && status !== 'degraded' && status !== 'unavailable')
) {
return null;
}
const models: NonNullable<CliProviderStatus['modelCatalog']>['models'] =
modelCatalog.models?.flatMap((model) => {
const id = model.id?.trim();
const launchModel = model.launchModel?.trim();
const displayName = model.displayName?.trim();
if (!id || !launchModel || !displayName) {
return [];
}
const supportedReasoningEfforts = collectRuntimeReasoningEfforts(
model.supportedReasoningEfforts
);
const defaultReasoningEffort = normalizeRuntimeReasoningEffort(
model.defaultReasoningEffort ?? null
);
const itemSource =
model.source === 'anthropic-models-api' ||
model.source === 'app-server' ||
model.source === 'static-fallback'
? model.source
: source;
return [
{
id,
launchModel,
displayName,
hidden: model.hidden === true,
supportedReasoningEfforts,
defaultReasoningEffort,
supportsFastMode: model.supportsFastMode === true,
inputModalities: model.inputModalities?.filter((value) => value.trim().length > 0) ?? [],
supportsPersonality: model.supportsPersonality === true,
isDefault: model.isDefault === true,
upgrade: model.upgrade === true,
source: itemSource,
badgeLabel: model.badgeLabel ?? null,
statusMessage: model.statusMessage ?? null,
},
];
}) ?? [];
return {
schemaVersion: 1,
providerId,
source,
status,
fetchedAt,
staleAt,
defaultModelId: modelCatalog.defaultModelId ?? null,
defaultLaunchModel: modelCatalog.defaultLaunchModel ?? null,
models,
diagnostics: {
configReadState: modelCatalog.diagnostics?.configReadState ?? 'skipped',
appServerState: modelCatalog.diagnostics?.appServerState ?? 'degraded',
message: modelCatalog.diagnostics?.message ?? null,
code: modelCatalog.diagnostics?.code ?? null,
},
};
}
export class ClaudeMultimodelBridgeService {
private async buildCliEnv(
binaryPath: string
@ -262,6 +570,7 @@ export class ClaudeMultimodelBridgeService {
authMethod: runtimeStatus.authMethod ?? null,
verificationState: runtimeStatus.verificationState ?? 'unknown',
statusMessage: runtimeStatus.statusMessage ?? null,
detailMessage: runtimeStatus.detailMessage ?? null,
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
@ -292,6 +601,7 @@ export class ClaudeMultimodelBridgeService {
detailMessage: diagnostic.detailMessage ?? null,
})) ?? [],
models: extractModelIds(runtimeStatus.models),
modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog),
backend: runtimeStatus.backend?.kind
? {
kind: runtimeStatus.backend.kind,
@ -301,6 +611,34 @@ export class ClaudeMultimodelBridgeService {
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
}
: null,
runtimeCapabilities: runtimeStatus.runtimeCapabilities
? {
modelCatalog: runtimeStatus.runtimeCapabilities.modelCatalog
? {
dynamic: runtimeStatus.runtimeCapabilities.modelCatalog.dynamic === true,
source: runtimeStatus.runtimeCapabilities.modelCatalog.source,
}
: undefined,
reasoningEffort: runtimeStatus.runtimeCapabilities.reasoningEffort
? {
supported: runtimeStatus.runtimeCapabilities.reasoningEffort.supported === true,
values: collectRuntimeReasoningEfforts(
runtimeStatus.runtimeCapabilities.reasoningEffort.values
),
configPassthrough:
runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true,
}
: undefined,
fastMode: runtimeStatus.runtimeCapabilities.fastMode
? {
supported: runtimeStatus.runtimeCapabilities.fastMode.supported === true,
available: runtimeStatus.runtimeCapabilities.fastMode.available === true,
reason: runtimeStatus.runtimeCapabilities.fastMode.reason ?? null,
source: 'runtime',
}
: undefined,
}
: null,
};
}
@ -319,6 +657,7 @@ export class ClaudeMultimodelBridgeService {
authMethod: null,
verificationState: 'error',
statusMessage: issue,
detailMessage: null,
backend: null,
};
}
@ -330,6 +669,94 @@ export class ClaudeMultimodelBridgeService {
return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues));
}
private async getOpenCodeVerifySnapshot(
binaryPath: string
): Promise<OpenCodeRuntimeVerifyResponse['snapshot'] | null> {
const { env } = await this.buildCliEnv(binaryPath);
const { stdout } = await execCli(
binaryPath,
['runtime', 'verify', '--json', '--provider', 'opencode'],
{
timeout: PROVIDER_STATUS_TIMEOUT_MS,
env,
}
);
const parsed = extractJsonObject<OpenCodeRuntimeVerifyResponse>(stdout);
return parsed.providerId === 'opencode' ? (parsed.snapshot ?? null) : null;
}
private mergeOpenCodeVerification(
provider: CliProviderStatus,
snapshot: OpenCodeRuntimeVerifyResponse['snapshot']
): CliProviderStatus {
if (!snapshot) {
return provider;
}
const diagnostics = snapshot.diagnostics ?? [];
const diagnosticsSummary = diagnostics.slice(0, 2).join(' - ');
const liveIssuesPresent =
snapshot.detected === false ||
snapshot.hostHealthy !== true ||
Boolean(snapshot.probeError) ||
diagnostics.length > 0;
const detailParts = [
provider.detailMessage ?? null,
snapshot.host?.resolvedConfigFingerprint
? `live ${snapshot.host.resolvedConfigFingerprint.slice(0, 12)}`
: null,
snapshot.profile?.managedConfigFingerprint
? `managed ${snapshot.profile.managedConfigFingerprint.slice(0, 12)}`
: null,
snapshot.profile?.projectBehaviorFingerprint
? `behavior ${snapshot.profile.projectBehaviorFingerprint.slice(0, 12)}`
: null,
diagnosticsSummary || null,
].filter((value): value is string => Boolean(value));
const nextDiagnostics = [
...(provider.externalRuntimeDiagnostics ?? []),
{
id: 'opencode-live-host',
label: 'OpenCode live host',
detected: snapshot.hostHealthy === true,
statusMessage: snapshot.hostHealthy === true ? 'Healthy' : 'Unavailable',
detailMessage: snapshot.probeError ?? null,
},
{
id: 'opencode-managed-runtime',
label: 'OpenCode managed runtime',
detected: !liveIssuesPresent,
statusMessage: liveIssuesPresent
? 'Live verification found runtime drift'
: 'Managed runtime verified',
detailMessage: diagnosticsSummary || null,
},
];
return {
...provider,
verificationState: liveIssuesPresent ? 'error' : 'verified',
statusMessage: liveIssuesPresent
? (snapshot.probeError ??
diagnostics[0] ??
'OpenCode live verification found runtime drift')
: provider.statusMessage,
detailMessage: detailParts.length > 0 ? detailParts.join(' - ') : provider.detailMessage,
externalRuntimeDiagnostics: nextDiagnostics,
backend: provider.backend
? {
...provider.backend,
authMethodDetail:
snapshot.config?.default_agent === 'teammate'
? 'managed teammate agent'
: (provider.backend.authMethodDetail ?? null),
}
: provider.backend,
};
}
async getProviderStatus(
binaryPath: string,
providerId: CliProviderId
@ -370,6 +797,134 @@ export class ClaudeMultimodelBridgeService {
);
}
async verifyProviderStatus(
binaryPath: string,
providerId: CliProviderId
): Promise<CliProviderStatus> {
const provider = await this.getProviderStatus(binaryPath, providerId);
if (providerId !== 'opencode') {
return provider;
}
try {
const snapshot = await this.getOpenCodeVerifySnapshot(binaryPath);
return this.mergeOpenCodeVerification(provider, snapshot);
} catch (error) {
logger.warn(
`OpenCode live verification unavailable: ${
error instanceof Error ? error.message : String(error)
}`
);
return {
...provider,
verificationState: 'error',
statusMessage: 'OpenCode live verification failed',
detailMessage: error instanceof Error ? error.message : String(error),
};
}
}
async getOpenCodeTranscript(
binaryPath: string,
params: {
teamId: string;
memberName: string;
limit?: number;
}
): Promise<OpenCodeRuntimeTranscriptResponse['transcript'] | null> {
const { env } = await this.buildCliEnv(binaryPath);
const args = [
'runtime',
'transcript',
'--json',
'--provider',
'opencode',
'--team',
params.teamId,
'--member',
params.memberName,
];
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<OpenCodeRuntimeTranscriptResponse>(stdout);
return parsed.providerId === 'opencode' ? (parsed.transcript ?? null) : null;
}
private async verifyOpenCodeModel(
binaryPath: string,
modelId: string
): Promise<CliProviderModelAvailability> {
const { env } = await this.buildCliEnv(binaryPath);
try {
const { stdout } = await execCli(
binaryPath,
['runtime', 'verify-model', '--json', '--provider', 'opencode', '--model', modelId],
{
timeout: OPENCODE_MODEL_VERIFY_TIMEOUT_MS,
env,
}
);
const parsed = extractJsonObject<OpenCodeRuntimeVerifyModelResponse>(stdout);
const outcome = parsed.providerId === 'opencode' ? parsed.result?.outcome : undefined;
const reason = parsed.providerId === 'opencode' ? (parsed.result?.reason ?? null) : null;
return {
modelId,
status:
outcome === 'available'
? 'available'
: outcome === 'unavailable'
? 'unavailable'
: 'unknown',
reason,
checkedAt: new Date().toISOString(),
};
} catch (error) {
return {
modelId,
status: 'unknown',
reason: error instanceof Error ? error.message : String(error),
checkedAt: new Date().toISOString(),
};
}
}
async verifyOpenCodeModels(
binaryPath: string,
provider: CliProviderStatus
): Promise<CliProviderStatus> {
const visibleModels = filterVisibleProviderRuntimeModels(provider.providerId, provider.models);
if (
provider.providerId !== 'opencode' ||
provider.supported !== true ||
provider.authenticated !== true ||
visibleModels.length === 0
) {
return {
...provider,
modelVerificationState: 'idle',
modelAvailability: [],
};
}
const modelAvailability: CliProviderModelAvailability[] = [];
for (const modelId of visibleModels) {
modelAvailability.push(await this.verifyOpenCodeModel(binaryPath, modelId));
}
return {
...provider,
modelVerificationState: 'verified',
modelAvailability,
};
}
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
const provider = createDefaultProviderStatus('gemini');
const { env } = await this.buildProviderCliEnv(binaryPath, 'gemini');
@ -491,6 +1046,7 @@ export class ClaudeMultimodelBridgeService {
authMethod: runtimeStatus.authMethod ?? null,
verificationState: runtimeStatus.verificationState ?? 'unknown',
statusMessage: runtimeStatus.statusMessage ?? null,
detailMessage: runtimeStatus.detailMessage ?? null,
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,

View file

@ -11,10 +11,12 @@ import type {
CodexAccountSnapshotDto,
} from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '@features/codex-account/main';
import type { CodexModelCatalogFeatureFacade } from '@features/codex-model-catalog/main';
import type {
CliProviderAuthMode,
CliProviderConnectionInfo,
CliProviderId,
CliProviderReasoningEffort,
CliProviderStatus,
} from '@shared/types';
@ -42,6 +44,11 @@ const PROVIDER_CAPABILITIES: Record<
supportsApiKey: true,
configurableAuthModes: [],
},
opencode: {
supportsOAuth: false,
supportsApiKey: false,
configurableAuthModes: [],
},
};
const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
@ -77,6 +84,8 @@ function buildCodexForcedLoginLaunchArgs(
export class ProviderConnectionService {
private static instance: ProviderConnectionService | null = null;
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
null;
constructor(
private readonly apiKeyService = new ApiKeyService(),
@ -92,6 +101,12 @@ export class ProviderConnectionService {
this.codexAccountFeature = feature;
}
setCodexModelCatalogFeature(
feature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null
): void {
this.codexModelCatalogFeature = feature;
}
getConfiguredAuthMode(providerId: CliProviderId): CliProviderAuthMode | null {
if (providerId === 'anthropic') {
return this.configManager.getConfig().providerConnections.anthropic.authMode;
@ -174,7 +189,7 @@ export class ProviderConnectionService {
async applyAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
let nextEnv = env;
for (const providerId of ['anthropic', 'codex', 'gemini'] as const) {
for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) {
nextEnv = await this.applyConfiguredConnectionEnv(nextEnv, providerId);
}
return nextEnv;
@ -228,7 +243,7 @@ export class ProviderConnectionService {
async augmentAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
let nextEnv = env;
for (const providerId of ['anthropic', 'codex', 'gemini'] as const) {
for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) {
nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId);
}
return nextEnv;
@ -298,7 +313,7 @@ export class ProviderConnectionService {
async getConfiguredConnectionIssues(
env: NodeJS.ProcessEnv,
providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'],
providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'],
runtimeBackendOverrides?: Partial<Record<CliProviderId, string>>
): Promise<Partial<Record<CliProviderId, string>>> {
const issues: Partial<Record<CliProviderId, string>> = {};
@ -353,10 +368,53 @@ export class ProviderConnectionService {
}
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
return {
const withConnection = {
...provider,
connection: await this.getConnectionInfo(provider.providerId),
};
if (provider.providerId !== 'codex' || !this.codexModelCatalogFeature) {
return withConnection;
}
try {
const catalog = await this.codexModelCatalogFeature.getCatalog();
const models = catalog.models
.filter((model) => !model.hidden)
.map((model) => model.launchModel.trim())
.filter(Boolean);
const reasoningEfforts = Array.from(
new Set(
catalog.models.flatMap<CliProviderReasoningEffort>(
(model) => model.supportedReasoningEfforts
)
)
);
const runtimeReasoningCapability = withConnection.runtimeCapabilities?.reasoningEffort;
const runtimeModelCatalogCapability = withConnection.runtimeCapabilities?.modelCatalog;
return {
...withConnection,
models: models.length > 0 ? models : withConnection.models,
modelCatalog: catalog,
runtimeCapabilities: {
...withConnection.runtimeCapabilities,
modelCatalog: {
dynamic: runtimeModelCatalogCapability?.dynamic === true,
source: catalog.source,
},
reasoningEffort: {
supported: runtimeReasoningCapability?.supported ?? reasoningEfforts.length > 0,
values:
runtimeReasoningCapability?.values && runtimeReasoningCapability.values.length > 0
? runtimeReasoningCapability.values
: (['low', 'medium', 'high'] satisfies CliProviderReasoningEffort[]),
configPassthrough: runtimeReasoningCapability?.configPassthrough === true,
},
},
};
} catch {
return withConnection;
}
}
async enrichProviderStatuses(providers: CliProviderStatus[]): Promise<CliProviderStatus[]> {

View file

@ -6,7 +6,7 @@ import { configManager } from '../infrastructure/ConfigManager';
import {
applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv,
resolveTeamProviderId,
resolveRuntimeProviderId,
} from './providerRuntimeEnv';
import type { CliProviderId, TeamProviderId } from '@shared/types';
@ -68,19 +68,19 @@ export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): {
};
}
const resolvedProviderId = resolveTeamProviderId(options.providerId);
const runtimeProviderId = resolveRuntimeProviderId(options.providerId);
applyProviderRuntimeEnv(env, options.providerId);
if (resolvedProviderId === 'codex' && options.providerBackendId?.trim()) {
if (runtimeProviderId === 'codex' && options.providerBackendId?.trim()) {
env.CLAUDE_CODE_CODEX_BACKEND = options.providerBackendId.trim();
}
if (resolvedProviderId === 'gemini' && options.providerBackendId?.trim()) {
if (runtimeProviderId === 'gemini' && options.providerBackendId?.trim()) {
env.CLAUDE_CODE_GEMINI_BACKEND = options.providerBackendId.trim();
}
return {
env,
resolvedProviderId,
resolvedProviderId: runtimeProviderId,
};
}

View file

@ -1,6 +1,8 @@
import { ConfigManager } from '../infrastructure/ConfigManager';
import type { TeamProviderId } from '@shared/types';
import type { CliProviderId, TeamProviderId } from '@shared/types';
type RuntimeEnvProviderId = CliProviderId | TeamProviderId;
const PROVIDER_ROUTING_ENV_KEYS = [
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
@ -32,10 +34,9 @@ export function applyConfiguredRuntimeBackendsEnv(
export function applyProviderRuntimeEnv(
env: NodeJS.ProcessEnv,
providerId: TeamProviderId | undefined
providerId: RuntimeEnvProviderId | undefined
): NodeJS.ProcessEnv {
const resolvedProvider: TeamProviderId =
providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
const resolvedProvider = resolveRuntimeProviderId(providerId);
for (const key of PROVIDER_ROUTING_ENV_KEYS) {
env[key] = undefined;
@ -52,6 +53,18 @@ export function applyProviderRuntimeEnv(
return env;
}
export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId {
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
export function resolveRuntimeProviderId(
providerId: RuntimeEnvProviderId | undefined
): CliProviderId {
if (providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode') {
return providerId;
}
return 'anthropic';
}
export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId {
return providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode'
? providerId
: 'anthropic';
}

View file

@ -22,6 +22,7 @@ import {
type TaskChangeEffectiveOptions,
type TaskChangeTaskMeta,
} from './taskChangeWorkerTypes';
import { TaskChangeLedgerReader } from './TaskChangeLedgerReader';
import { TeamConfigReader } from './TeamConfigReader';
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
@ -30,6 +31,7 @@ import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient';
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types';
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
const logger = createLogger('Service:ChangeExtractorService');
@ -66,6 +68,7 @@ export class ChangeExtractorService {
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
private readonly taskChangeComputer: TaskChangeComputer;
private readonly taskChangeLedgerReader = new TaskChangeLedgerReader();
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
@ -164,6 +167,18 @@ export class ChangeExtractorService {
includeDetails,
};
const ledgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (ledgerResult) {
await this.recordTaskChangePresence(
teamName,
taskId,
taskMeta,
effectiveOptions,
ledgerResult
);
return ledgerResult;
}
if (!shouldUseSummaryCache) {
const result = await this.computeTaskChangesPreferred(resolvedInput);
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
@ -293,6 +308,32 @@ export class ChangeExtractorService {
return this.taskChangeComputer.computeTaskChanges(input);
}
private async readLedgerTaskChanges(
input: ResolvedTaskChangeComputeInput
): Promise<TaskChangeSetV2 | null> {
try {
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
return null;
}
const context = await this.logsFinder.getLogSourceWatchContext(input.teamName);
if (!context?.projectDir) {
return null;
}
return await this.taskChangeLedgerReader.readTaskChanges({
teamName: input.teamName,
taskId: input.taskId,
projectDir: context.projectDir,
projectPath: input.projectPath ?? context.projectPath,
includeDetails: input.includeDetails,
});
} catch (error) {
logger.warn(
`Task change ledger read failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
private isValidWorkerTaskChangeResult(
result: TaskChangeSetV2,
input: ResolvedTaskChangeComputeInput
@ -719,11 +760,8 @@ export class ChangeExtractorService {
return;
}
if (
result.files.length === 0 &&
result.confidence !== 'high' &&
result.confidence !== 'medium'
) {
const resolvedPresence = resolveTaskChangePresenceFromResult(result);
if (!resolvedPresence) {
return;
}
@ -749,7 +787,7 @@ export class ChangeExtractorService {
{
taskId,
taskSignature: descriptor.taskSignature,
presence: result.files.length > 0 ? 'has_changes' : 'no_changes',
presence: resolvedPresence,
writtenAt: now,
logSourceGeneration: snapshot.logSourceGeneration,
}

View file

@ -63,6 +63,11 @@ export class FileContentResolver {
modified: string | null;
source: FileChangeWithContent['contentSource'];
}> {
const ledgerResult = this.tryLedgerContent(snippets);
if (ledgerResult) {
return ledgerResult;
}
// Read current file from disk (= modified state after agent's changes)
let currentContent: string | null = null;
try {
@ -183,7 +188,9 @@ export class FileContentResolver {
}
}
const isNewFile = snippets.some((s) => s.type === 'write-new');
const isNewFile = snippets.some(
(s) => s.type === 'write-new' || s.ledger?.operation === 'create'
);
return {
filePath,
@ -257,6 +264,49 @@ export class FileContentResolver {
// ── Private: Resolution strategies ──
private tryLedgerContent(snippets: SnippetDiff[]): {
original: string | null;
modified: string | null;
source: FileChangeWithContent['contentSource'];
} | null {
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
if (ledgerSnippets.length === 0) {
return null;
}
const first = ledgerSnippets[0]?.ledger;
const last = ledgerSnippets[ledgerSnippets.length - 1]?.ledger;
if (!first || !last) {
return null;
}
const canUseSyntheticOriginal =
first.originalFullContent === null &&
first.operation === 'create' &&
last.modifiedFullContent !== null &&
!first.beforeState?.unavailableReason;
const canUseSyntheticModified =
last.modifiedFullContent === null &&
last.operation === 'delete' &&
first.originalFullContent !== null &&
!last.afterState?.unavailableReason;
const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null);
const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : null);
if (original === null && modified === null) {
return null;
}
const hasSnapshot = ledgerSnippets.some(
(snippet) => snippet.ledger?.source === 'ledger-snapshot'
);
return {
original,
modified,
source: hasSnapshot ? 'ledger-snapshot' : 'ledger-exact',
};
}
/**
* Strategy 1: Read original content from Claude's file-history backup.
*
@ -429,6 +479,13 @@ export class FileContentResolver {
return null;
}
case 'notebook-edit':
case 'shell-snapshot':
case 'hook-snapshot': {
// Snapshot/full-file changes are only safe when ledger content is available.
return null;
}
case 'edit':
case 'multi-edit': {
// Guard: empty newString means deletion — can't find position to reverse

View file

@ -144,8 +144,14 @@ export class HunkSnippetMatcher {
): boolean {
if (!snippet.newString && !snippet.oldString) return false;
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
// Full-file writes are intentionally excluded from localized hunk↔snippet matching.
if (
snippet.type === 'write-new' ||
snippet.type === 'write-update' ||
snippet.type === 'notebook-edit' ||
snippet.type === 'shell-snapshot' ||
snippet.type === 'hook-snapshot'
) {
// Full-file and snapshot changes are intentionally excluded from localized hunk↔snippet matching.
// They are handled by whole-file reject logic or hunk-level inverse patch.
return false;
}

View file

@ -1,8 +1,10 @@
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { createLogger } from '@shared/utils/logger';
import { createHash } from 'crypto';
import { applyPatch, structuredPatch } from 'diff';
import { readFile, unlink, writeFile } from 'fs/promises';
import { mkdir, readFile, unlink, writeFile } from 'fs/promises';
import { diff3Merge } from 'node-diff3';
import { dirname } from 'path';
import { HunkSnippetMatcher } from './HunkSnippetMatcher';
@ -11,6 +13,7 @@ import type {
ApplyReviewResult,
ConflictCheckResult,
FileChangeWithContent,
LedgerChangeRelation,
RejectResult,
SnippetDiff,
} from '@shared/types';
@ -18,6 +21,21 @@ import type { StructuredPatchHunk } from 'diff';
const logger = createLogger('Service:ReviewApplierService');
type ApplyErrorCode = NonNullable<ApplyReviewResult['errors'][number]['code']>;
type LedgerApplyOutcome =
| { handled: false }
| { handled: true; status: 'applied' | 'skipped' }
| { handled: true; status: 'conflict' | 'error'; error: string; code: ApplyErrorCode };
type CurrentTextReadResult =
| { missing: true; content: '' }
| { missing: false; content: string }
| { missing: false; content: ''; error: string };
function getCurrentTextReadError(result: CurrentTextReadResult): string | null {
return 'error' in result ? result.error : null;
}
/**
* Service for applying reject decisions from code review.
*
@ -255,16 +273,43 @@ export class ReviewApplierService {
const allHunksRejected =
Object.keys(decision.hunkDecisions).length > 0 &&
Object.values(decision.hunkDecisions).every((d) => d === 'rejected');
const hasWriteNewSnippet = fileContent.snippets.some((s) => s.type === 'write-new');
const hasNewFileSnippet = fileContent.snippets.some(
(s) => s.type === 'write-new' || s.ledger?.operation === 'create'
);
// Special case: rejecting an entirely new file should remove it from disk.
// IMPORTANT: Do NOT delete on partial reject — users may want to keep parts of the new file.
const shouldDeleteNewFile =
fileContent.isNewFile &&
hasWriteNewSnippet &&
hasNewFileSnippet &&
original === '' &&
(decision.fileDecision === 'rejected' || allHunksRejected);
const ledgerOutcome = await this.tryApplyLedgerDecision(
decision.filePath,
original,
modified,
decision.fileDecision === 'rejected',
allHunksRejected,
rejectedHunkIndices,
fileContent.snippets
);
if (ledgerOutcome.handled) {
if (ledgerOutcome.status === 'applied') {
applied++;
} else if (ledgerOutcome.status === 'skipped') {
skipped++;
} else if (ledgerOutcome.status === 'conflict' || ledgerOutcome.status === 'error') {
if (ledgerOutcome.status === 'conflict') conflicts++;
errors.push({
filePath: decision.filePath,
error: ledgerOutcome.error,
code: ledgerOutcome.code,
});
}
continue;
}
if (shouldDeleteNewFile) {
// If we have an expected modified baseline, guard against deleting a user-modified file.
if (modified !== null) {
@ -275,6 +320,7 @@ export class ReviewApplierService {
filePath: decision.filePath,
error:
'File was modified since review was computed; refusing to delete new file automatically.',
code: 'conflict',
});
continue;
}
@ -289,6 +335,7 @@ export class ReviewApplierService {
errors.push({
filePath: decision.filePath,
error: 'Cannot delete new file: expected modified content is unavailable.',
code: 'unavailable',
});
continue;
}
@ -304,6 +351,7 @@ export class ReviewApplierService {
errors.push({
filePath: decision.filePath,
error: `Failed to delete new file: ${msg}`,
code: 'io-error',
});
}
}
@ -314,6 +362,7 @@ export class ReviewApplierService {
errors.push({
filePath: decision.filePath,
error: 'Содержимое файла недоступно для применения review',
code: 'unavailable',
});
continue;
}
@ -393,6 +442,403 @@ export class ReviewApplierService {
// ── Private: Rejection strategies ──
private async tryApplyLedgerDecision(
filePath: string,
original: string | null,
modified: string | null,
fileRejected: boolean,
allHunksRejected: boolean,
rejectedHunkIndices: number[],
snippets: SnippetDiff[]
): Promise<LedgerApplyOutcome> {
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
if (ledgerSnippets.length === 0) {
return { handled: false };
}
const firstLedger = ledgerSnippets[0]?.ledger;
const lastLedger = ledgerSnippets[ledgerSnippets.length - 1]?.ledger;
if (!firstLedger || !lastLedger) {
return { handled: false };
}
const fullReject = fileRejected || allHunksRejected;
const hasUnavailableState = ledgerSnippets.some(
(snippet) =>
snippet.ledger?.beforeState?.unavailableReason ||
snippet.ledger?.afterState?.unavailableReason
);
const relation = this.resolveLedgerRelation(ledgerSnippets);
if (!fullReject) {
if (relation?.kind === 'rename' || relation?.kind === 'copy') {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: `Ledger ${relation.kind} partial reject requires manual review.`,
};
}
if (original === null || modified === null) {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger full text is unavailable; partial reject requires manual review.',
};
}
const guard = await this.checkLedgerCurrentHash(
filePath,
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
);
if (!guard.ok) {
return guard.outcome;
}
const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices);
if (!patchResult) {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger partial reject could not be applied safely.',
};
}
try {
await writeFile(filePath, patchResult.newContent, 'utf8');
return { handled: true, status: 'applied' };
} catch (err) {
return {
handled: true,
status: 'error',
code: 'io-error',
error: `Не удалось записать файл: ${String(err)}`,
};
}
}
if (relation?.kind === 'rename') {
return this.rejectLedgerRename(ledgerSnippets, relation, original, hasUnavailableState);
}
const operation = this.resolveLedgerOperation(ledgerSnippets);
if (operation === 'create') {
const afterHash = lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined;
const current = await this.readCurrentText(filePath);
if (current.missing) {
return { handled: true, status: 'applied' };
}
const currentError = getCurrentTextReadError(current);
if (currentError) {
return {
handled: true,
status: 'error',
code: 'io-error',
error: currentError,
};
}
if (!afterHash) {
return {
handled: true,
status: 'error',
code: hasUnavailableState ? 'manual-review-required' : 'unavailable',
error: 'Ledger after content hash is unavailable; refusing to delete file automatically.',
};
}
if (this.hashText(current.content) !== afterHash) {
return {
handled: true,
status: 'conflict',
code: 'conflict',
error: 'File was modified since review was computed; refusing ledger delete.',
};
}
try {
await unlink(filePath);
return { handled: true, status: 'applied' };
} catch (err) {
const msg = String(err);
if (msg.includes('ENOENT')) {
return { handled: true, status: 'applied' };
}
return {
handled: true,
status: 'error',
code: 'io-error',
error: `Failed to delete new file: ${msg}`,
};
}
}
if (operation === 'delete') {
if (original === null) {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger before content is unavailable; deleted file requires manual restore.',
};
}
const current = await this.readCurrentText(filePath);
if (!current.missing) {
const currentError = getCurrentTextReadError(current);
return {
handled: true,
status: 'conflict',
code: 'conflict',
error:
currentError || 'File exists on disk; refusing to overwrite while rejecting delete.',
};
}
try {
await writeFile(filePath, original, 'utf8');
return { handled: true, status: 'applied' };
} catch (err) {
return {
handled: true,
status: 'error',
code: 'io-error',
error: `Не удалось записать файл: ${String(err)}`,
};
}
}
if (original === null) {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error:
'Ledger before content is unavailable; rejecting this change requires manual review.',
};
}
const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256);
if (!guard.ok) {
return guard.outcome;
}
try {
await writeFile(filePath, original, 'utf8');
return { handled: true, status: 'applied' };
} catch (err) {
return {
handled: true,
status: 'error',
code: 'io-error',
error: `Не удалось записать файл: ${String(err)}`,
};
}
}
private resolveLedgerOperation(snippets: SnippetDiff[]): 'create' | 'modify' | 'delete' {
if (snippets.some((snippet) => snippet.ledger?.operation === 'create')) return 'create';
if (snippets[snippets.length - 1]?.ledger?.operation === 'delete') return 'delete';
return 'modify';
}
private resolveLedgerRelation(snippets: SnippetDiff[]): LedgerChangeRelation | undefined {
return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation;
}
private async rejectLedgerRename(
snippets: SnippetDiff[],
relation: LedgerChangeRelation,
original: string | null,
hasUnavailableState: boolean
): Promise<LedgerApplyOutcome> {
const oldSnippet =
snippets.find(
(snippet) =>
snippet.ledger?.operation === 'delete' &&
this.pathMatchesRelationPath(snippet.filePath, relation.oldPath)
) ?? snippets.find((snippet) => snippet.ledger?.operation === 'delete');
const newSnippet =
snippets.find(
(snippet) =>
snippet.ledger?.operation === 'create' &&
this.pathMatchesRelationPath(snippet.filePath, relation.newPath)
) ?? snippets.find((snippet) => snippet.ledger?.operation === 'create');
const oldFilePath =
oldSnippet?.filePath ??
this.resolveRelatedLedgerPath(newSnippet?.filePath, relation.newPath, relation.oldPath);
const newFilePath = newSnippet?.filePath;
const oldContent = oldSnippet?.ledger?.originalFullContent ?? original;
const newHash = newSnippet?.ledger?.afterState?.sha256 ?? newSnippet?.ledger?.afterHash;
const oldHash = oldSnippet?.ledger?.beforeState?.sha256 ?? oldSnippet?.ledger?.beforeHash;
if (!oldFilePath || !newFilePath || oldContent === null) {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger rename metadata is incomplete; manual review is required.',
};
}
if (hasUnavailableState || !newHash) {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger rename content metadata is unavailable; manual review is required.',
};
}
const newCurrent = await this.readCurrentText(newFilePath);
if (!newCurrent.missing) {
const newCurrentError = getCurrentTextReadError(newCurrent);
if (newCurrentError) {
return {
handled: true,
status: 'error',
code: 'io-error',
error: newCurrentError,
};
}
if (this.hashText(newCurrent.content) !== newHash) {
return {
handled: true,
status: 'conflict',
code: 'conflict',
error: 'Renamed file was modified since review was computed; refusing ledger reject.',
};
}
}
const oldCurrent = await this.readCurrentText(oldFilePath);
if (!oldCurrent.missing) {
const oldCurrentError = getCurrentTextReadError(oldCurrent);
if (oldCurrentError) {
return {
handled: true,
status: 'error',
code: 'io-error',
error: oldCurrentError,
};
}
if (!oldHash || this.hashText(oldCurrent.content) !== oldHash) {
return {
handled: true,
status: 'conflict',
code: 'conflict',
error: 'Original rename path already exists with different content; refusing overwrite.',
};
}
}
try {
if (oldCurrent.missing) {
await mkdir(dirname(oldFilePath), { recursive: true });
await writeFile(oldFilePath, oldContent, 'utf8');
}
if (!newCurrent.missing) {
await unlink(newFilePath);
}
return { handled: true, status: 'applied' };
} catch (err) {
return {
handled: true,
status: 'error',
code: 'io-error',
error: `Failed to reject ledger rename: ${String(err)}`,
};
}
}
private pathMatchesRelationPath(filePath: string, relationPath: string): boolean {
const normalizedFilePath = filePath.replace(/\\/g, '/');
const normalizedRelationPath = relationPath.replace(/\\/g, '/');
return (
normalizedFilePath === normalizedRelationPath ||
normalizedFilePath.endsWith(`/${normalizedRelationPath}`)
);
}
private resolveRelatedLedgerPath(
anchorPath: string | undefined,
anchorRelationPath: string,
targetRelationPath: string
): string | null {
if (!anchorPath) {
return null;
}
const normalizedAnchor = anchorPath.replace(/\\/g, '/');
const normalizedRelation = anchorRelationPath.replace(/\\/g, '/');
if (!normalizedAnchor.endsWith(normalizedRelation)) {
return null;
}
return `${normalizedAnchor.slice(0, normalizedAnchor.length - normalizedRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`;
}
private async checkLedgerCurrentHash(
filePath: string,
expectedHash: string | undefined
): Promise<{ ok: true } | { ok: false; outcome: LedgerApplyOutcome }> {
if (!expectedHash) {
return {
ok: false,
outcome: {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger expected content hash is unavailable; refusing automatic apply.',
},
};
}
const current = await this.readCurrentText(filePath);
if (current.missing) {
return {
ok: false,
outcome: {
handled: true,
status: 'conflict',
code: 'conflict',
error: 'File is missing on disk; refusing ledger apply.',
},
};
}
const currentError = getCurrentTextReadError(current);
if (currentError) {
return {
ok: false,
outcome: {
handled: true,
status: 'error',
code: 'io-error',
error: currentError,
},
};
}
if (this.hashText(current.content) !== expectedHash) {
return {
ok: false,
outcome: {
handled: true,
status: 'conflict',
code: 'conflict',
error: 'File was modified since review was computed; refusing ledger apply.',
},
};
}
return { ok: true };
}
private async readCurrentText(filePath: string): Promise<CurrentTextReadResult> {
try {
return { missing: false, content: await readFile(filePath, 'utf8') };
} catch (err) {
const code =
err && typeof err === 'object' && 'code' in err
? String((err as { code?: unknown }).code)
: '';
if (code === 'ENOENT') {
return { missing: true, content: '' };
}
return { missing: false, content: '', error: `Не удалось прочитать файл: ${String(err)}` };
}
}
private hashText(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
/**
* Snippet-level rejection: reverse specific snippets by position (most accurate).
*
@ -409,7 +855,13 @@ export class ReviewApplierService {
// They are not localized, and matching a single hunk to a full-file write
// can incorrectly delete/overwrite large parts of the file.
const validSnippets = snippets.filter(
(s) => !s.isError && s.type !== 'write-new' && s.type !== 'write-update'
(s) =>
!s.isError &&
s.type !== 'write-new' &&
s.type !== 'write-update' &&
s.type !== 'notebook-edit' &&
s.type !== 'shell-snapshot' &&
s.type !== 'hook-snapshot'
);
if (validSnippets.length === 0) return null;

View file

@ -1,5 +1,6 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@ -10,6 +11,7 @@ import type { HunkDecision } from '@shared/types';
const logger = createLogger('ReviewDecisionStore');
export interface ReviewDecisionsData {
scopeToken?: string;
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
/** filePath -> (hunkIndex -> contextHash) */
@ -17,50 +19,60 @@ export interface ReviewDecisionsData {
updatedAt: string;
}
interface ReviewDecisionsDataV2 extends ReviewDecisionsData {
version: 2;
scopeKey: string;
scopeToken: string;
}
export class ReviewDecisionStore {
private getDirPath(teamName: string): string {
private getLegacyDirPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'review-decisions');
}
private getFilePath(teamName: string, scopeKey: string): string {
return path.join(this.getDirPath(teamName), `${scopeKey}.json`);
private getLegacyFilePath(teamName: string, scopeKey: string): string {
return path.join(this.getLegacyDirPath(teamName), `${scopeKey}.json`);
}
async load(
teamName: string,
scopeKey: string
): Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null> {
const filePath = this.getFilePath(teamName, scopeKey);
private getV2DirPath(teamName: string, scopeKey: string): string {
return path.join(
this.getLegacyDirPath(teamName),
'v2',
encodeURIComponent(scopeKey)
);
}
let raw: string;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read review decisions for ${teamName}/${scopeKey}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
logger.error(`Corrupted review decisions file for ${teamName}/${scopeKey}`);
return null;
}
private getV2FilePath(teamName: string, scopeKey: string, scopeToken: string): string {
const scopeHash = createHash('sha256').update(scopeToken).digest('hex');
return path.join(this.getV2DirPath(teamName, scopeKey), `${scopeHash}.json`);
}
private parseStoredData(parsed: unknown): ReviewDecisionsData | ReviewDecisionsDataV2 | null {
if (!parsed || typeof parsed !== 'object') {
return null;
}
const data = parsed as Partial<ReviewDecisionsData>;
const data = parsed as Partial<ReviewDecisionsDataV2>;
const isV2 =
data.version === 2 &&
typeof data.scopeKey === 'string' &&
typeof data.scopeToken === 'string';
if (data.version !== undefined && !isV2) {
return null;
}
return data as ReviewDecisionsData | ReviewDecisionsDataV2;
}
private extractDecisions(
data: ReviewDecisionsData | ReviewDecisionsDataV2,
scopeToken?: string
): {
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null {
const hunkDecisions: Record<string, HunkDecision> =
data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {};
const fileDecisions: Record<string, HunkDecision> =
@ -70,37 +82,162 @@ export class ReviewDecisionStore {
? data.hunkContextHashesByFile
: undefined;
if (scopeToken) {
if (typeof data.scopeToken !== 'string' || data.scopeToken !== scopeToken) {
return null;
}
}
return { hunkDecisions, fileDecisions, hunkContextHashesByFile };
}
private async loadFromPath(
filePath: string,
scopeToken?: string
): Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null> {
let raw: string;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read review decisions at ${filePath}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
logger.error(`Corrupted review decisions file at ${filePath}`);
return null;
}
const data = this.parseStoredData(parsed);
return data ? this.extractDecisions(data, scopeToken) : null;
}
private async pruneScopeDir(teamName: string, scopeKey: string): Promise<void> {
const dirPath = this.getV2DirPath(teamName, scopeKey);
let entries: string[];
try {
entries = await fs.promises.readdir(dirPath);
} catch {
return;
}
if (entries.length <= 16) {
return;
}
const files = await Promise.all(
entries
.filter((entry) => entry.endsWith('.json'))
.map(async (entry) => {
const filePath = path.join(dirPath, entry);
try {
const stats = await fs.promises.stat(filePath);
return { filePath, mtimeMs: stats.mtimeMs };
} catch {
return null;
}
})
);
const staleFiles = files
.filter((entry): entry is { filePath: string; mtimeMs: number } => !!entry)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.slice(16);
await Promise.all(
staleFiles.map((entry) =>
fs.promises.unlink(entry.filePath).catch(() => undefined)
)
);
}
async load(
teamName: string,
scopeKey: string,
scopeToken?: string
): Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null> {
if (scopeToken) {
const exact = await this.loadFromPath(
this.getV2FilePath(teamName, scopeKey, scopeToken),
scopeToken
);
if (exact) {
return exact;
}
}
return this.loadFromPath(this.getLegacyFilePath(teamName, scopeKey), scopeToken);
}
async save(
teamName: string,
scopeKey: string,
data: {
scopeToken: string;
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
}
): Promise<void> {
try {
const payload: ReviewDecisionsData = {
const payload: ReviewDecisionsDataV2 = {
version: 2,
scopeKey,
scopeToken: data.scopeToken,
hunkDecisions: data.hunkDecisions,
fileDecisions: data.fileDecisions,
hunkContextHashesByFile: data.hunkContextHashesByFile,
updatedAt: new Date().toISOString(),
};
const filePath = this.getV2FilePath(teamName, scopeKey, data.scopeToken);
await atomicWriteAsync(
this.getFilePath(teamName, scopeKey),
filePath,
JSON.stringify(payload, null, 2)
);
await this.pruneScopeDir(teamName, scopeKey);
} catch (error) {
logger.error(`Failed to save review decisions for ${teamName}/${scopeKey}: ${String(error)}`);
}
}
async clear(teamName: string, scopeKey: string): Promise<void> {
async clear(teamName: string, scopeKey: string, scopeToken?: string): Promise<void> {
try {
await fs.promises.unlink(this.getFilePath(teamName, scopeKey));
if (scopeToken) {
await fs.promises
.unlink(this.getV2FilePath(teamName, scopeKey, scopeToken))
.catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
const legacyPath = this.getLegacyFilePath(teamName, scopeKey);
const legacy = await this.loadFromPath(legacyPath, scopeToken);
if (legacy) {
await fs.promises.unlink(legacyPath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
return;
}
await fs.promises.unlink(this.getLegacyFilePath(teamName, scopeKey)).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
});
await fs.promises.rm(this.getV2DirPath(teamName, scopeKey), {
recursive: true,
force: true,
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ import {
wrapAgentBlock,
} from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
@ -77,6 +78,7 @@ import type {
TeamMember,
TeamMemberActivityMeta,
TeamProcess,
TeamProviderId,
TeamSummary,
TeamTask,
TeamTaskStatus,
@ -240,7 +242,7 @@ export class TeamDataService {
kanbanTaskState?: KanbanState['tasks'][string]
): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task);
const reviewer = kanbanTaskState?.reviewer ?? this.resolveReviewerFromHistory(task) ?? null;
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
return {
...task,
reviewState,
@ -250,23 +252,45 @@ export class TeamDataService {
}
/**
* Extract reviewer name from task history events as a fallback
* when kanban state doesn't have it (e.g. review done via MCP agent-teams).
* Extract reviewer name from the current review cycle history.
* For legacy boards that stored reviewer only in kanban state, preserve that
* value as a migration fallback while the task is still actively in review.
*/
private resolveReviewerFromHistory(task: TeamTask): string | null {
if (!task.historyEvents?.length) return null;
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
const event = task.historyEvents[i];
if (event.type === 'review_approved' && event.actor) {
return event.actor;
}
if (event.type === 'review_started' && event.actor) {
return event.actor;
}
if (event.type === 'review_requested' && event.reviewer) {
return event.reviewer;
private resolveReviewerFromHistory(
task: TeamTask,
kanbanTaskState?: KanbanState['tasks'][string],
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task)
): string | null {
if (task.historyEvents?.length) {
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
const event = task.historyEvents[i];
if (event.type === 'review_started' && event.actor) {
return event.actor;
}
if (event.type === 'review_requested' && event.reviewer) {
return event.reviewer;
}
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
break;
}
if (event.type === 'status_changed' && event.to === 'in_progress') {
break;
}
if (event.type === 'task_created') {
break;
}
}
}
if (
reviewState === 'review' &&
kanbanTaskState?.column === 'review' &&
typeof kanbanTaskState.reviewer === 'string' &&
kanbanTaskState.reviewer.trim().length > 0
) {
return kanbanTaskState.reviewer.trim();
}
return null;
}
@ -1258,10 +1282,7 @@ export class TeamDataService {
? request.providerId
: undefined,
model: request.model?.trim() || undefined,
effort:
request.effort === 'low' || request.effort === 'medium' || request.effort === 'high'
? request.effort
: undefined,
effort: isTeamEffortLevel(request.effort) ? request.effort : undefined,
agentType: 'general-purpose',
joinedAt: Date.now(),
};
@ -1295,9 +1316,9 @@ export class TeamDataService {
name: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerId?: TeamProviderId;
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: TeamMember['effort'];
}[];
}
): Promise<void> {
@ -1339,10 +1360,7 @@ export class TeamDataService {
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
agentType: prev?.agentType ?? 'general-purpose',
agentId: isSameActiveMember ? prev?.agentId : undefined,
color: prev?.color,
@ -2389,6 +2407,7 @@ export class TeamDataService {
color: request.color,
cwd: request.cwd?.trim() || '',
providerBackendId: request.providerBackendId,
fastMode: request.fastMode,
createdAt: joinedAt,
});
@ -2418,10 +2437,7 @@ export class TeamDataService {
workflow: member.workflow?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
agentType: 'general-purpose' as const,
joinedAt,
}))

View file

@ -15,6 +15,8 @@ import type { FSWatcher } from 'chokidar';
const logger = createLogger('Service:TeamLogSourceTracker');
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness';
const BOARD_TASK_CHANGES_DIRNAME = '.board-task-changes';
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
interface TeamLogSourceSnapshot {
@ -41,6 +43,28 @@ interface TrackingState {
lifecycleVersion: number;
}
type DecodedFreshnessTaskId =
| { kind: 'task-id'; taskId: string }
| { kind: 'opaque-safe-segment' }
| { kind: 'invalid' };
function isOpaqueSafeTaskIdSegment(segment: string): boolean {
return /^task-id-[0-9a-f]{32}$/.test(segment);
}
export function shouldIgnoreLogSourceWatcherPath(
projectDir: string,
watchedPath: string
): boolean {
const relativePath = path.relative(projectDir, watchedPath);
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return false;
}
const parts = relativePath.split(path.sep).filter(Boolean);
return parts[0] === BOARD_TASK_CHANGES_DIRNAME;
}
export class TeamLogSourceTracker {
private readonly stateByTeam = new Map<string, TrackingState>();
private emitter: ((event: TeamChangeEvent) => void) | null = null;
@ -275,6 +299,7 @@ export class TeamLogSourceTracker {
ignorePermissionErrors: true,
followSymlinks: false,
depth: 3,
ignored: (watchedPath) => shouldIgnoreLogSourceWatcherPath(projectDir, watchedPath),
awaitWriteFinish: {
stabilityThreshold: 250,
pollInterval: 50,
@ -288,7 +313,18 @@ export class TeamLogSourceTracker {
}
if (
changedPath &&
this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath)
(this.handleTaskFreshnessSignalChange(
teamName,
current.projectDir,
changedPath,
BOARD_TASK_LOG_FRESHNESS_DIRNAME
) ||
this.handleTaskFreshnessSignalChange(
teamName,
current.projectDir,
changedPath,
BOARD_TASK_CHANGE_FRESHNESS_DIRNAME
))
) {
return;
}
@ -311,12 +347,13 @@ export class TeamLogSourceTracker {
});
}
private handleTaskLogFreshnessSignalChange(
private handleTaskFreshnessSignalChange(
teamName: string,
projectDir: string,
changedPath: string
changedPath: string,
signalDirName: string
): boolean {
const signalDir = path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME);
const signalDir = path.join(projectDir, signalDirName);
const relativePath = path.relative(signalDir, changedPath);
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return path.normalize(changedPath) === path.normalize(signalDir);
@ -330,37 +367,68 @@ export class TeamLogSourceTracker {
return true;
}
const taskId = this.decodeTaskLogFreshnessTaskId(relativePath);
if (!taskId) {
const decoded = this.decodeTaskLogFreshnessTaskId(relativePath);
if (decoded.kind === 'invalid') {
return true;
}
if (decoded.kind === 'opaque-safe-segment') {
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath);
return true;
}
this.emitter?.({
type: 'task-log-change',
teamName,
taskId,
taskId: decoded.taskId,
});
return true;
}
private decodeTaskLogFreshnessTaskId(fileName: string): string | null {
private decodeTaskLogFreshnessTaskId(fileName: string): DecodedFreshnessTaskId {
if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) {
return null;
return { kind: 'invalid' };
}
const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length);
if (!encodedTaskId) {
return null;
return { kind: 'invalid' };
}
if (isOpaqueSafeTaskIdSegment(encodedTaskId)) {
return { kind: 'opaque-safe-segment' };
}
try {
const taskId = decodeURIComponent(encodedTaskId);
return taskId.trim().length > 0 ? taskId : null;
return taskId.trim().length > 0
? { kind: 'task-id', taskId }
: { kind: 'invalid' };
} catch {
return null;
return { kind: 'invalid' };
}
}
private async emitTaskFreshnessSignalFromFile(teamName: string, filePath: string): Promise<void> {
try {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const taskId =
typeof parsed.taskId === 'string' && parsed.taskId.trim().length > 0
? parsed.taskId.trim()
: null;
if (taskId) {
this.emitter?.({
type: 'task-log-change',
teamName,
taskId,
});
return;
}
} catch {
// Deletions or partially unavailable files still need a team-level refresh.
}
this.emitLogSourceChange(teamName);
}
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
if (this.getActiveConsumerCount(state) === 0) {

View file

@ -7,7 +7,7 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
interface McpLaunchSpec {
export interface McpLaunchSpec {
command: string;
args: string[];
}
@ -202,7 +202,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
}
}
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
const checked: string[] = [];
// 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath
@ -250,7 +250,7 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
export class TeamMcpConfigBuilder {
async writeConfigFile(_projectPath?: string): Promise<string> {
const launchSpec = await resolveMcpLaunchSpec();
const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
const configDir = getMcpConfigsBasePath();
const configPath = path.join(
configDir,

View file

@ -5,8 +5,15 @@ import {
} from '@shared/utils/teamMemberName';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
import type {
TeamConfig,
TeamMember,
TeamMemberSnapshot,
TeamProviderId,
TeamTaskWithKanban,
} from '@shared/types';
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
@ -121,9 +128,9 @@ export class TeamMemberResolver {
agentType?: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerId?: TeamProviderId;
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: TeamMember['effort'];
color?: string;
cwd?: string;
}
@ -131,17 +138,10 @@ export class TeamMemberResolver {
if (Array.isArray(config.members)) {
for (const m of config.members) {
if (typeof m?.name === 'string' && m.name.trim() !== '') {
const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' };
const configMember = m as TeamMember & { provider?: TeamProviderId };
const providerId =
configMember.providerId === 'anthropic' ||
configMember.providerId === 'codex' ||
configMember.providerId === 'gemini'
? configMember.providerId
: configMember.provider === 'anthropic' ||
configMember.provider === 'codex' ||
configMember.provider === 'gemini'
? configMember.provider
: undefined;
normalizeOptionalTeamProviderId(configMember.providerId) ??
normalizeOptionalTeamProviderId(configMember.provider);
configMemberMap.set(m.name.trim(), {
agentId: configMember.agentId,
agentType: configMember.agentType,
@ -164,9 +164,9 @@ export class TeamMemberResolver {
agentType?: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerId?: TeamProviderId;
model?: string;
effort?: 'low' | 'medium' | 'high';
effort?: TeamMember['effort'];
color?: string;
removedAt?: number;
}

View file

@ -15,7 +15,13 @@ const QUOTA_EXHAUSTED_TOKENS = [
'quota exceeded',
'quota exhausted',
];
const RATE_LIMITED_TOKENS = ['rate limit', 'too many requests', '429'];
const RATE_LIMITED_TOKENS = [
'rate limit',
'too many requests',
'429',
'model cooldown',
'cooling down',
];
const AUTH_ERROR_TOKENS = [
'unauthorized',
'forbidden',

View file

@ -1,5 +1,6 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import * as fs from 'fs';
@ -36,10 +37,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
? member.effort
: undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
agentType:
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,

View file

@ -6,6 +6,8 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { ProviderModelLaunchIdentity, TeamFastMode, TeamProviderId } from '@shared/types';
/**
* Persisted team-level metadata saved by the UI before CLI provisioning.
* CLI does not know about this file it only reads/writes config.json.
@ -19,14 +21,16 @@ export interface TeamMetaFile {
color?: string;
cwd: string;
prompt?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerId?: TeamProviderId;
providerBackendId?: string;
model?: string;
effort?: string;
fastMode?: TeamFastMode;
skipPermissions?: boolean;
worktree?: string;
extraCliArgs?: string;
limitContext?: boolean;
launchIdentity?: ProviderModelLaunchIdentity;
createdAt: number;
}
@ -40,6 +44,80 @@ function normalizeOptionalBackendId(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeProviderId(value: unknown): TeamProviderId | undefined {
return value === 'anthropic' || value === 'codex' || value === 'gemini' ? value : undefined;
}
function normalizeOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeFastMode(value: unknown): TeamFastMode | null {
return value === 'inherit' || value === 'on' || value === 'off' ? value : null;
}
function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const raw = value as Partial<ProviderModelLaunchIdentity>;
const providerId = normalizeProviderId(raw.providerId);
const selectedModelKind =
raw.selectedModelKind === 'default' || raw.selectedModelKind === 'explicit'
? raw.selectedModelKind
: null;
if (!providerId || !selectedModelKind) {
return undefined;
}
const catalogSource =
raw.catalogSource === 'anthropic-models-api' ||
raw.catalogSource === 'app-server' ||
raw.catalogSource === 'static-fallback' ||
raw.catalogSource === 'runtime' ||
raw.catalogSource === 'unavailable'
? raw.catalogSource
: 'unavailable';
const selectedEffort =
raw.selectedEffort === 'none' ||
raw.selectedEffort === 'minimal' ||
raw.selectedEffort === 'low' ||
raw.selectedEffort === 'medium' ||
raw.selectedEffort === 'high' ||
raw.selectedEffort === 'xhigh' ||
raw.selectedEffort === 'max'
? raw.selectedEffort
: null;
const resolvedEffort =
raw.resolvedEffort === 'none' ||
raw.resolvedEffort === 'minimal' ||
raw.resolvedEffort === 'low' ||
raw.resolvedEffort === 'medium' ||
raw.resolvedEffort === 'high' ||
raw.resolvedEffort === 'xhigh' ||
raw.resolvedEffort === 'max'
? raw.resolvedEffort
: null;
return {
providerId,
providerBackendId:
migrateProviderBackendId(providerId, normalizeOptionalString(raw.providerBackendId)) ?? null,
selectedModel: normalizeOptionalString(raw.selectedModel),
selectedModelKind,
resolvedLaunchModel: normalizeOptionalString(raw.resolvedLaunchModel),
catalogId: normalizeOptionalString(raw.catalogId),
catalogSource,
catalogFetchedAt: normalizeOptionalString(raw.catalogFetchedAt),
selectedEffort,
resolvedEffort,
selectedFastMode: normalizeFastMode(raw.selectedFastMode),
resolvedFastMode: typeof raw.resolvedFastMode === 'boolean' ? raw.resolvedFastMode : null,
fastResolutionReason: normalizeOptionalString(raw.fastResolutionReason),
};
}
export class TeamMetaStore {
private getMetaPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'team.meta.json');
@ -105,11 +183,13 @@ export class TeamMetaStore {
),
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
fastMode: normalizeFastMode(file.fastMode) ?? undefined,
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
worktree: typeof file.worktree === 'string' ? file.worktree.trim() || undefined : undefined,
extraCliArgs:
typeof file.extraCliArgs === 'string' ? file.extraCliArgs.trim() || undefined : undefined,
limitContext: typeof file.limitContext === 'boolean' ? file.limitContext : undefined,
launchIdentity: normalizeLaunchIdentity(file.launchIdentity),
createdAt: typeof file.createdAt === 'number' ? file.createdAt : Date.now(),
};
}
@ -129,10 +209,12 @@ export class TeamMetaStore {
),
model: data.model?.trim() || undefined,
effort: data.effort?.trim() || undefined,
fastMode: normalizeFastMode(data.fastMode) ?? undefined,
skipPermissions: data.skipPermissions,
worktree: data.worktree?.trim() || undefined,
extraCliArgs: data.extraCliArgs?.trim() || undefined,
limitContext: data.limitContext,
launchIdentity: normalizeLaunchIdentity(data.launchIdentity),
createdAt: data.createdAt,
};
await atomicWriteAsync(this.getMetaPath(teamName), JSON.stringify(payload, null, 2));

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,10 @@ import {
} from './taskChangePresenceCacheSchema';
import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes';
import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes';
import type {
PersistedTaskChangePresence,
PersistedTaskChangePresenceIndex,
} from './taskChangePresenceCacheTypes';
import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository';
const logger = createLogger('Service:JsonTaskChangePresenceRepository');
@ -87,7 +90,7 @@ export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepos
entry: {
taskId: string;
taskSignature: string;
presence: 'has_changes' | 'no_changes';
presence: PersistedTaskChangePresence;
writtenAt: string;
logSourceGeneration: string;
}

View file

@ -1,4 +1,5 @@
import {
LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
type PersistedTaskChangePresence,
type PersistedTaskChangePresenceEntry,
type PersistedTaskChangePresenceIndex,
@ -10,7 +11,9 @@ function isIsoString(value: unknown): value is string {
}
function normalizePresence(value: unknown): PersistedTaskChangePresence | null {
return value === 'has_changes' || value === 'no_changes' ? value : null;
return value === 'has_changes' || value === 'needs_attention' || value === 'no_changes'
? value
: null;
}
function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null {
@ -47,8 +50,11 @@ export function normalizePersistedTaskChangePresenceIndex(
}
const raw = value as Record<string, unknown>;
const rawVersion =
typeof raw.version === 'number' ? raw.version : Number.NaN;
if (
raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION ||
(rawVersion !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION &&
rawVersion !== LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION) ||
typeof raw.teamName !== 'string' ||
typeof raw.projectFingerprint !== 'string' ||
raw.projectFingerprint.length === 0 ||

View file

@ -1,6 +1,7 @@
import type { TaskChangePresenceState } from '@shared/types/team';
export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1;
export const LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1;
export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 2;
export type PersistedTaskChangePresence = Exclude<TaskChangePresenceState, 'unknown'>;

View file

@ -16,12 +16,67 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher';
export { MemberStatsComputer } from './MemberStatsComputer';
export { ReviewApplierService } from './ReviewApplierService';
export { TaskBoundaryParser } from './TaskBoundaryParser';
export {
isTeamRuntimeProviderId,
OpenCodeTeamRuntimeAdapter,
TeamRuntimeAdapterRegistry,
TEAM_RUNTIME_PROVIDER_IDS,
} from './runtime';
export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge';
export type {
OpenCodeTeamLaunchMode,
OpenCodeTeamRuntimeAdapterOptions,
OpenCodeTeamRuntimeBridgePort,
TeamLaunchRuntimeAdapter,
TeamRuntimeLaunchInput,
TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberSpec,
TeamRuntimeMemberStopEvidence,
TeamRuntimePrepareFailure,
TeamRuntimePrepareResult,
TeamRuntimePrepareSuccess,
TeamRuntimeProviderId,
TeamRuntimeReconcileInput,
TeamRuntimeReconcileReason,
TeamRuntimeReconcileResult,
TeamRuntimeStopInput,
TeamRuntimeStopReason,
TeamRuntimeStopResult,
} from './runtime';
export type {
OpenCodeReadinessBridgeCommandBody,
OpenCodeReadinessBridgeCommandExecutor,
OpenCodeReadinessBridgeOptions,
} from './opencode/bridge/OpenCodeReadinessBridge';
export { BoardTaskActivityDetailService } from './taskLogs/activity/BoardTaskActivityDetailService';
export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource';
export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService';
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
export { OpenCodeTaskLogAttributionService } from './taskLogs/stream/OpenCodeTaskLogAttributionService';
export type {
OpenCodeTaskLogAttributionBulkWriteOutcome,
OpenCodeTaskLogAttributionMemberWindowInput,
OpenCodeTaskLogAttributionRecordDraft,
OpenCodeTaskLogAttributionRecordWriteOutcome,
OpenCodeTaskLogAttributionReplaceInput,
OpenCodeTaskLogAttributionTaskInput,
OpenCodeTaskLogAttributionTaskSessionInput,
OpenCodeTaskLogAttributionWriter,
} from './taskLogs/stream/OpenCodeTaskLogAttributionService';
export {
OpenCodeTaskLogAttributionStore,
getOpenCodeTaskLogAttributionPath,
} from './taskLogs/stream/OpenCodeTaskLogAttributionStore';
export type {
OpenCodeTaskLogAttributionReader,
OpenCodeTaskLogAttributionRecord,
OpenCodeTaskLogAttributionScope,
OpenCodeTaskLogAttributionSource,
OpenCodeTaskLogAttributionWriteResult,
} from './taskLogs/stream/OpenCodeTaskLogAttributionStore';
export { TeamAttachmentStore } from './TeamAttachmentStore';
export { TeamBackupService } from './TeamBackupService';
export { TeamConfigReader } from './TeamConfigReader';

View file

@ -1,8 +1,10 @@
import type { TeamProviderId } from '@shared/types';
export interface MemberDiffInput {
name: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerId?: TeamProviderId;
model?: string;
removedAt?: number | string | null;
}
@ -12,7 +14,7 @@ export interface ReplaceMembersDiff {
name: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerId?: TeamProviderId;
model?: string;
}[];
removed: string[];
@ -65,7 +67,7 @@ export function buildReplaceMembersDiff(
name: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
providerId?: TeamProviderId;
model?: string;
}[]
): ReplaceMembersDiff {

View file

@ -0,0 +1,267 @@
import { randomUUID } from 'crypto';
import { promises as fs } from 'fs';
import * as path from 'path';
import { execCli } from '@main/utils/childProcess';
import {
extractRunId,
OPEN_CODE_BRIDGE_SCHEMA_VERSION,
parseSingleBridgeJsonResult,
validateBridgeResultEnvelope,
type OpenCodeBridgeCommandEnvelope,
type OpenCodeBridgeCommandName,
type OpenCodeBridgeDiagnosticEvent,
type OpenCodeBridgeFailure,
type OpenCodeBridgeFailureKind,
type OpenCodeBridgeResult,
} from './OpenCodeBridgeCommandContract';
export interface OpenCodeBridgeProcessRunInput {
binaryPath: string;
args: string[];
cwd: string;
timeoutMs: number;
stdoutLimitBytes: number;
stderrLimitBytes: number;
env: NodeJS.ProcessEnv;
}
export interface OpenCodeBridgeProcessRunResult {
stdout: string;
stderr: string;
exitCode: number | null;
timedOut: boolean;
}
export interface OpenCodeBridgeProcessRunner {
run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult>;
}
export interface OpenCodeBridgeDiagnosticsSink {
append(event: OpenCodeBridgeDiagnosticEvent): Promise<void>;
}
export interface OpenCodeBridgeCommandClientOptions {
binaryPath: string;
tempDirectory: string;
processRunner?: OpenCodeBridgeProcessRunner;
diagnostics?: OpenCodeBridgeDiagnosticsSink;
requestIdFactory?: () => string;
diagnosticIdFactory?: () => string;
clock?: () => Date;
env?: NodeJS.ProcessEnv;
keepInputFile?: boolean;
}
const DEFAULT_STDOUT_LIMIT_BYTES = 1_000_000;
const DEFAULT_STDERR_LIMIT_BYTES = 256_000;
export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
try {
const result = await execCli(input.binaryPath, input.args, {
cwd: input.cwd,
timeout: input.timeoutMs,
maxBuffer: input.stdoutLimitBytes + input.stderrLimitBytes,
env: input.env,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
timedOut: false,
};
} catch (error) {
const failure = error as NodeJS.ErrnoException & {
stdout?: string | Buffer;
stderr?: string | Buffer;
killed?: boolean;
signal?: string;
};
const message = failure.message ?? '';
return {
stdout: bufferToString(failure.stdout),
stderr: bufferToString(failure.stderr) || message,
exitCode: typeof failure.code === 'number' ? failure.code : null,
timedOut:
failure.killed === true ||
failure.signal === 'SIGTERM' ||
/timed out|timeout/i.test(message),
};
}
}
}
export class OpenCodeBridgeCommandClient {
private readonly binaryPath: string;
private readonly tempDirectory: string;
private readonly processRunner: OpenCodeBridgeProcessRunner;
private readonly diagnostics: OpenCodeBridgeDiagnosticsSink | null;
private readonly requestIdFactory: () => string;
private readonly diagnosticIdFactory: () => string;
private readonly clock: () => Date;
private readonly env: NodeJS.ProcessEnv;
private readonly keepInputFile: boolean;
constructor(options: OpenCodeBridgeCommandClientOptions) {
this.binaryPath = options.binaryPath;
this.tempDirectory = options.tempDirectory;
this.processRunner = options.processRunner ?? new ExecCliOpenCodeBridgeProcessRunner();
this.diagnostics = options.diagnostics ?? null;
this.requestIdFactory = options.requestIdFactory ?? (() => `opencode-bridge-${randomUUID()}`);
this.diagnosticIdFactory =
options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`);
this.clock = options.clock ?? (() => new Date());
this.env = options.env ?? process.env;
this.keepInputFile = options.keepInputFile ?? false;
}
async execute<TBody, TData>(
command: OpenCodeBridgeCommandName,
body: TBody,
options: {
cwd: string;
timeoutMs: number;
requestId?: string;
stdoutLimitBytes?: number;
stderrLimitBytes?: number;
}
): Promise<OpenCodeBridgeResult<TData>> {
const envelope: OpenCodeBridgeCommandEnvelope<TBody> = {
schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION,
requestId: options.requestId ?? this.requestIdFactory(),
command,
cwd: options.cwd,
startedAt: this.clock().toISOString(),
timeoutMs: options.timeoutMs,
body,
};
const inputPath = await this.writeInputFile(envelope);
try {
const processResult = await this.processRunner.run({
binaryPath: this.binaryPath,
args: ['runtime', 'opencode-command', '--json', '--input', inputPath],
cwd: options.cwd,
timeoutMs: options.timeoutMs,
stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES,
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
env: this.env,
});
if (processResult.timedOut) {
return this.contractFailure(
envelope,
'timeout',
'OpenCode bridge command timed out',
true,
{
stderr: redactBridgeDiagnosticText(processResult.stderr),
}
);
}
if (processResult.exitCode !== 0) {
return this.contractFailure(
envelope,
'provider_error',
'OpenCode bridge command failed',
true,
{
exitCode: processResult.exitCode,
stderr: redactBridgeDiagnosticText(processResult.stderr),
}
);
}
const parsed = parseSingleBridgeJsonResult<TData>(processResult.stdout);
if (!parsed.ok) {
return this.contractFailure(envelope, 'contract_violation', parsed.error, false, {
stdoutPreview: redactBridgeDiagnosticText(processResult.stdout.slice(0, 2_000)),
});
}
const validation = validateBridgeResultEnvelope(parsed.value, envelope);
if (!validation.ok) {
return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {});
}
return parsed.value;
} finally {
if (!this.keepInputFile) {
await fs.unlink(inputPath).catch(() => undefined);
}
}
}
private async writeInputFile<TBody>(
envelope: OpenCodeBridgeCommandEnvelope<TBody>
): Promise<string> {
await fs.mkdir(this.tempDirectory, { recursive: true, mode: 0o700 });
const inputPath = path.join(this.tempDirectory, `opencode-command-${envelope.requestId}.json`);
await fs.writeFile(inputPath, `${JSON.stringify(envelope, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
return inputPath;
}
private async contractFailure<TBody>(
envelope: OpenCodeBridgeCommandEnvelope<TBody>,
kind: OpenCodeBridgeFailureKind,
message: string,
retryable: boolean,
details: Record<string, unknown>
): Promise<OpenCodeBridgeFailure> {
const completedAt = this.clock().toISOString();
const diagnostic: OpenCodeBridgeDiagnosticEvent = {
id: this.diagnosticIdFactory(),
type:
kind === 'timeout'
? 'opencode_bridge_unknown_outcome'
: 'opencode_bridge_contract_violation',
providerId: 'opencode',
runId: extractRunId(envelope.body) ?? undefined,
severity: retryable ? 'warning' : 'error',
message,
data: details,
createdAt: completedAt,
};
await this.diagnostics?.append(diagnostic);
return {
ok: false,
schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION,
requestId: envelope.requestId,
command: envelope.command,
completedAt,
durationMs: Math.max(0, Date.parse(completedAt) - Date.parse(envelope.startedAt)),
error: {
kind,
message,
retryable,
details,
},
diagnostics: [diagnostic],
};
}
}
export function redactBridgeDiagnosticText(value: string): string {
const capped = value.length > 4_000 ? `${value.slice(0, 4_000)}...[truncated]` : value;
return capped
.replace(/(authorization:\s*bearer\s+)[^\s]+/gi, '$1[redacted]')
.replace(/((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s"'`]+/gi, '$1[redacted]');
}
function bufferToString(value: string | Buffer | undefined): string {
if (typeof value === 'string') {
return value;
}
if (Buffer.isBuffer(value)) {
return value.toString('utf8');
}
return '';
}

View file

@ -0,0 +1,813 @@
import { createHash } from 'crypto';
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
export type OpenCodeBridgeCommandName =
| 'opencode.handshake'
| 'opencode.commandStatus'
| 'opencode.readiness'
| 'opencode.launchTeam'
| 'opencode.reconcileTeam'
| 'opencode.stopTeam'
| 'opencode.answerPermission'
| 'opencode.listRuntimePermissions'
| 'opencode.getRuntimeTranscript'
| 'opencode.recoverDeliveryJournal';
export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production';
export type OpenCodeTeamLaunchBridgeState =
| 'blocked'
| 'launching'
| 'ready'
| 'permission_blocked'
| 'failed';
export type OpenCodeTeamMemberLaunchBridgeState =
| 'created'
| 'confirmed_alive'
| 'permission_blocked'
| 'failed';
export interface OpenCodeTeamBridgeDiagnostic {
code: string;
severity: 'info' | 'warning' | 'error';
message: string;
}
export interface OpenCodeTeamBridgeWarning {
code: string;
message: string;
}
export interface OpenCodeTeamLaunchMemberCommandSpec {
name: string;
role: string;
prompt: string;
}
export interface OpenCodeLaunchTeamCommandBody {
mode: OpenCodeTeamLaunchMode;
runId: string;
teamId: string;
teamName: string;
projectPath: string;
selectedModel: string;
members: OpenCodeTeamLaunchMemberCommandSpec[];
leadPrompt: string;
expectedCapabilitySnapshotId: string | null;
manifestHighWatermark: number | null;
}
export interface OpenCodeTeamMemberLaunchCommandData {
sessionId: string;
launchState: OpenCodeTeamMemberLaunchBridgeState;
model: string;
evidence: Array<{ kind: string; observedAt: string }>;
}
export interface OpenCodeLaunchTeamCommandData {
runId: string;
teamLaunchState: OpenCodeTeamLaunchBridgeState;
members: Record<string, OpenCodeTeamMemberLaunchCommandData>;
warnings: OpenCodeTeamBridgeWarning[];
diagnostics: OpenCodeTeamBridgeDiagnostic[];
idempotencyKey?: string;
manifestHighWatermark?: number | null;
runtimeStoreManifestHighWatermark?: number | null;
durableCheckpoints?: Array<{ name: string; memberName?: string | null; observedAt: string }>;
}
export interface OpenCodeReconcileTeamCommandBody {
runId: string;
teamId: string;
teamName: string;
projectPath?: string;
expectedCapabilitySnapshotId?: string | null;
manifestHighWatermark?: number | null;
reconcileAttemptId?: string;
expectedMembers: Array<{ name: string; model: string | null }>;
reason: string;
}
export interface OpenCodeStopTeamCommandBody {
runId: string;
teamId: string;
teamName: string;
projectPath?: string;
expectedCapabilitySnapshotId?: string | null;
manifestHighWatermark?: number | null;
reason: string;
force?: boolean;
}
export interface OpenCodeStopTeamCommandData {
runId: string;
stopped: boolean;
members: Record<string, { sessionId?: string; stopped: boolean; diagnostics: string[] }>;
warnings: OpenCodeTeamBridgeWarning[];
diagnostics: OpenCodeTeamBridgeDiagnostic[];
idempotencyKey?: string;
manifestHighWatermark?: number | null;
runtimeStoreManifestHighWatermark?: number | null;
}
export type OpenCodeBridgePeerName = 'claude_team' | 'agent_teams_orchestrator';
export type OpenCodeBridgeFailureKind =
| 'unsupported_schema'
| 'unsupported_command'
| 'invalid_input'
| 'runtime_not_ready'
| 'provider_error'
| 'timeout'
| 'contract_violation'
| 'internal_error';
export interface OpenCodeBridgeDiagnosticEvent {
id?: string;
type: string;
providerId: 'opencode';
teamName?: string;
runId?: string;
severity: 'info' | 'warning' | 'error';
message: string;
data?: Record<string, unknown>;
createdAt: string;
}
export interface OpenCodeBridgeCommandEnvelope<TBody> {
schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION;
requestId: string;
command: OpenCodeBridgeCommandName;
cwd: string;
startedAt: string;
timeoutMs: number;
body: TBody;
}
export interface OpenCodeBridgeRuntimeSnapshot {
providerId: 'opencode';
binaryPath: string | null;
binaryFingerprint: string | null;
version: string | null;
capabilitySnapshotId: string | null;
}
export interface OpenCodeBridgeSuccess<TData> {
ok: true;
schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION;
requestId: string;
command: OpenCodeBridgeCommandName;
completedAt: string;
durationMs: number;
runtime: OpenCodeBridgeRuntimeSnapshot;
diagnostics: OpenCodeBridgeDiagnosticEvent[];
data: TData;
}
export interface OpenCodeBridgeFailure {
ok: false;
schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION;
requestId: string;
command: OpenCodeBridgeCommandName;
completedAt: string;
durationMs: number;
error: {
kind: OpenCodeBridgeFailureKind;
message: string;
retryable: boolean;
details?: Record<string, unknown>;
};
diagnostics: OpenCodeBridgeDiagnosticEvent[];
}
export type OpenCodeBridgeResult<TData> = OpenCodeBridgeSuccess<TData> | OpenCodeBridgeFailure;
export interface OpenCodeBridgePeerIdentity {
schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION;
peer: OpenCodeBridgePeerName;
appVersion: string;
gitSha: string | null;
buildId: string | null;
bridgeProtocol: {
minVersion: number;
currentVersion: number;
supportedCommands: OpenCodeBridgeCommandName[];
};
runtime: {
providerId: 'opencode';
binaryPath: string | null;
binaryFingerprint: string | null;
version: string | null;
capabilitySnapshotId: string | null;
runtimeStoreManifestHighWatermark: number | null;
activeRunId: string | null;
};
featureFlags: {
opencodeTeamLaunch: boolean;
opencodeStateChangingCommands: boolean;
};
}
export interface OpenCodeBridgeHandshake {
schemaVersion: typeof OPEN_CODE_BRIDGE_SCHEMA_VERSION;
requestId: string;
client: OpenCodeBridgePeerIdentity;
server: OpenCodeBridgePeerIdentity;
agreedProtocolVersion: number;
acceptedCommands: OpenCodeBridgeCommandName[];
serverTime: string;
identityHash: string;
}
export interface OpenCodeBridgeCommandPreconditions {
handshakeIdentityHash: string;
expectedRunId: string | null;
expectedCapabilitySnapshotId: string | null;
expectedBehaviorFingerprint: string | null;
expectedManifestHighWatermark: number | null;
commandLeaseId: string | null;
idempotencyKey: string;
}
export interface OpenCodeStateChangingBridgeEnvelope<
TBody,
> extends OpenCodeBridgeCommandEnvelope<TBody> {
stateChanging: true;
preconditions: OpenCodeBridgeCommandPreconditions;
}
export interface RuntimeStoreManifestEvidence {
highWatermark: number;
activeRunId?: string | null;
capabilitySnapshotId?: string | null;
}
const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = new Set([
'opencode.handshake',
'opencode.commandStatus',
'opencode.readiness',
'opencode.launchTeam',
'opencode.reconcileTeam',
'opencode.stopTeam',
'opencode.answerPermission',
'opencode.listRuntimePermissions',
'opencode.getRuntimeTranscript',
'opencode.recoverDeliveryJournal',
]);
const VALID_FAILURE_KINDS: ReadonlySet<OpenCodeBridgeFailureKind> = new Set([
'unsupported_schema',
'unsupported_command',
'invalid_input',
'runtime_not_ready',
'provider_error',
'timeout',
'contract_violation',
'internal_error',
]);
export function isOpenCodeBridgeCommandName(value: unknown): value is OpenCodeBridgeCommandName {
return typeof value === 'string' && VALID_COMMANDS.has(value as OpenCodeBridgeCommandName);
}
export function parseSingleBridgeJsonResult<TData>(
stdout: string
): { ok: true; value: OpenCodeBridgeResult<TData> } | { ok: false; error: string } {
const trimmed = stdout.trim();
if (!trimmed) {
return { ok: false, error: 'Bridge stdout was empty' };
}
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim().length > 0);
if (lines.length !== 1) {
return {
ok: false,
error: `Bridge stdout must contain exactly one JSON line, got ${lines.length}`,
};
}
let parsed: unknown;
try {
parsed = JSON.parse(lines[0]);
} catch (error) {
return { ok: false, error: `Bridge stdout JSON parse failed: ${stringifyError(error)}` };
}
const validation = validateOpenCodeBridgeResultShape(parsed);
if (!validation.ok) {
return { ok: false, error: validation.reason };
}
return { ok: true, value: validation.value as OpenCodeBridgeResult<TData> };
}
export function validateBridgeResultEnvelope<TBody, TData>(
result: OpenCodeBridgeResult<TData>,
envelope: Pick<OpenCodeBridgeCommandEnvelope<TBody>, 'schemaVersion' | 'requestId' | 'command'>
): { ok: true } | { ok: false; reason: string } {
const shape = validateOpenCodeBridgeResultShape(result);
if (!shape.ok) {
return { ok: false, reason: shape.reason };
}
if (result.schemaVersion !== envelope.schemaVersion) {
return { ok: false, reason: 'OpenCode bridge schemaVersion mismatch' };
}
if (result.requestId !== envelope.requestId) {
return { ok: false, reason: 'OpenCode bridge requestId mismatch' };
}
if (result.command !== envelope.command) {
return { ok: false, reason: 'OpenCode bridge command mismatch' };
}
return { ok: true };
}
export function assertBridgeResultCanMutateState<TData>(
result: OpenCodeBridgeResult<TData>,
expected: {
requestId: string;
command: OpenCodeBridgeCommandName;
runId: string | null;
capabilitySnapshotId: string | null;
}
): asserts result is OpenCodeBridgeSuccess<TData> {
if (!result.ok) {
throw new Error(
`OpenCode bridge command failed: ${result.error.kind}: ${result.error.message}`
);
}
if (result.requestId !== expected.requestId) {
throw new Error('OpenCode bridge requestId mismatch');
}
if (result.command !== expected.command) {
throw new Error('OpenCode bridge command mismatch');
}
if (extractRunId(result.data) !== expected.runId) {
throw new Error('OpenCode bridge runId mismatch');
}
if (
expected.capabilitySnapshotId !== null &&
result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId
) {
throw new Error('OpenCode bridge capability snapshot mismatch');
}
}
export function validateOpenCodeBridgeHandshake(input: {
handshake: OpenCodeBridgeHandshake;
expectedClient: OpenCodeBridgePeerIdentity;
requiredCommand: OpenCodeBridgeCommandName;
expectedCapabilitySnapshotId: string | null;
expectedManifestHighWatermark: number | null;
expectedRunId: string | null;
}): { ok: true } | { ok: false; reason: string } {
const shape = validateOpenCodeBridgeHandshakeShape(input.handshake);
if (!shape.ok) {
return shape;
}
if (input.handshake.client.peer !== input.expectedClient.peer) {
return { ok: false, reason: 'Bridge handshake client peer mismatch' };
}
if (stableHash(input.handshake.client) !== stableHash(input.expectedClient)) {
return { ok: false, reason: 'Bridge handshake client identity mismatch' };
}
const minimumProtocol = Math.max(
input.handshake.client.bridgeProtocol.minVersion,
input.handshake.server.bridgeProtocol.minVersion
);
const maximumProtocol = Math.min(
input.handshake.client.bridgeProtocol.currentVersion,
input.handshake.server.bridgeProtocol.currentVersion
);
if (
input.handshake.agreedProtocolVersion < minimumProtocol ||
input.handshake.agreedProtocolVersion > maximumProtocol
) {
return { ok: false, reason: 'Bridge handshake protocol version mismatch' };
}
if (!input.handshake.acceptedCommands.includes(input.requiredCommand)) {
return { ok: false, reason: `Bridge server does not accept command ${input.requiredCommand}` };
}
if (!input.handshake.server.bridgeProtocol.supportedCommands.includes(input.requiredCommand)) {
return { ok: false, reason: `Bridge server does not support command ${input.requiredCommand}` };
}
if (
input.expectedCapabilitySnapshotId &&
input.handshake.server.runtime.capabilitySnapshotId !== input.expectedCapabilitySnapshotId
) {
return { ok: false, reason: 'Bridge server capability snapshot mismatch' };
}
if (
input.expectedRunId &&
input.handshake.server.runtime.activeRunId &&
input.handshake.server.runtime.activeRunId !== input.expectedRunId
) {
return { ok: false, reason: 'Bridge server active run mismatch' };
}
const serverHighWatermark = input.handshake.server.runtime.runtimeStoreManifestHighWatermark;
if (
input.expectedManifestHighWatermark !== null &&
serverHighWatermark !== null &&
serverHighWatermark < input.expectedManifestHighWatermark
) {
return { ok: false, reason: 'Bridge server runtime manifest high watermark is stale' };
}
const expectedIdentityHash = createOpenCodeBridgeHandshakeIdentityHash(input.handshake);
if (input.handshake.identityHash !== expectedIdentityHash) {
return { ok: false, reason: 'Bridge handshake identity hash mismatch' };
}
return { ok: true };
}
export function createOpenCodeBridgeHandshakeIdentityHash(
handshake: Omit<OpenCodeBridgeHandshake, 'identityHash'> | OpenCodeBridgeHandshake
): string {
const { identityHash: _ignored, ...hashable } = handshake as OpenCodeBridgeHandshake;
return stableHash(hashable);
}
export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
result: OpenCodeBridgeResult<unknown>;
requestId: string;
command: OpenCodeBridgeCommandName;
runId: string | null;
capabilitySnapshotId: string | null;
manifest: RuntimeStoreManifestEvidence;
idempotencyKey: string;
}): asserts input is {
result: OpenCodeBridgeSuccess<unknown>;
requestId: string;
command: OpenCodeBridgeCommandName;
runId: string | null;
capabilitySnapshotId: string | null;
manifest: RuntimeStoreManifestEvidence;
idempotencyKey: string;
} {
assertBridgeResultCanMutateState(input.result, {
requestId: input.requestId,
command: input.command,
runId: input.runId,
capabilitySnapshotId: input.capabilitySnapshotId,
});
const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data);
if (
typeof resultManifestHighWatermark === 'number' &&
resultManifestHighWatermark < input.manifest.highWatermark
) {
throw new Error('Bridge result manifest high watermark is stale');
}
if (extractIdempotencyKey(input.result.data) !== input.idempotencyKey) {
throw new Error('Bridge result idempotency key mismatch');
}
}
export function createOpenCodeBridgeIdempotencyKey(input: {
command: OpenCodeBridgeCommandName;
teamName: string;
runId: string | null;
body: unknown;
}): string {
const scope = [
'opencode',
sanitizeKeyPart(input.command),
sanitizeKeyPart(input.teamName),
sanitizeKeyPart(input.runId ?? 'no-run'),
].join(':');
return `${scope}:${stableHash(input).slice(0, 32)}`;
}
export function stableHash(value: unknown): string {
return createHash('sha256').update(stableJsonStringify(value)).digest('hex');
}
export function stableJsonStringify(value: unknown): string {
return JSON.stringify(normalizeStableJson(value));
}
export function extractRunId(value: unknown): string | null {
return (
extractStringByPath(value, ['runId']) ??
extractStringByPath(value, ['runtimeRunId']) ??
extractStringByPath(value, ['runtime', 'runId']) ??
extractStringByPath(value, ['launch', 'runId'])
);
}
export function extractIdempotencyKey(value: unknown): string | null {
return (
extractStringByPath(value, ['idempotencyKey']) ??
extractStringByPath(value, ['preconditions', 'idempotencyKey']) ??
extractStringByPath(value, ['command', 'idempotencyKey'])
);
}
export function extractManifestHighWatermark(value: unknown): number | null {
return (
extractNumberByPath(value, ['runtimeStoreManifestHighWatermark']) ??
extractNumberByPath(value, ['manifestHighWatermark']) ??
extractNumberByPath(value, ['manifest', 'highWatermark'])
);
}
function validateOpenCodeBridgeResultShape(
value: unknown
): { ok: true; value: OpenCodeBridgeResult<unknown> } | { ok: false; reason: string } {
if (!isRecord(value)) {
return { ok: false, reason: 'Bridge result must be a JSON object' };
}
if (value.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION) {
return { ok: false, reason: 'Bridge result has unsupported schemaVersion' };
}
if (typeof value.ok !== 'boolean') {
return { ok: false, reason: 'Bridge result missing ok boolean' };
}
if (typeof value.requestId !== 'string' || !value.requestId.trim()) {
return { ok: false, reason: 'Bridge result missing requestId' };
}
if (!isOpenCodeBridgeCommandName(value.command)) {
return { ok: false, reason: 'Bridge result has unsupported command' };
}
if (typeof value.completedAt !== 'string' || !value.completedAt.trim()) {
return { ok: false, reason: 'Bridge result missing completedAt' };
}
if (!isNonNegativeFiniteNumber(value.durationMs)) {
return { ok: false, reason: 'Bridge result has invalid durationMs' };
}
if (!Array.isArray(value.diagnostics) || !value.diagnostics.every(isDiagnosticEvent)) {
return { ok: false, reason: 'Bridge result diagnostics are invalid' };
}
if (value.ok) {
if (!isRuntimeSnapshot(value.runtime)) {
return { ok: false, reason: 'Bridge success runtime snapshot is invalid' };
}
if (!Object.prototype.hasOwnProperty.call(value, 'data')) {
return { ok: false, reason: 'Bridge success missing data' };
}
return { ok: true, value: value as unknown as OpenCodeBridgeSuccess<unknown> };
}
if (!isRecord(value.error)) {
return { ok: false, reason: 'Bridge failure missing error object' };
}
if (!VALID_FAILURE_KINDS.has(value.error.kind as OpenCodeBridgeFailureKind)) {
return { ok: false, reason: 'Bridge failure has unsupported error kind' };
}
if (typeof value.error.message !== 'string' || !value.error.message.trim()) {
return { ok: false, reason: 'Bridge failure missing error message' };
}
if (typeof value.error.retryable !== 'boolean') {
return { ok: false, reason: 'Bridge failure missing retryable boolean' };
}
if (
value.error.details !== undefined &&
(value.error.details === null || !isRecord(value.error.details))
) {
return { ok: false, reason: 'Bridge failure details must be an object' };
}
return { ok: true, value: value as unknown as OpenCodeBridgeFailure };
}
function validateOpenCodeBridgeHandshakeShape(
handshake: OpenCodeBridgeHandshake
): { ok: true } | { ok: false; reason: string } {
if (!isRecord(handshake)) {
return { ok: false, reason: 'Bridge handshake must be a JSON object' };
}
if (handshake.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION) {
return { ok: false, reason: 'Bridge handshake has unsupported schemaVersion' };
}
if (typeof handshake.requestId !== 'string' || !handshake.requestId.trim()) {
return { ok: false, reason: 'Bridge handshake missing requestId' };
}
if (!isPeerIdentity(handshake.client) || !isPeerIdentity(handshake.server)) {
return { ok: false, reason: 'Bridge handshake peer identity is invalid' };
}
if (!Number.isInteger(handshake.agreedProtocolVersion) || handshake.agreedProtocolVersion < 1) {
return { ok: false, reason: 'Bridge handshake protocol version is invalid' };
}
if (
!Array.isArray(handshake.acceptedCommands) ||
!handshake.acceptedCommands.every(isOpenCodeBridgeCommandName)
) {
return { ok: false, reason: 'Bridge handshake accepted commands are invalid' };
}
if (typeof handshake.serverTime !== 'string' || !handshake.serverTime.trim()) {
return { ok: false, reason: 'Bridge handshake serverTime is invalid' };
}
if (typeof handshake.identityHash !== 'string' || !handshake.identityHash.trim()) {
return { ok: false, reason: 'Bridge handshake identityHash is invalid' };
}
return { ok: true };
}
function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
if (!isRecord(value)) {
return false;
}
if (
value.schemaVersion !== OPEN_CODE_BRIDGE_SCHEMA_VERSION ||
(value.peer !== 'claude_team' && value.peer !== 'agent_teams_orchestrator') ||
typeof value.appVersion !== 'string' ||
!isNullableString(value.gitSha) ||
!isNullableString(value.buildId)
) {
return false;
}
const bridgeProtocol = value.bridgeProtocol;
if (!isRecord(bridgeProtocol)) {
return false;
}
if (
!Number.isInteger(bridgeProtocol.minVersion) ||
!Number.isInteger(bridgeProtocol.currentVersion) ||
(bridgeProtocol.minVersion as number) < 1 ||
(bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) ||
!Array.isArray(bridgeProtocol.supportedCommands) ||
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName)
) {
return false;
}
const runtime = value.runtime;
if (!isRecord(runtime) || runtime.providerId !== 'opencode') {
return false;
}
if (
!isNullableString(runtime.binaryPath) ||
!isNullableString(runtime.binaryFingerprint) ||
!isNullableString(runtime.version) ||
!isNullableString(runtime.capabilitySnapshotId) ||
!isNullableInteger(runtime.runtimeStoreManifestHighWatermark) ||
!isNullableString(runtime.activeRunId)
) {
return false;
}
const featureFlags = value.featureFlags;
if (!isRecord(featureFlags)) {
return false;
}
return (
typeof featureFlags.opencodeTeamLaunch === 'boolean' &&
typeof featureFlags.opencodeStateChangingCommands === 'boolean'
);
}
function isRuntimeSnapshot(value: unknown): value is OpenCodeBridgeRuntimeSnapshot {
return (
isRecord(value) &&
value.providerId === 'opencode' &&
isNullableString(value.binaryPath) &&
isNullableString(value.binaryFingerprint) &&
isNullableString(value.version) &&
isNullableString(value.capabilitySnapshotId)
);
}
function isDiagnosticEvent(value: unknown): value is OpenCodeBridgeDiagnosticEvent {
return (
isRecord(value) &&
value.providerId === 'opencode' &&
typeof value.type === 'string' &&
value.type.trim().length > 0 &&
(value.severity === 'info' || value.severity === 'warning' || value.severity === 'error') &&
typeof value.message === 'string' &&
value.message.trim().length > 0 &&
typeof value.createdAt === 'string' &&
value.createdAt.trim().length > 0 &&
(value.data === undefined || isRecord(value.data))
);
}
function extractStringByPath(value: unknown, pathParts: string[]): string | null {
const nested = getByPath(value, pathParts);
return typeof nested === 'string' && nested.trim() ? nested : null;
}
function extractNumberByPath(value: unknown, pathParts: string[]): number | null {
const nested = getByPath(value, pathParts);
return isNonNegativeFiniteNumber(nested) ? nested : null;
}
function getByPath(value: unknown, pathParts: string[]): unknown {
let current = value;
for (const part of pathParts) {
if (!isRecord(current)) {
return undefined;
}
current = current[part];
}
return current;
}
function sanitizeKeyPart(value: string): string {
const sanitized = value
.trim()
.replace(/[^a-zA-Z0-9_.-]+/g, '_')
.replace(/^_+|_+$/g, '');
return sanitized.slice(0, 64) || 'unknown';
}
function stableJsonComparableNumber(value: number): number | string {
if (Number.isFinite(value)) {
return value;
}
return String(value);
}
function normalizeStableJson(value: unknown): unknown {
if (value === null) {
return null;
}
if (typeof value === 'number') {
return stableJsonComparableNumber(value);
}
if (typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(normalizeStableJson);
}
const output: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
const nested = (value as Record<string, unknown>)[key];
if (nested !== undefined) {
output[key] = normalizeStableJson(nested);
}
}
return output;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function isNullableInteger(value: unknown): value is number | null {
return value === null || (Number.isInteger(value) && (value as number) >= 0);
}
function isNonNegativeFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,438 @@
import {
createOpenCodeBridgeIdempotencyKey,
isOpenCodeBridgeCommandName,
stableHash,
type OpenCodeBridgeCommandName,
} from './OpenCodeBridgeCommandContract';
import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore';
export const OPEN_CODE_BRIDGE_COMMAND_LEDGER_SCHEMA_VERSION = 1;
export const OPEN_CODE_BRIDGE_COMMAND_LEASE_SCHEMA_VERSION = 1;
export type OpenCodeBridgeCommandLedgerStatus =
| 'started'
| 'completed'
| 'failed'
| 'unknown_after_timeout';
export interface OpenCodeBridgeCommandLedgerEntry {
idempotencyKey: string;
requestId: string;
command: OpenCodeBridgeCommandName;
teamName: string;
runId: string | null;
requestHash: string;
responseHash: string | null;
status: OpenCodeBridgeCommandLedgerStatus;
retryable: boolean;
startedAt: string;
completedAt: string | null;
lastError: string | null;
}
export interface OpenCodeBridgeCommandLease {
leaseId: string;
teamName: string;
runId: string | null;
command: OpenCodeBridgeCommandName;
holderPeer: 'claude_team';
acquiredAt: string;
expiresAt: string;
state: 'active' | 'released' | 'expired';
}
export type OpenCodeBridgeLedgerBeginResult = 'started' | 'duplicate_same_payload_completed';
export class OpenCodeBridgeCommandLedgerError extends Error {
constructor(message: string) {
super(message);
this.name = 'OpenCodeBridgeCommandLedgerError';
}
}
export class OpenCodeBridgeCommandLeaseError extends Error {
constructor(message: string) {
super(message);
this.name = 'OpenCodeBridgeCommandLeaseError';
}
}
export class OpenCodeBridgeCommandLedger {
constructor(
private readonly store: VersionedJsonStore<OpenCodeBridgeCommandLedgerEntry[]>,
private readonly clock: () => Date = () => new Date()
) {}
async begin(input: {
idempotencyKey: string;
requestId: string;
command: OpenCodeBridgeCommandName;
teamName: string;
runId: string | null;
requestHash: string;
}): Promise<OpenCodeBridgeLedgerBeginResult> {
let outcome: OpenCodeBridgeLedgerBeginResult = 'started';
await this.store.updateLocked((entries) => {
const existing = entries.find((entry) => entry.idempotencyKey === input.idempotencyKey);
if (existing) {
if (existing.requestHash !== input.requestHash) {
throw new OpenCodeBridgeCommandLedgerError(
'OpenCode bridge idempotency key reused with different payload'
);
}
if (existing.status === 'unknown_after_timeout') {
throw new OpenCodeBridgeCommandLedgerError(
'OpenCode bridge command outcome must be reconciled before retry'
);
}
if (existing.status === 'started') {
throw new OpenCodeBridgeCommandLedgerError('OpenCode bridge command already started');
}
if (existing.status === 'completed') {
outcome = 'duplicate_same_payload_completed';
return entries;
}
throw new OpenCodeBridgeCommandLedgerError(
`OpenCode bridge command cannot be retried from status ${existing.status}`
);
}
const now = this.clock().toISOString();
return [
...entries,
{
idempotencyKey: input.idempotencyKey,
requestId: input.requestId,
command: input.command,
teamName: input.teamName,
runId: input.runId,
requestHash: input.requestHash,
responseHash: null,
status: 'started',
retryable: false,
startedAt: now,
completedAt: null,
lastError: null,
},
];
});
return outcome;
}
async markCompleted(input: {
idempotencyKey: string;
response: unknown;
completedAt?: Date;
}): Promise<void> {
await this.updateExisting(input.idempotencyKey, (entry) => ({
...entry,
responseHash: stableHash(input.response),
status: 'completed',
retryable: false,
completedAt: (input.completedAt ?? this.clock()).toISOString(),
lastError: null,
}));
}
async markFailed(input: {
idempotencyKey: string;
error: string;
retryable: boolean;
completedAt?: Date;
}): Promise<void> {
await this.updateExisting(input.idempotencyKey, (entry) => ({
...entry,
status: 'failed',
retryable: input.retryable,
completedAt: (input.completedAt ?? this.clock()).toISOString(),
lastError: input.error,
}));
}
async markUnknownAfterTimeout(input: { idempotencyKey: string; error: string }): Promise<void> {
await this.updateExisting(input.idempotencyKey, (entry) => ({
...entry,
status: 'unknown_after_timeout',
retryable: false,
completedAt: null,
lastError: input.error,
}));
}
async getByIdempotencyKey(
idempotencyKey: string
): Promise<OpenCodeBridgeCommandLedgerEntry | null> {
const entries = await this.readRequired();
return entries.find((entry) => entry.idempotencyKey === idempotencyKey) ?? null;
}
async list(): Promise<OpenCodeBridgeCommandLedgerEntry[]> {
return this.readRequired();
}
private async updateExisting(
idempotencyKey: string,
updater: (entry: OpenCodeBridgeCommandLedgerEntry) => OpenCodeBridgeCommandLedgerEntry
): Promise<void> {
let found = false;
await this.store.updateLocked((entries) =>
entries.map((entry) => {
if (entry.idempotencyKey !== idempotencyKey) {
return entry;
}
found = true;
return updater(entry);
})
);
if (!found) {
throw new OpenCodeBridgeCommandLedgerError(
`OpenCode bridge command ledger entry not found: ${idempotencyKey}`
);
}
}
private async readRequired(): Promise<OpenCodeBridgeCommandLedgerEntry[]> {
const result = await this.store.read();
if (!result.ok) {
throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath);
}
return result.data;
}
}
export class OpenCodeBridgeCommandLeaseStore {
constructor(
private readonly store: VersionedJsonStore<OpenCodeBridgeCommandLease[]>,
private readonly idFactory: () => string,
private readonly clock: () => Date = () => new Date()
) {}
async acquire(input: {
teamName: string;
runId: string | null;
command: OpenCodeBridgeCommandName;
ttlMs: number;
}): Promise<OpenCodeBridgeCommandLease> {
let created: OpenCodeBridgeCommandLease | null = null;
await this.store.updateLocked((leases) => {
const now = this.clock();
const nowMs = now.getTime();
const normalized = leases.map((lease) =>
lease.state === 'active' && Date.parse(lease.expiresAt) <= nowMs
? { ...lease, state: 'expired' as const }
: lease
);
const active = normalized.find(
(lease) =>
lease.teamName === input.teamName &&
lease.state === 'active' &&
Date.parse(lease.expiresAt) > nowMs
);
if (active) {
throw new OpenCodeBridgeCommandLeaseError(
`OpenCode bridge command lease already active: ${active.leaseId}`
);
}
created = {
leaseId: this.idFactory(),
teamName: input.teamName,
runId: input.runId,
command: input.command,
holderPeer: 'claude_team',
acquiredAt: now.toISOString(),
expiresAt: new Date(nowMs + input.ttlMs).toISOString(),
state: 'active',
};
return [...normalized, created];
});
if (!created) {
throw new OpenCodeBridgeCommandLeaseError('OpenCode bridge command lease was not created');
}
return created;
}
async release(leaseId: string): Promise<void> {
let found = false;
await this.store.updateLocked((leases) =>
leases.map((lease) => {
if (lease.leaseId !== leaseId) {
return lease;
}
found = true;
return { ...lease, state: 'released' as const };
})
);
if (!found) {
throw new OpenCodeBridgeCommandLeaseError(
`OpenCode bridge command lease not found: ${leaseId}`
);
}
}
async getActive(teamName: string): Promise<OpenCodeBridgeCommandLease | null> {
const result = await this.store.read();
if (!result.ok) {
throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath);
}
const nowMs = this.clock().getTime();
return (
result.data.find(
(lease) =>
lease.teamName === teamName &&
lease.state === 'active' &&
Date.parse(lease.expiresAt) > nowMs
) ?? null
);
}
}
export function createOpenCodeBridgeCommandLedgerStore(options: {
filePath: string;
clock?: () => Date;
}): OpenCodeBridgeCommandLedger {
const clock = options.clock ?? (() => new Date());
return new OpenCodeBridgeCommandLedger(
new VersionedJsonStore<OpenCodeBridgeCommandLedgerEntry[]>({
filePath: options.filePath,
schemaVersion: OPEN_CODE_BRIDGE_COMMAND_LEDGER_SCHEMA_VERSION,
defaultData: () => [],
validate: validateLedgerEntries,
clock,
}),
clock
);
}
export function createOpenCodeBridgeCommandLeaseStore(options: {
filePath: string;
idFactory?: () => string;
clock?: () => Date;
}): OpenCodeBridgeCommandLeaseStore {
const clock = options.clock ?? (() => new Date());
return new OpenCodeBridgeCommandLeaseStore(
new VersionedJsonStore<OpenCodeBridgeCommandLease[]>({
filePath: options.filePath,
schemaVersion: OPEN_CODE_BRIDGE_COMMAND_LEASE_SCHEMA_VERSION,
defaultData: () => [],
validate: validateLeases,
clock,
}),
options.idFactory ??
(() =>
createOpenCodeBridgeIdempotencyKey({
command: 'opencode.commandStatus',
teamName: 'lease',
runId: null,
body: { now: clock().toISOString(), random: Math.random() },
})),
clock
);
}
export function validateLedgerEntries(value: unknown): OpenCodeBridgeCommandLedgerEntry[] {
if (!Array.isArray(value)) {
throw new Error('OpenCode bridge command ledger must be an array');
}
const seen = new Set<string>();
return value.map((entry, index) => {
if (!isLedgerEntry(entry)) {
throw new Error(`Invalid OpenCode bridge command ledger entry at index ${index}`);
}
if (seen.has(entry.idempotencyKey)) {
throw new Error(`Duplicate OpenCode bridge ledger idempotencyKey at index ${index}`);
}
seen.add(entry.idempotencyKey);
return entry;
});
}
export function validateLeases(value: unknown): OpenCodeBridgeCommandLease[] {
if (!Array.isArray(value)) {
throw new Error('OpenCode bridge command leases must be an array');
}
const seen = new Set<string>();
return value.map((lease, index) => {
if (!isLease(lease)) {
throw new Error(`Invalid OpenCode bridge command lease at index ${index}`);
}
if (seen.has(lease.leaseId)) {
throw new Error(`Duplicate OpenCode bridge leaseId at index ${index}`);
}
seen.add(lease.leaseId);
return lease;
});
}
function isLedgerEntry(value: unknown): value is OpenCodeBridgeCommandLedgerEntry {
return (
isRecord(value) &&
isNonEmptyString(value.idempotencyKey) &&
isNonEmptyString(value.requestId) &&
isOpenCodeBridgeCommandName(value.command) &&
isNonEmptyString(value.teamName) &&
isNullableString(value.runId) &&
isNonEmptyString(value.requestHash) &&
isNullableString(value.responseHash) &&
isLedgerStatus(value.status) &&
typeof value.retryable === 'boolean' &&
isNonEmptyString(value.startedAt) &&
isNullableString(value.completedAt) &&
isNullableString(value.lastError) &&
Number.isFinite(Date.parse(value.startedAt)) &&
(value.completedAt === null || Number.isFinite(Date.parse(value.completedAt)))
);
}
function isLease(value: unknown): value is OpenCodeBridgeCommandLease {
return (
isRecord(value) &&
isNonEmptyString(value.leaseId) &&
isNonEmptyString(value.teamName) &&
isNullableString(value.runId) &&
isOpenCodeBridgeCommandName(value.command) &&
value.holderPeer === 'claude_team' &&
isNonEmptyString(value.acquiredAt) &&
isNonEmptyString(value.expiresAt) &&
Number.isFinite(Date.parse(value.acquiredAt)) &&
Number.isFinite(Date.parse(value.expiresAt)) &&
(value.state === 'active' || value.state === 'released' || value.state === 'expired')
);
}
function isLedgerStatus(value: unknown): value is OpenCodeBridgeCommandLedgerStatus {
return (
value === 'started' ||
value === 'completed' ||
value === 'failed' ||
value === 'unknown_after_timeout'
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}

View file

@ -0,0 +1,112 @@
import type {
OpenCodeBridgeCommandName,
OpenCodeBridgeHandshake,
OpenCodeBridgePeerIdentity,
} from './OpenCodeBridgeCommandContract';
import type {
OpenCodeBridgeCommandExecutor,
OpenCodeBridgeHandshakePort,
} from './OpenCodeStateChangingBridgeCommandService';
export interface OpenCodeBridgeCommandHandshakePortOptions {
bridge: OpenCodeBridgeCommandExecutor;
clientIdentity: OpenCodeBridgePeerIdentity;
timeoutMs?: number;
}
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 120_000;
export class OpenCodeBridgeCommandHandshakePort implements OpenCodeBridgeHandshakePort {
private readonly bridge: OpenCodeBridgeCommandExecutor;
private readonly clientIdentity: OpenCodeBridgePeerIdentity;
private readonly timeoutMs: number;
constructor(options: OpenCodeBridgeCommandHandshakePortOptions) {
this.bridge = options.bridge;
this.clientIdentity = options.clientIdentity;
this.timeoutMs = options.timeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
}
async handshake(input: {
requiredCommand: OpenCodeBridgeCommandName;
expectedRunId: string | null;
expectedCapabilitySnapshotId: string | null;
expectedManifestHighWatermark: number | null;
cwd?: string;
}): Promise<OpenCodeBridgeHandshake> {
const result = await this.bridge.execute<
{
client: OpenCodeBridgePeerIdentity;
requiredCommand: OpenCodeBridgeCommandName;
expectedRunId: string | null;
expectedCapabilitySnapshotId: string | null;
expectedManifestHighWatermark: number | null;
},
OpenCodeBridgeHandshake
>(
'opencode.handshake',
{
client: this.clientIdentity,
requiredCommand: input.requiredCommand,
expectedRunId: input.expectedRunId,
expectedCapabilitySnapshotId: input.expectedCapabilitySnapshotId,
expectedManifestHighWatermark: input.expectedManifestHighWatermark,
},
{
cwd: input.cwd ?? process.cwd(),
timeoutMs: this.timeoutMs,
}
);
if (!result.ok) {
throw new Error(
`OpenCode bridge handshake failed: ${result.error.kind}: ${result.error.message}`
);
}
return result.data;
}
}
export function createOpenCodeBridgeClientIdentity(input: {
appVersion: string;
gitSha?: string | null;
buildId?: string | null;
}): OpenCodeBridgePeerIdentity {
return {
schemaVersion: 1,
peer: 'claude_team',
appVersion: input.appVersion,
gitSha: input.gitSha ?? null,
buildId: input.buildId ?? null,
bridgeProtocol: {
minVersion: 1,
currentVersion: 1,
supportedCommands: [
'opencode.handshake',
'opencode.commandStatus',
'opencode.readiness',
'opencode.launchTeam',
'opencode.reconcileTeam',
'opencode.stopTeam',
'opencode.answerPermission',
'opencode.listRuntimePermissions',
'opencode.getRuntimeTranscript',
'opencode.recoverDeliveryJournal',
],
},
runtime: {
providerId: 'opencode',
binaryPath: null,
binaryFingerprint: null,
version: null,
capabilitySnapshotId: null,
runtimeStoreManifestHighWatermark: null,
activeRunId: null,
},
featureFlags: {
opencodeTeamLaunch: true,
opencodeStateChangingCommands: true,
},
};
}

View file

@ -0,0 +1,414 @@
import {
assertOpenCodeProductionE2EArtifactGate,
type OpenCodeProductionE2EEvidence,
} from '../e2e/OpenCodeProductionE2EEvidence';
import {
buildOpenCodeCanonicalMcpToolId,
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
} from '../mcp/OpenCodeMcpToolAvailability';
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
import type {
OpenCodeTeamLaunchReadiness,
OpenCodeTeamLaunchReadinessState,
} from '../readiness/OpenCodeTeamLaunchReadiness';
import type {
OpenCodeBridgeCommandName,
OpenCodeBridgeDiagnosticEvent,
OpenCodeBridgeFailureKind,
OpenCodeBridgeResult,
OpenCodeBridgeRuntimeSnapshot,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeReconcileTeamCommandBody,
OpenCodeStopTeamCommandBody,
OpenCodeStopTeamCommandData,
OpenCodeTeamLaunchMode,
} from './OpenCodeBridgeCommandContract';
import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService';
export interface OpenCodeReadinessBridgeCommandExecutor {
execute<TBody, TData>(
command: OpenCodeBridgeCommandName,
body: TBody,
options: {
cwd: string;
timeoutMs: number;
requestId?: string;
stdoutLimitBytes?: number;
stderrLimitBytes?: number;
}
): Promise<OpenCodeBridgeResult<TData>>;
}
export interface OpenCodeReadinessBridgeOptions {
timeoutMs?: number;
launchTimeoutMs?: number;
reconcileTimeoutMs?: number;
stopTimeoutMs?: number;
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort;
}
export interface OpenCodeProductionE2EEvidenceReadPort {
read(input?: { selectedModel?: string | null }): Promise<{
ok: boolean;
evidence: OpenCodeProductionE2EEvidence | null;
artifactPath: string;
diagnostics: string[];
}>;
}
export interface OpenCodeReadinessBridgeCommandBody {
projectPath: string;
selectedModel: string | null;
requireExecutionProbe: boolean;
launchMode?: OpenCodeTeamLaunchMode;
}
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
string,
OpenCodeBridgeRuntimeSnapshot
>();
constructor(
private readonly bridge: OpenCodeReadinessBridgeCommandExecutor,
private readonly options: OpenCodeReadinessBridgeOptions = {}
) {}
async checkOpenCodeTeamLaunchReadiness(
input: OpenCodeReadinessBridgeCommandBody
): Promise<OpenCodeTeamLaunchReadiness> {
const result = await this.bridge.execute<
OpenCodeReadinessBridgeCommandBody,
OpenCodeTeamLaunchReadiness
>('opencode.readiness', input, {
cwd: input.projectPath,
timeoutMs: this.options.timeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS,
});
if (result.ok) {
this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime);
return this.applyProductionE2EGate({
input,
readiness: result.data,
runtime: result.runtime,
});
}
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
return blockedReadiness({
state: mapBridgeFailureToReadinessState(result.error.kind),
modelId: input.selectedModel,
diagnostics: [
`OpenCode readiness bridge failed: ${result.error.kind}: ${result.error.message}`,
...result.diagnostics.map(formatDiagnosticEvent),
],
missing: [result.error.message],
});
}
private async applyProductionE2EGate(input: {
input: OpenCodeReadinessBridgeCommandBody;
readiness: OpenCodeTeamLaunchReadiness;
runtime: OpenCodeBridgeRuntimeSnapshot;
}): Promise<OpenCodeTeamLaunchReadiness> {
const launchMode = input.input.launchMode;
if (launchMode !== 'production' && launchMode !== 'dogfood') {
return input.readiness;
}
if (!input.readiness.launchAllowed) {
return input.readiness;
}
const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
const evidenceRead = this.options.productionE2eEvidence
? await this.options.productionE2eEvidence.read({ selectedModel: expectedModel })
: {
ok: false,
evidence: null,
artifactPath: '',
diagnostics: ['OpenCode production E2E evidence store is not configured'],
};
const gate = evidenceRead.ok
? assertOpenCodeProductionE2EArtifactGate({
evidence: evidenceRead.evidence,
artifactPath: evidenceRead.artifactPath,
expected: {
opencodeVersion: input.runtime.version,
binaryFingerprint: input.runtime.binaryFingerprint,
capabilitySnapshotId: input.runtime.capabilitySnapshotId,
selectedModel: expectedModel,
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
),
},
})
: {
ok: false,
diagnostics: evidenceRead.diagnostics,
};
if (gate.ok) {
return {
...input.readiness,
diagnostics: dedupe([...input.readiness.diagnostics, ...evidenceRead.diagnostics]),
supportLevel: 'production_supported',
};
}
const diagnostics = dedupe([
...input.readiness.diagnostics,
...evidenceRead.diagnostics,
...gate.diagnostics,
]);
if (launchMode === 'dogfood') {
return {
...input.readiness,
supportLevel: 'supported_e2e_pending',
diagnostics,
};
}
return {
...input.readiness,
state: 'e2e_missing',
launchAllowed: false,
supportLevel: 'supported_e2e_pending',
missing: dedupe([...input.readiness.missing, ...gate.diagnostics]),
diagnostics,
};
}
getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null {
return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? null;
}
async launchOpenCodeTeam(
input: OpenCodeLaunchTeamCommandBody
): Promise<OpenCodeLaunchTeamCommandData> {
const result = await this.executeStateChangingCommand<
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData
>('opencode.launchTeam', input, {
teamName: input.teamName,
runId: input.runId,
capabilitySnapshotId: input.expectedCapabilitySnapshotId,
cwd: input.projectPath,
timeoutMs: this.options.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS,
});
return result.ok ? result.data : blockedLaunchData(input.runId, result);
}
async reconcileOpenCodeTeam(
input: OpenCodeReconcileTeamCommandBody
): Promise<OpenCodeLaunchTeamCommandData> {
const cwd = input.projectPath ?? process.cwd();
const result = await this.executeStateChangingCommand<
OpenCodeReconcileTeamCommandBody,
OpenCodeLaunchTeamCommandData
>('opencode.reconcileTeam', input, {
teamName: input.teamName,
runId: input.runId,
capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null,
cwd,
timeoutMs: this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS,
});
return result.ok ? result.data : blockedLaunchData(input.runId, result);
}
async stopOpenCodeTeam(input: OpenCodeStopTeamCommandBody): Promise<OpenCodeStopTeamCommandData> {
const cwd = input.projectPath ?? process.cwd();
const result = await this.executeStateChangingCommand<
OpenCodeStopTeamCommandBody,
OpenCodeStopTeamCommandData
>('opencode.stopTeam', input, {
teamName: input.teamName,
runId: input.runId,
capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null,
cwd,
timeoutMs: this.options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS,
});
if (result.ok) {
return result.data;
}
return {
runId: input.runId,
stopped: false,
members: {},
warnings: [],
diagnostics: [
{
code: result.error.kind,
severity: 'error',
message: `OpenCode stop bridge failed: ${result.error.message}`,
},
...result.diagnostics.map((event) => ({
code: event.type,
severity: event.severity,
message: event.message,
})),
],
};
}
private async executeStateChangingCommand<TBody, TData>(
command: OpenCodeStateChangingTeamCommandName,
body: TBody,
input: {
teamName: string;
runId: string;
capabilitySnapshotId: string | null;
cwd: string;
timeoutMs: number;
}
): Promise<OpenCodeBridgeResult<TData>> {
if (this.options.stateChangingCommands) {
try {
return await this.options.stateChangingCommands.execute<TBody, TData>({
command,
teamName: input.teamName,
runId: input.runId,
capabilitySnapshotId: input.capabilitySnapshotId,
behaviorFingerprint: null,
body,
cwd: input.cwd,
timeoutMs: input.timeoutMs,
});
} catch (error) {
return thrownBridgeFailure(command, input.runId, error);
}
}
return this.bridge.execute<TBody, TData>(command, body, {
cwd: input.cwd,
timeoutMs: input.timeoutMs,
});
}
}
type OpenCodeStateChangingTeamCommandName = Extract<
OpenCodeBridgeCommandName,
'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam'
>;
function blockedLaunchData(
runId: string,
result: OpenCodeBridgeResult<unknown>
): OpenCodeLaunchTeamCommandData {
if (result.ok) {
throw new Error('blockedLaunchData expects a failed bridge result');
}
return {
runId,
teamLaunchState: 'failed',
members: {},
warnings: [],
diagnostics: [
{
code: result.error.kind,
severity: 'error',
message: `OpenCode bridge failed: ${result.error.message}`,
},
...result.diagnostics.map((event) => ({
code: event.type,
severity: event.severity,
message: event.message,
})),
],
};
}
function blockedReadiness(input: {
state: OpenCodeTeamLaunchReadinessState;
modelId: string | null;
diagnostics: string[];
missing: string[];
}): OpenCodeTeamLaunchReadiness {
return {
state: input.state,
launchAllowed: false,
modelId: input.modelId,
opencodeVersion: null,
installMethod: null,
binaryPath: null,
hostHealthy: false,
appMcpConnected: false,
requiredToolsPresent: false,
permissionBridgeReady: false,
runtimeStoresReady: false,
supportLevel: null,
missing: dedupe(input.missing),
diagnostics: dedupe(input.diagnostics),
evidence: {
capabilitiesReady: false,
mcpToolProofRoute: null,
observedMcpTools: [],
runtimeStoreReadinessReason: null,
},
};
}
function mapBridgeFailureToReadinessState(
kind: OpenCodeBridgeFailureKind
): OpenCodeTeamLaunchReadinessState {
switch (kind) {
case 'runtime_not_ready':
return 'adapter_disabled';
case 'timeout':
case 'contract_violation':
case 'provider_error':
case 'unsupported_schema':
case 'unsupported_command':
case 'invalid_input':
case 'internal_error':
default:
return 'unknown_error';
}
}
function formatDiagnosticEvent(event: OpenCodeBridgeDiagnosticEvent): string {
return `${event.type}: ${event.message}`;
}
function thrownBridgeFailure<TData>(
command: OpenCodeBridgeCommandName,
runId: string,
error: unknown
): OpenCodeBridgeResult<TData> {
const message = error instanceof Error ? error.message : String(error);
const completedAt = new Date().toISOString();
return {
ok: false,
schemaVersion: 1,
requestId: 'opencode-state-changing-bridge-exception',
command,
completedAt,
durationMs: 0,
error: {
kind: 'internal_error',
message,
retryable: false,
},
diagnostics: [
{
type: 'opencode_state_changing_bridge_exception',
providerId: 'opencode',
runId,
severity: 'error',
message,
createdAt: completedAt,
},
],
};
}
function dedupe(values: string[]): string[] {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}

View file

@ -0,0 +1,283 @@
import { randomUUID } from 'crypto';
import {
assertBridgeEvidenceCanCommitToRuntimeStores,
createOpenCodeBridgeIdempotencyKey,
extractRunId,
stableHash,
validateOpenCodeBridgeHandshake,
type OpenCodeBridgeCommandName,
type OpenCodeBridgeCommandPreconditions,
type OpenCodeBridgeDiagnosticEvent,
type OpenCodeBridgeHandshake,
type OpenCodeBridgePeerIdentity,
type OpenCodeBridgeResult,
type RuntimeStoreManifestEvidence,
} from './OpenCodeBridgeCommandContract';
import {
OpenCodeBridgeCommandLedger,
OpenCodeBridgeCommandLeaseStore,
} from './OpenCodeBridgeCommandLedgerStore';
export interface OpenCodeBridgeCommandExecutor {
execute<TBody, TData>(
command: OpenCodeBridgeCommandName,
body: TBody,
options: {
cwd: string;
timeoutMs: number;
requestId?: string;
stdoutLimitBytes?: number;
stderrLimitBytes?: number;
}
): Promise<OpenCodeBridgeResult<TData>>;
}
export interface OpenCodeBridgeHandshakePort {
handshake(input: {
requiredCommand: OpenCodeBridgeCommandName;
expectedRunId: string | null;
expectedCapabilitySnapshotId: string | null;
expectedManifestHighWatermark: number | null;
cwd?: string;
}): Promise<OpenCodeBridgeHandshake>;
}
export interface RuntimeStoreManifestReader {
read(teamName: string): Promise<RuntimeStoreManifestEvidence>;
}
export interface OpenCodeStateChangingBridgeDiagnosticsSink {
append(event: OpenCodeBridgeDiagnosticEvent): Promise<void>;
}
export interface OpenCodeStateChangingBridgeCommandServiceOptions {
expectedClientIdentity: OpenCodeBridgePeerIdentity;
handshakePort: OpenCodeBridgeHandshakePort;
leaseStore: OpenCodeBridgeCommandLeaseStore;
ledger: OpenCodeBridgeCommandLedger;
bridge: OpenCodeBridgeCommandExecutor;
manifestReader: RuntimeStoreManifestReader;
diagnostics?: OpenCodeStateChangingBridgeDiagnosticsSink;
requestIdFactory?: () => string;
diagnosticIdFactory?: () => string;
clock?: () => Date;
}
export class OpenCodeStateChangingBridgeCommandService {
private readonly expectedClientIdentity: OpenCodeBridgePeerIdentity;
private readonly handshakePort: OpenCodeBridgeHandshakePort;
private readonly leaseStore: OpenCodeBridgeCommandLeaseStore;
private readonly ledger: OpenCodeBridgeCommandLedger;
private readonly bridge: OpenCodeBridgeCommandExecutor;
private readonly manifestReader: RuntimeStoreManifestReader;
private readonly diagnostics: OpenCodeStateChangingBridgeDiagnosticsSink | null;
private readonly requestIdFactory: () => string;
private readonly diagnosticIdFactory: () => string;
private readonly clock: () => Date;
constructor(options: OpenCodeStateChangingBridgeCommandServiceOptions) {
this.expectedClientIdentity = options.expectedClientIdentity;
this.handshakePort = options.handshakePort;
this.leaseStore = options.leaseStore;
this.ledger = options.ledger;
this.bridge = options.bridge;
this.manifestReader = options.manifestReader;
this.diagnostics = options.diagnostics ?? null;
this.requestIdFactory = options.requestIdFactory ?? (() => `opencode-bridge-${randomUUID()}`);
this.diagnosticIdFactory =
options.diagnosticIdFactory ?? (() => `opencode-bridge-diagnostic-${randomUUID()}`);
this.clock = options.clock ?? (() => new Date());
}
async execute<TBody, TData>(input: {
command: OpenCodeBridgeCommandName;
teamName: string;
runId: string | null;
capabilitySnapshotId: string | null;
behaviorFingerprint: string | null;
body: TBody;
cwd: string;
timeoutMs: number;
}): Promise<OpenCodeBridgeResult<TData>> {
const manifest = await this.manifestReader.read(input.teamName);
const handshake = await this.handshakePort.handshake({
requiredCommand: input.command,
expectedRunId: input.runId,
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
expectedManifestHighWatermark: manifest.highWatermark,
cwd: input.cwd,
});
const handshakeValidation = validateOpenCodeBridgeHandshake({
handshake,
expectedClient: this.expectedClientIdentity,
requiredCommand: input.command,
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
expectedManifestHighWatermark: manifest.highWatermark,
expectedRunId: input.runId,
});
if (!handshakeValidation.ok) {
throw new Error(handshakeValidation.reason);
}
const idempotencyKey = createOpenCodeBridgeIdempotencyKey({
command: input.command,
teamName: input.teamName,
runId: input.runId,
body: input.body,
});
const commandRequestId = this.requestIdFactory();
const lease = await this.leaseStore.acquire({
teamName: input.teamName,
runId: input.runId,
command: input.command,
ttlMs: input.timeoutMs + 5_000,
});
try {
const bodyWithPreconditions = attachBridgePreconditions(input.body, {
handshakeIdentityHash: handshake.identityHash,
expectedRunId: input.runId,
expectedCapabilitySnapshotId: input.capabilitySnapshotId,
expectedBehaviorFingerprint: input.behaviorFingerprint,
expectedManifestHighWatermark: manifest.highWatermark,
commandLeaseId: lease.leaseId,
idempotencyKey,
});
const begin = await this.ledger.begin({
idempotencyKey,
requestId: commandRequestId,
command: input.command,
teamName: input.teamName,
runId: input.runId,
requestHash: stableHash({
command: input.command,
teamName: input.teamName,
runId: input.runId,
capabilitySnapshotId: input.capabilitySnapshotId,
behaviorFingerprint: input.behaviorFingerprint,
manifestHighWatermark: manifest.highWatermark,
body: input.body,
}),
});
if (begin === 'duplicate_same_payload_completed') {
throw new Error('OpenCode bridge command already completed; recover through commandStatus');
}
const result = await this.bridge.execute<typeof bodyWithPreconditions, TData>(
input.command,
bodyWithPreconditions,
{
cwd: input.cwd,
timeoutMs: input.timeoutMs,
requestId: commandRequestId,
}
);
if (!result.ok) {
if (result.error.kind === 'timeout') {
await this.ledger.markUnknownAfterTimeout({
idempotencyKey,
error: result.error.message,
});
await this.appendUnknownOutcomeDiagnostic({
result,
teamName: input.teamName,
runId: input.runId,
command: input.command,
idempotencyKey,
leaseId: lease.leaseId,
});
} else {
await this.ledger.markFailed({
idempotencyKey,
error: result.error.message,
retryable: result.error.retryable,
});
}
await this.leaseStore.release(lease.leaseId);
return result;
}
try {
assertBridgeEvidenceCanCommitToRuntimeStores({
result,
requestId: commandRequestId,
command: input.command,
runId: input.runId,
capabilitySnapshotId: input.capabilitySnapshotId,
manifest,
idempotencyKey,
});
} catch (error) {
await this.ledger.markFailed({
idempotencyKey,
error: stringifyError(error),
retryable: false,
});
throw error;
}
await this.ledger.markCompleted({ idempotencyKey, response: result });
await this.leaseStore.release(lease.leaseId);
return result;
} catch (error) {
await this.leaseStore.release(lease.leaseId).catch(() => undefined);
throw error;
}
}
private async appendUnknownOutcomeDiagnostic(input: {
result: OpenCodeBridgeResult<unknown>;
teamName: string;
runId: string | null;
command: OpenCodeBridgeCommandName;
idempotencyKey: string;
leaseId: string;
}): Promise<void> {
const completedAt = this.clock().toISOString();
await this.diagnostics?.append({
id: this.diagnosticIdFactory(),
type: 'opencode_bridge_unknown_outcome',
providerId: 'opencode',
teamName: input.teamName,
runId: input.runId ?? extractRunId(input.result) ?? undefined,
severity: 'warning',
message: 'OpenCode bridge command timed out; outcome must be reconciled before retry',
data: {
command: input.command,
idempotencyKey: input.idempotencyKey,
leaseId: input.leaseId,
},
createdAt: completedAt,
});
}
}
export function attachBridgePreconditions<TBody>(
body: TBody,
preconditions: OpenCodeBridgeCommandPreconditions
): TBody & { preconditions: OpenCodeBridgeCommandPreconditions } {
if (isRecord(body)) {
return {
...body,
preconditions,
} as TBody & { preconditions: OpenCodeBridgeCommandPreconditions };
}
return {
payload: body,
preconditions,
} as unknown as TBody & { preconditions: OpenCodeBridgeCommandPreconditions };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,555 @@
import { createHash } from 'crypto';
export interface OpenCodeApiEndpointMap {
health: boolean;
sessionCreate: boolean;
sessionGet: boolean;
sessionMessageList: boolean;
sessionPromptAsync: boolean;
sessionAbort: boolean;
sessionStatus: boolean;
permissionList: boolean;
permissionReply: boolean;
permissionLegacySessionRespond: boolean;
sessionEventStream: boolean;
globalEventStream: boolean;
mcpList: boolean;
mcpCreate: boolean;
experimentalToolIds: boolean;
experimentalToolList: boolean;
}
export type OpenCodeApiEndpointKey = keyof OpenCodeApiEndpointMap;
export type OpenCodeEndpointEvidence =
| 'openapi'
| 'direct_probe'
| 'undocumented_direct_probe'
| 'real_e2e'
| 'missing';
export type OpenCodeApiCapabilitySource =
| 'openapi_doc'
| 'sdk_probe'
| 'direct_probe'
| 'mixed_openapi_direct_probe';
export interface OpenCodeApiCapabilities {
version: string | null;
source: OpenCodeApiCapabilitySource;
endpoints: OpenCodeApiEndpointMap;
requiredForTeamLaunch: {
ready: boolean;
missing: string[];
};
evidence: Record<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>;
diagnostics: string[];
}
export interface OpenCodeApiDiscoverySnapshot {
checkedAt: string;
opencodeVersion: string | null;
baseUrlRedacted: string;
capabilities: OpenCodeApiCapabilities;
openApiHash: string | null;
}
export interface OpenCodeApiCapabilityDetectorInput {
baseUrl: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
}
export interface OpenCodeApiDiscoverySnapshotInput {
baseUrl: string;
checkedAt: string;
capabilities: OpenCodeApiCapabilities;
openApiDocument?: unknown;
}
interface OpenApiDocument {
openapi?: string;
info?: {
version?: unknown;
};
paths?: Record<string, Record<string, unknown>>;
}
interface RequiredOpenCodeEndpoint {
key: OpenCodeApiEndpointKey;
method: 'get' | 'post' | 'delete' | 'patch';
path: RegExp;
label: string;
}
interface DirectSafeProbe {
method: 'GET';
path: string;
accept: 'application/json' | 'text/event-stream';
}
const OPENAPI_SPEC_CANDIDATES = ['/doc', '/doc.json', '/openapi.json'] as const;
export const REQUIRED_OPENCODE_ENDPOINTS: RequiredOpenCodeEndpoint[] = [
{ key: 'health', method: 'get', path: /^\/global\/health\/?$/, label: 'GET /global/health' },
{ key: 'sessionCreate', method: 'post', path: /^\/session\/?$/, label: 'POST /session' },
{
key: 'sessionGet',
method: 'get',
path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/?$/,
label: 'GET /session/:id',
},
{
key: 'sessionMessageList',
method: 'get',
path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/message\/?$/,
label: 'GET /session/:id/message',
},
{
key: 'sessionPromptAsync',
method: 'post',
path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/prompt_async\/?$/,
label: 'POST /session/:id/prompt_async',
},
{
key: 'sessionAbort',
method: 'post',
path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/abort\/?$/,
label: 'POST /session/:id/abort',
},
{
key: 'sessionStatus',
method: 'get',
path: /^\/session\/status\/?$/,
label: 'GET /session/status',
},
{ key: 'permissionList', method: 'get', path: /^\/permission\/?$/, label: 'GET /permission' },
{
key: 'permissionReply',
method: 'post',
path: /^\/permission\/(?:\{[^}]+\}|:[^/]+)\/reply\/?$/,
label: 'POST /permission/:requestID/reply',
},
{
key: 'permissionLegacySessionRespond',
method: 'post',
path: /^\/session\/(?:\{[^}]+\}|:[^/]+)\/permissions\/(?:\{[^}]+\}|:[^/]+)\/?$/,
label: 'POST /session/:sessionID/permissions/:permissionID',
},
{ key: 'sessionEventStream', method: 'get', path: /^\/event\/?$/, label: 'GET /event' },
{
key: 'globalEventStream',
method: 'get',
path: /^\/global\/event\/?$/,
label: 'GET /global/event',
},
{ key: 'mcpList', method: 'get', path: /^\/mcp\/?$/, label: 'GET /mcp' },
{ key: 'mcpCreate', method: 'post', path: /^\/mcp\/?$/, label: 'POST /mcp' },
{
key: 'experimentalToolIds',
method: 'get',
path: /^\/experimental\/tool\/ids\/?$/,
label: 'GET /experimental/tool/ids',
},
{
key: 'experimentalToolList',
method: 'get',
path: /^\/experimental\/tool\/?$/,
label: 'GET /experimental/tool',
},
];
const DIRECT_SAFE_PROBES: Partial<Record<OpenCodeApiEndpointKey, DirectSafeProbe>> = {
health: { method: 'GET', path: '/global/health', accept: 'application/json' },
sessionStatus: { method: 'GET', path: '/session/status', accept: 'application/json' },
permissionList: { method: 'GET', path: '/permission/', accept: 'application/json' },
sessionEventStream: { method: 'GET', path: '/event', accept: 'text/event-stream' },
globalEventStream: { method: 'GET', path: '/global/event', accept: 'text/event-stream' },
mcpList: { method: 'GET', path: '/mcp', accept: 'application/json' },
experimentalToolIds: {
method: 'GET',
path: '/experimental/tool/ids',
accept: 'application/json',
},
experimentalToolList: {
method: 'GET',
path: '/experimental/tool',
accept: 'application/json',
},
};
export async function detectOpenCodeApiCapabilities(
input: OpenCodeApiCapabilityDetectorInput
): Promise<OpenCodeApiCapabilities> {
const fetchImpl = input.fetchImpl ?? fetch;
const timeoutMs = input.timeoutMs ?? 5_000;
const diagnostics: string[] = [];
const endpoints = createEmptyEndpointMap();
const evidence = createEmptyEvidenceMap();
const openApi = await loadOpenApiDocument({
baseUrl: input.baseUrl,
fetchImpl,
timeoutMs,
diagnostics,
});
if (openApi.document?.paths) {
applyOpenApiEndpointEvidence(openApi.document, endpoints, evidence);
}
await runDirectSafeProbes({
baseUrl: input.baseUrl,
fetchImpl,
timeoutMs,
docAvailable: Boolean(openApi.document),
endpoints,
evidence,
diagnostics,
});
if (!endpoints.permissionReply && !endpoints.permissionLegacySessionRespond) {
diagnostics.push(
'OpenCode permission response endpoint was not proven by OpenAPI; require real permission E2E before production launch'
);
}
const missing = resolveMissingOpenCodeCapabilities(endpoints);
const version =
extractOpenApiVersion(openApi.document) ??
(await probeOpenCodeHealthVersion(input.baseUrl, fetchImpl, timeoutMs, diagnostics));
return {
version,
source: resolveCapabilitySource(openApi.document, evidence),
endpoints,
requiredForTeamLaunch: {
ready: missing.length === 0,
missing,
},
evidence,
diagnostics,
};
}
export function createOpenCodeApiDiscoverySnapshot(
input: OpenCodeApiDiscoverySnapshotInput
): OpenCodeApiDiscoverySnapshot {
return {
checkedAt: input.checkedAt,
opencodeVersion: input.capabilities.version,
baseUrlRedacted: redactUrl(input.baseUrl),
capabilities: input.capabilities,
openApiHash: input.openApiDocument === undefined ? null : stableHash(input.openApiDocument),
};
}
export function applyOpenApiEndpointEvidence(
document: OpenApiDocument,
endpoints: OpenCodeApiEndpointMap,
evidence: Record<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>
): void {
for (const [path, methods] of Object.entries(document.paths ?? {})) {
for (const required of REQUIRED_OPENCODE_ENDPOINTS) {
if (required.path.test(path) && Boolean(methods[required.method])) {
endpoints[required.key] = true;
evidence[required.key] = 'openapi';
}
}
}
}
export function resolveMissingOpenCodeCapabilities(endpoints: OpenCodeApiEndpointMap): string[] {
const missing: string[] = [];
for (const endpoint of REQUIRED_OPENCODE_ENDPOINTS) {
if (endpoint.key === 'permissionLegacySessionRespond') {
continue;
}
if (endpoint.key === 'experimentalToolList') {
continue;
}
if (endpoint.key === 'permissionReply') {
if (!endpoints.permissionReply && !endpoints.permissionLegacySessionRespond) {
missing.push('POST permission reply route');
}
continue;
}
if (endpoint.key === 'experimentalToolIds') {
if (!endpoints.experimentalToolIds && !endpoints.experimentalToolList) {
missing.push('GET OpenCode tool availability route');
}
continue;
}
if (!endpoints[endpoint.key]) {
missing.push(endpoint.label);
}
}
return missing;
}
export function createEmptyEndpointMap(): OpenCodeApiEndpointMap {
return {
health: false,
sessionCreate: false,
sessionGet: false,
sessionMessageList: false,
sessionPromptAsync: false,
sessionAbort: false,
sessionStatus: false,
permissionList: false,
permissionReply: false,
permissionLegacySessionRespond: false,
sessionEventStream: false,
globalEventStream: false,
mcpList: false,
mcpCreate: false,
experimentalToolIds: false,
experimentalToolList: false,
};
}
function createEmptyEvidenceMap(): Record<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence> {
return Object.fromEntries(
(Object.keys(createEmptyEndpointMap()) as OpenCodeApiEndpointKey[]).map((key) => [
key,
'missing',
])
) as Record<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>;
}
async function loadOpenApiDocument(input: {
baseUrl: string;
fetchImpl: typeof fetch;
timeoutMs: number;
diagnostics: string[];
}): Promise<{ document: OpenApiDocument | null; raw: string | null }> {
for (const candidate of OPENAPI_SPEC_CANDIDATES) {
try {
const response = await fetchWithTimeout(input.fetchImpl, buildUrl(input.baseUrl, candidate), {
timeoutMs: input.timeoutMs,
requestInit: { headers: { accept: 'application/json' } },
});
const text = await response.text();
if (!response.ok) {
input.diagnostics.push(`OpenCode ${candidate} returned HTTP ${response.status}`);
continue;
}
if (looksLikeHtml(text)) {
input.diagnostics.push(`OpenCode ${candidate} returned HTML, expected OpenAPI JSON`);
continue;
}
const parsed = JSON.parse(text) as OpenApiDocument;
if (parsed.paths && Object.keys(parsed.paths).length > 0) {
return { document: parsed, raw: text };
}
input.diagnostics.push(`OpenCode ${candidate} did not include OpenAPI paths`);
} catch (error) {
input.diagnostics.push(`OpenCode ${candidate} probe failed: ${stringifyError(error)}`);
}
}
return { document: null, raw: null };
}
async function runDirectSafeProbes(input: {
baseUrl: string;
fetchImpl: typeof fetch;
timeoutMs: number;
docAvailable: boolean;
endpoints: OpenCodeApiEndpointMap;
evidence: Record<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>;
diagnostics: string[];
}): Promise<void> {
for (const [key, probe] of Object.entries(DIRECT_SAFE_PROBES) as Array<
[OpenCodeApiEndpointKey, DirectSafeProbe]
>) {
if (input.endpoints[key]) {
continue;
}
try {
const response = await fetchWithTimeout(
input.fetchImpl,
buildUrl(input.baseUrl, probe.path),
{
timeoutMs: input.timeoutMs,
requestInit: {
method: probe.method,
headers: { accept: probe.accept },
},
}
);
await cancelResponseBody(response);
if (!response.ok) {
input.diagnostics.push(
`OpenCode direct probe ${probe.path} returned HTTP ${response.status}`
);
continue;
}
input.endpoints[key] = true;
input.evidence[key] = input.docAvailable ? 'undocumented_direct_probe' : 'direct_probe';
} catch (error) {
input.diagnostics.push(
`OpenCode direct probe ${probe.path} failed: ${stringifyError(error)}`
);
}
}
}
async function probeOpenCodeHealthVersion(
baseUrl: string,
fetchImpl: typeof fetch,
timeoutMs: number,
diagnostics: string[]
): Promise<string | null> {
try {
const response = await fetchWithTimeout(fetchImpl, buildUrl(baseUrl, '/global/health'), {
timeoutMs,
requestInit: { headers: { accept: 'application/json' } },
});
const text = await response.text();
if (!response.ok) {
diagnostics.push(`OpenCode health version probe returned HTTP ${response.status}`);
return null;
}
const parsed = JSON.parse(text) as unknown;
return extractHealthVersion(parsed);
} catch (error) {
diagnostics.push(`OpenCode health version probe failed: ${stringifyError(error)}`);
return null;
}
}
function extractOpenApiVersion(document: OpenApiDocument | null): string | null {
return typeof document?.info?.version === 'string' && document.info.version.trim().length > 0
? document.info.version
: null;
}
function extractHealthVersion(value: unknown): string | null {
if (!isRecord(value)) {
return null;
}
if (typeof value.version === 'string' && value.version.trim().length > 0) {
return value.version;
}
if (
isRecord(value.build) &&
typeof value.build.version === 'string' &&
value.build.version.trim().length > 0
) {
return value.build.version;
}
if (
isRecord(value.data) &&
typeof value.data.version === 'string' &&
value.data.version.trim().length > 0
) {
return value.data.version;
}
return null;
}
function resolveCapabilitySource(
document: OpenApiDocument | null,
evidence: Record<OpenCodeApiEndpointKey, OpenCodeEndpointEvidence>
): OpenCodeApiCapabilitySource {
if (!document) {
return 'direct_probe';
}
return Object.values(evidence).some((item) => item === 'undocumented_direct_probe')
? 'mixed_openapi_direct_probe'
: 'openapi_doc';
}
async function fetchWithTimeout(
fetchImpl: typeof fetch,
url: string,
options: {
timeoutMs: number;
requestInit?: RequestInit;
}
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
try {
return await fetchImpl(url, {
...options.requestInit,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
async function cancelResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// Best-effort cleanup for SSE probes after headers are proven.
}
}
function buildUrl(baseUrl: string, path: string): string {
return new URL(path, normalizeBaseUrl(baseUrl)).toString();
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
}
function looksLikeHtml(text: string): boolean {
return text.trimStart().startsWith('<');
}
function redactUrl(url: string): string {
try {
const parsed = new URL(url);
if (parsed.username) {
parsed.username = 'redacted';
}
if (parsed.password) {
parsed.password = 'redacted';
}
return parsed.toString();
} catch {
return '<invalid-url>';
}
}
function stableHash(value: unknown): string {
return createHash('sha256').update(stableJsonStringify(value)).digest('hex');
}
function stableJsonStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(stableJsonStringify).join(',')}]`;
}
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`)
.join(',')}}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,401 @@
import { createHash } from 'crypto';
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
export interface OpenCodeMcpServerConfig {
type: 'local' | 'remote';
command?: string;
args?: string[];
url?: string;
enabled: boolean;
environment?: Record<string, string>;
timeout?: number;
}
export type OpenCodeBehaviorSourceKind =
| 'global_config'
| 'project_config'
| 'global_plugin_dir'
| 'project_plugin_dir'
| 'project_opencode_dir';
export interface OpenCodeBehaviorSource {
kind: OpenCodeBehaviorSourceKind;
pathHash: string;
exists: boolean;
fingerprint: string | null;
fileCount: number;
}
export interface OpenCodeManagedOverlay {
launchMode: 'project_root_with_inline_overlay';
projectPath: string;
env: {
OPENCODE_CONFIG_CONTENT: string;
OPENCODE_DISABLE_AUTOUPDATE: '1';
};
appMcpServerName: string;
appMcpConfig: OpenCodeMcpServerConfig;
preservedSources: OpenCodeBehaviorSource[];
diagnostics: string[];
}
export interface OpenCodeManagedOverlayBuilderInput {
projectPath: string;
preferredMcpName: string;
appMcpCommand: string;
appMcpArgs: string[];
appMcpEnv: Record<string, string>;
mcpTimeoutMs?: number;
}
export interface OpenCodeBehaviorSourceScannerOptions {
homePath?: string;
maxDirectoryFiles?: number;
}
const FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS = [
'plugin',
'plugins',
'agent',
'command',
'instructions',
'formatter',
'lsp',
'theme',
'keybinds',
'model',
'mode',
'provider',
'tools',
'skills',
] as const;
export class OpenCodeManagedOverlayBuilder {
constructor(
private readonly behaviorSourceScanner = new OpenCodeBehaviorSourceScanner(),
private readonly clock: () => Date = () => new Date()
) {}
async build(input: OpenCodeManagedOverlayBuilderInput): Promise<OpenCodeManagedOverlay> {
const preservedSources = await this.behaviorSourceScanner.scan(input.projectPath);
const existingMcpNames = await this.behaviorSourceScanner.readDeclaredMcpNames(
input.projectPath
);
const appMcpServerName = pickAppOwnedMcpServerName(input.preferredMcpName, existingMcpNames);
const overlayConfig = buildManagedOverlayConfig({
serverName: appMcpServerName,
command: input.appMcpCommand,
args: input.appMcpArgs,
environment: input.appMcpEnv,
timeout: input.mcpTimeoutMs ?? 10_000,
});
assertManagedOverlayDoesNotShadowUserConfig(overlayConfig);
return {
launchMode: 'project_root_with_inline_overlay',
projectPath: input.projectPath,
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(overlayConfig),
OPENCODE_DISABLE_AUTOUPDATE: '1',
},
appMcpServerName,
appMcpConfig: overlayConfig.mcp[appMcpServerName],
preservedSources,
diagnostics: buildOverlayDiagnostics({
preferredMcpName: input.preferredMcpName,
appMcpServerName,
existingMcpNames,
preservedSources,
checkedAt: this.clock().toISOString(),
}),
};
}
}
export class OpenCodeBehaviorSourceScanner {
private readonly homePath: string;
private readonly maxDirectoryFiles: number;
constructor(options: OpenCodeBehaviorSourceScannerOptions = {}) {
this.homePath = options.homePath ?? os.homedir();
this.maxDirectoryFiles = options.maxDirectoryFiles ?? 200;
}
async scan(projectPath: string): Promise<OpenCodeBehaviorSource[]> {
const sourceSpecs: Array<{ kind: OpenCodeBehaviorSourceKind; targetPath: string }> = [
{
kind: 'global_config',
targetPath: path.join(this.homePath, '.config/opencode/opencode.json'),
},
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.json') },
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') },
{
kind: 'global_plugin_dir',
targetPath: path.join(this.homePath, '.config/opencode/plugins'),
},
{ kind: 'project_plugin_dir', targetPath: path.join(projectPath, '.opencode/plugins') },
{ kind: 'project_opencode_dir', targetPath: path.join(projectPath, '.opencode') },
];
return Promise.all(sourceSpecs.map((source) => this.fingerprintSource(source)));
}
async readDeclaredMcpNames(projectPath: string): Promise<Set<string>> {
const configPaths = [
path.join(this.homePath, '.config/opencode/opencode.json'),
path.join(projectPath, 'opencode.json'),
path.join(projectPath, 'opencode.jsonc'),
path.join(projectPath, '.opencode/opencode.json'),
path.join(projectPath, '.opencode/opencode.jsonc'),
];
const names = new Set<string>();
for (const configPath of configPaths) {
const config = await this.readConfig(configPath);
const mcp = asRecord(config?.mcp);
for (const name of Object.keys(mcp ?? {})) {
names.add(name);
}
}
return names;
}
private async fingerprintSource(input: {
kind: OpenCodeBehaviorSourceKind;
targetPath: string;
}): Promise<OpenCodeBehaviorSource> {
const pathHash = hashText(input.targetPath);
let stat;
try {
stat = await fs.stat(input.targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {
kind: input.kind,
pathHash,
exists: false,
fingerprint: null,
fileCount: 0,
};
}
throw error;
}
if (stat.isFile()) {
const content = await fs.readFile(input.targetPath);
return {
kind: input.kind,
pathHash,
exists: true,
fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:${hashBuffer(content)}`),
fileCount: 1,
};
}
if (stat.isDirectory()) {
const entries = await this.listDirectoryFiles(input.targetPath);
return {
kind: input.kind,
pathHash,
exists: true,
fingerprint: hashJson(
entries.map((entry) => ({
relativePath: entry.relativePath,
size: entry.size,
mtimeMs: entry.mtimeMs,
contentHash: entry.contentHash,
}))
),
fileCount: entries.length,
};
}
return {
kind: input.kind,
pathHash,
exists: true,
fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:unsupported`),
fileCount: 0,
};
}
private async listDirectoryFiles(rootPath: string): Promise<
Array<{
relativePath: string;
size: number;
mtimeMs: number;
contentHash: string;
}>
> {
const results: Array<{
relativePath: string;
size: number;
mtimeMs: number;
contentHash: string;
}> = [];
const visit = async (directoryPath: string): Promise<void> => {
if (results.length >= this.maxDirectoryFiles) {
return;
}
const entries = await fs.readdir(directoryPath, { withFileTypes: true });
for (const entry of entries) {
if (results.length >= this.maxDirectoryFiles) {
return;
}
const absolutePath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
await visit(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
const stat = await fs.stat(absolutePath);
const content = await fs.readFile(absolutePath);
results.push({
relativePath: path.relative(rootPath, absolutePath),
size: stat.size,
mtimeMs: stat.mtimeMs,
contentHash: hashBuffer(content),
});
}
};
await visit(rootPath);
return results.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
}
private async readConfig(configPath: string): Promise<Record<string, unknown> | null> {
let text: string;
try {
text = await fs.readFile(configPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
try {
const parsed = JSON.parse(stripJsonComments(text)) as unknown;
return asRecord(parsed);
} catch {
return null;
}
}
}
export function buildManagedOverlayConfig(input: {
serverName: string;
command: string;
args: string[];
environment: Record<string, string>;
timeout: number;
}): { mcp: Record<string, OpenCodeMcpServerConfig> } {
return {
mcp: {
[input.serverName]: {
type: 'local',
command: input.command,
args: input.args,
enabled: true,
environment: input.environment,
timeout: input.timeout,
},
},
};
}
export function assertManagedOverlayDoesNotShadowUserConfig(config: Record<string, unknown>): void {
const usedForbiddenKeys = FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS.filter((key) => key in config);
if (usedForbiddenKeys.length > 0) {
throw new Error(
`Managed OpenCode overlay must not set user behavior keys: ${usedForbiddenKeys.join(', ')}`
);
}
}
export function pickAppOwnedMcpServerName(preferred: string, existingNames: Set<string>): string {
if (!existingNames.has(preferred)) {
return preferred;
}
let index = 1;
while (existingNames.has(`${preferred}-runtime-${index}`)) {
index += 1;
}
return `${preferred}-runtime-${index}`;
}
function buildOverlayDiagnostics(input: {
preferredMcpName: string;
appMcpServerName: string;
existingMcpNames: Set<string>;
preservedSources: OpenCodeBehaviorSource[];
checkedAt: string;
}): string[] {
const diagnostics = [
`OpenCode managed overlay checked at ${input.checkedAt}`,
`OpenCode preserved behavior sources: ${input.preservedSources.filter((source) => source.exists).length}`,
];
if (input.appMcpServerName !== input.preferredMcpName) {
diagnostics.push(
`User OpenCode config already declares MCP server "${input.preferredMcpName}"; managed runtime will use "${input.appMcpServerName}"`
);
}
if (input.existingMcpNames.size > 0) {
diagnostics.push(
`OpenCode existing MCP server names observed: ${[...input.existingMcpNames].sort().join(', ')}`
);
}
return diagnostics;
}
function stripJsonComments(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, (_match, prefix: string) => prefix);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hashJson(value: unknown): string {
return hashText(stableJsonStringify(value));
}
function hashText(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function hashBuffer(value: Buffer): string {
return createHash('sha256').update(value).digest('hex');
}
function stableJsonStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(stableJsonStringify).join(',')}]`;
}
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`)
.join(',')}}`;
}

View file

@ -0,0 +1,482 @@
import { stableHash, stableJsonStringify } from '../bridge/OpenCodeBridgeCommandContract';
import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore';
export const RUNTIME_DELIVERY_JOURNAL_SCHEMA_VERSION = 1;
export type RuntimeDeliveryJournalStatus =
| 'pending'
| 'committed'
| 'failed_retryable'
| 'failed_terminal';
export type RuntimeDeliveryDestinationRef =
| { kind: 'user_sent_messages'; teamName: string }
| { kind: 'member_inbox'; teamName: string; memberName: string }
| {
kind: 'cross_team_outbox';
fromTeamName: string;
toTeamName: string;
toMemberName: string;
};
export type RuntimeDeliveryLocation =
| { kind: 'user_sent_messages'; teamName: string; messageId: string }
| { kind: 'member_inbox'; teamName: string; memberName: string; messageId: string }
| {
kind: 'cross_team_outbox';
fromTeamName: string;
toTeamName: string;
toMemberName: string;
messageId: string;
};
export interface RuntimeDeliveryJournalRecord {
idempotencyKey: string;
runId: string;
teamName: string;
fromMemberName: string;
providerId: 'opencode';
runtimeSessionId: string;
payloadHash: string;
destination: RuntimeDeliveryDestinationRef;
destinationMessageId: string;
committedLocation: RuntimeDeliveryLocation | null;
status: RuntimeDeliveryJournalStatus;
attempts: number;
createdAt: string;
updatedAt: string;
committedAt: string | null;
lastError: string | null;
}
export interface RuntimeDeliveryJournalBeginInput {
idempotencyKey: string;
payloadHash: string;
runId: string;
teamName: string;
fromMemberName: string;
providerId: 'opencode';
runtimeSessionId: string;
destination: RuntimeDeliveryDestinationRef;
destinationMessageId: string;
now: string;
}
export type RuntimeDeliveryJournalBeginResult =
| { state: 'new'; record: RuntimeDeliveryJournalRecord }
| { state: 'already_committed'; record: RuntimeDeliveryJournalRecord }
| { state: 'resume_pending'; record: RuntimeDeliveryJournalRecord }
| { state: 'payload_conflict'; record: RuntimeDeliveryJournalRecord };
export class RuntimeDeliveryJournalStore {
constructor(private readonly store: VersionedJsonStore<RuntimeDeliveryJournalRecord[]>) {}
async begin(input: RuntimeDeliveryJournalBeginInput): Promise<RuntimeDeliveryJournalBeginResult> {
let result: RuntimeDeliveryJournalBeginResult | null = null;
await this.store.updateLocked((records) => {
const existing = records.find((record) => record.idempotencyKey === input.idempotencyKey);
if (existing) {
if (existing.payloadHash !== input.payloadHash) {
result = { state: 'payload_conflict', record: existing };
return records;
}
if (existing.status === 'committed') {
result = { state: 'already_committed', record: existing };
return records;
}
const resumed = {
...existing,
attempts: existing.attempts + 1,
status: existing.status === 'failed_terminal' ? existing.status : 'pending',
updatedAt: input.now,
} satisfies RuntimeDeliveryJournalRecord;
result = { state: 'resume_pending', record: resumed };
return records.map((record) =>
record.idempotencyKey === input.idempotencyKey ? resumed : record
);
}
const created: RuntimeDeliveryJournalRecord = {
idempotencyKey: input.idempotencyKey,
runId: input.runId,
teamName: input.teamName,
fromMemberName: input.fromMemberName,
providerId: input.providerId,
runtimeSessionId: input.runtimeSessionId,
payloadHash: input.payloadHash,
destination: input.destination,
destinationMessageId: input.destinationMessageId,
committedLocation: null,
status: 'pending',
attempts: 1,
createdAt: input.now,
updatedAt: input.now,
committedAt: null,
lastError: null,
};
result = { state: 'new', record: created };
return [...records, created];
});
if (!result) {
throw new Error('Runtime delivery journal begin failed');
}
return result;
}
async markCommitted(input: {
idempotencyKey: string;
location: RuntimeDeliveryLocation;
committedAt: string;
}): Promise<void> {
await this.updateExisting(input.idempotencyKey, (record) => ({
...record,
committedLocation: input.location,
status: 'committed',
updatedAt: input.committedAt,
committedAt: input.committedAt,
lastError: null,
}));
}
async markFailed(input: {
idempotencyKey: string;
status: 'failed_retryable' | 'failed_terminal';
error: string;
updatedAt: string;
}): Promise<void> {
await this.updateExisting(input.idempotencyKey, (record) => ({
...record,
status: input.status,
updatedAt: input.updatedAt,
lastError: input.error,
}));
}
async get(idempotencyKey: string): Promise<RuntimeDeliveryJournalRecord | null> {
const records = await this.readRequired();
return records.find((record) => record.idempotencyKey === idempotencyKey) ?? null;
}
async listRecoverable(teamName: string): Promise<RuntimeDeliveryJournalRecord[]> {
const records = await this.readRequired();
return records.filter(
(record) =>
record.teamName === teamName &&
(record.status === 'pending' || record.status === 'failed_retryable')
);
}
async findCommittedByRuntimeSession(input: {
teamName: string;
runId: string;
runtimeSessionId: string;
}): Promise<Map<string, RuntimeDeliveryJournalRecord>> {
const records = await this.readRequired();
return new Map(
records
.filter(
(record) =>
record.teamName === input.teamName &&
record.runId === input.runId &&
record.runtimeSessionId === input.runtimeSessionId &&
record.status === 'committed'
)
.map((record) => [record.idempotencyKey, record])
);
}
async list(): Promise<RuntimeDeliveryJournalRecord[]> {
return this.readRequired();
}
private async updateExisting(
idempotencyKey: string,
updater: (record: RuntimeDeliveryJournalRecord) => RuntimeDeliveryJournalRecord
): Promise<void> {
let found = false;
await this.store.updateLocked((records) =>
records.map((record) => {
if (record.idempotencyKey !== idempotencyKey) {
return record;
}
found = true;
return updater(record);
})
);
if (!found) {
throw new Error(`Runtime delivery journal record not found: ${idempotencyKey}`);
}
}
private async readRequired(): Promise<RuntimeDeliveryJournalRecord[]> {
const result = await this.store.read();
if (!result.ok) {
throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath);
}
return result.data;
}
}
export function createRuntimeDeliveryJournalStore(options: {
filePath: string;
clock?: () => Date;
}): RuntimeDeliveryJournalStore {
const clock = options.clock ?? (() => new Date());
return new RuntimeDeliveryJournalStore(
new VersionedJsonStore<RuntimeDeliveryJournalRecord[]>({
filePath: options.filePath,
schemaVersion: RUNTIME_DELIVERY_JOURNAL_SCHEMA_VERSION,
defaultData: () => [],
validate: validateRuntimeDeliveryJournalRecords,
clock,
})
);
}
export function validateRuntimeDeliveryJournalRecords(
value: unknown
): RuntimeDeliveryJournalRecord[] {
if (!Array.isArray(value)) {
throw new Error('Runtime delivery journal must be an array');
}
const seen = new Set<string>();
return value.map((record, index) => {
if (!isRuntimeDeliveryJournalRecord(record)) {
throw new Error(`Invalid runtime delivery journal record at index ${index}`);
}
if (seen.has(record.idempotencyKey)) {
throw new Error(`Duplicate runtime delivery idempotency key: ${record.idempotencyKey}`);
}
seen.add(record.idempotencyKey);
return record;
});
}
export function hashRuntimeDeliveryEnvelope(envelope: RuntimeDeliveryEnvelope): string {
return `sha256:${stableHash({
providerId: envelope.providerId,
runId: envelope.runId,
teamName: envelope.teamName,
fromMemberName: envelope.fromMemberName,
runtimeSessionId: envelope.runtimeSessionId,
to: envelope.to,
text: envelope.text,
summary: envelope.summary ?? null,
taskRefs: envelope.taskRefs ?? [],
createdAt: envelope.createdAt,
})}`;
}
export function buildRuntimeDestinationMessageId(envelope: RuntimeDeliveryEnvelope): string {
return `runtime-delivery-${stableHash({
idempotencyKey: envelope.idempotencyKey,
runId: envelope.runId,
teamName: envelope.teamName,
}).slice(0, 32)}`;
}
export type RuntimeDeliveryTarget =
| 'user'
| { memberName: string }
| { teamName: string; memberName: string };
export interface RuntimeDeliveryEnvelope {
idempotencyKey: string;
runId: string;
teamName: string;
fromMemberName: string;
providerId: 'opencode';
runtimeSessionId: string;
to: RuntimeDeliveryTarget;
text: string;
createdAt: string;
summary?: string | null;
taskRefs?: string[];
}
export function normalizeRuntimeDeliveryEnvelope(value: unknown): RuntimeDeliveryEnvelope {
if (!isRecord(value)) {
throw new Error('Runtime delivery envelope must be an object');
}
const envelope: RuntimeDeliveryEnvelope = {
idempotencyKey: requireNonEmptyString(value.idempotencyKey, 'idempotencyKey'),
runId: requireNonEmptyString(value.runId, 'runId'),
teamName: requireNonEmptyString(value.teamName, 'teamName'),
fromMemberName: requireNonEmptyString(value.fromMemberName, 'fromMemberName'),
providerId: value.providerId === 'opencode' ? 'opencode' : fail('providerId must be opencode'),
runtimeSessionId: requireNonEmptyString(value.runtimeSessionId, 'runtimeSessionId'),
to: normalizeRuntimeDeliveryTarget(value.to),
text: requireNonEmptyString(value.text, 'text'),
createdAt: requireNonEmptyString(value.createdAt, 'createdAt'),
summary: value.summary === undefined || value.summary === null ? null : String(value.summary),
taskRefs: Array.isArray(value.taskRefs)
? value.taskRefs.filter((item): item is string => typeof item === 'string')
: [],
};
return envelope;
}
export function resolveRuntimeDeliveryDestination(
envelope: RuntimeDeliveryEnvelope
): RuntimeDeliveryDestinationRef {
if (envelope.to === 'user') {
return { kind: 'user_sent_messages', teamName: envelope.teamName };
}
if ('memberName' in envelope.to && !('teamName' in envelope.to)) {
return {
kind: 'member_inbox',
teamName: envelope.teamName,
memberName: envelope.to.memberName,
};
}
return {
kind: 'cross_team_outbox',
fromTeamName: envelope.teamName,
toTeamName: envelope.to.teamName,
toMemberName: envelope.to.memberName,
};
}
export function buildLocationFromJournal(
record: RuntimeDeliveryJournalRecord
): RuntimeDeliveryLocation {
if (record.committedLocation) {
return record.committedLocation;
}
switch (record.destination.kind) {
case 'user_sent_messages':
return {
kind: 'user_sent_messages',
teamName: record.destination.teamName,
messageId: record.destinationMessageId,
};
case 'member_inbox':
return {
kind: 'member_inbox',
teamName: record.destination.teamName,
memberName: record.destination.memberName,
messageId: record.destinationMessageId,
};
case 'cross_team_outbox':
return {
kind: 'cross_team_outbox',
fromTeamName: record.destination.fromTeamName,
toTeamName: record.destination.toTeamName,
toMemberName: record.destination.toMemberName,
messageId: record.destinationMessageId,
};
}
}
export function runtimeDeliveryEnvelopeStableJson(envelope: RuntimeDeliveryEnvelope): string {
return stableJsonStringify(envelope);
}
function normalizeRuntimeDeliveryTarget(value: unknown): RuntimeDeliveryTarget {
if (value === 'user') {
return 'user';
}
if (!isRecord(value)) {
throw new Error('Runtime delivery target must be user or object');
}
const memberName = requireNonEmptyString(value.memberName, 'to.memberName');
if (typeof value.teamName === 'string' && value.teamName.trim()) {
return { teamName: value.teamName, memberName };
}
return { memberName };
}
function isRuntimeDeliveryJournalRecord(value: unknown): value is RuntimeDeliveryJournalRecord {
return (
isRecord(value) &&
isNonEmptyString(value.idempotencyKey) &&
isNonEmptyString(value.runId) &&
isNonEmptyString(value.teamName) &&
isNonEmptyString(value.fromMemberName) &&
value.providerId === 'opencode' &&
isNonEmptyString(value.runtimeSessionId) &&
isNonEmptyString(value.payloadHash) &&
isRuntimeDeliveryDestinationRef(value.destination) &&
isNonEmptyString(value.destinationMessageId) &&
(value.committedLocation === null || isRuntimeDeliveryLocation(value.committedLocation)) &&
isRuntimeDeliveryJournalStatus(value.status) &&
Number.isInteger(value.attempts) &&
(value.attempts as number) >= 1 &&
isNonEmptyString(value.createdAt) &&
isNonEmptyString(value.updatedAt) &&
(value.committedAt === null || isNonEmptyString(value.committedAt)) &&
(value.lastError === null || typeof value.lastError === 'string')
);
}
function isRuntimeDeliveryJournalStatus(value: unknown): value is RuntimeDeliveryJournalStatus {
return (
value === 'pending' ||
value === 'committed' ||
value === 'failed_retryable' ||
value === 'failed_terminal'
);
}
function isRuntimeDeliveryDestinationRef(value: unknown): value is RuntimeDeliveryDestinationRef {
if (!isRecord(value)) {
return false;
}
if (value.kind === 'user_sent_messages') {
return isNonEmptyString(value.teamName);
}
if (value.kind === 'member_inbox') {
return isNonEmptyString(value.teamName) && isNonEmptyString(value.memberName);
}
return (
value.kind === 'cross_team_outbox' &&
isNonEmptyString(value.fromTeamName) &&
isNonEmptyString(value.toTeamName) &&
isNonEmptyString(value.toMemberName)
);
}
function isRuntimeDeliveryLocation(value: unknown): value is RuntimeDeliveryLocation {
if (!isRecord(value) || !isNonEmptyString(value.messageId)) {
return false;
}
if (value.kind === 'user_sent_messages') {
return isNonEmptyString(value.teamName);
}
if (value.kind === 'member_inbox') {
return isNonEmptyString(value.teamName) && isNonEmptyString(value.memberName);
}
return (
value.kind === 'cross_team_outbox' &&
isNonEmptyString(value.fromTeamName) &&
isNonEmptyString(value.toTeamName) &&
isNonEmptyString(value.toMemberName)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function requireNonEmptyString(value: unknown, field: string): string {
if (!isNonEmptyString(value)) {
throw new Error(`Runtime delivery envelope missing ${field}`);
}
return value;
}
function fail(message: string): never {
throw new Error(message);
}

View file

@ -0,0 +1,306 @@
import {
buildLocationFromJournal,
buildRuntimeDestinationMessageId,
hashRuntimeDeliveryEnvelope,
normalizeRuntimeDeliveryEnvelope,
resolveRuntimeDeliveryDestination,
type RuntimeDeliveryDestinationRef,
type RuntimeDeliveryEnvelope,
type RuntimeDeliveryJournalRecord,
RuntimeDeliveryJournalStore,
type RuntimeDeliveryLocation,
} from './RuntimeDeliveryJournal';
export interface RuntimeDeliveryVerifyResult {
found: boolean;
location: RuntimeDeliveryLocation | null;
diagnostics: string[];
}
export interface RuntimeDeliveryDestinationPort {
readonly kind: RuntimeDeliveryDestinationRef['kind'];
write(input: {
envelope: RuntimeDeliveryEnvelope;
destinationMessageId: string;
}): Promise<RuntimeDeliveryLocation>;
verify(input: {
destination: RuntimeDeliveryDestinationRef;
destinationMessageId: string;
}): Promise<RuntimeDeliveryVerifyResult>;
buildChangeEvent(input: {
teamName: string;
location: RuntimeDeliveryLocation;
}): RuntimeDeliveryTeamChangeEvent | null;
}
export interface RuntimeDeliveryTeamChangeEvent {
type: string;
teamName: string;
data?: Record<string, unknown>;
}
export interface RuntimeDeliveryRunStateReader {
getCurrentRunId(teamName: string): Promise<string | null>;
}
export interface RuntimeDeliveryDiagnosticsSink {
append(event: RuntimeDeliveryDiagnosticEvent): Promise<void>;
}
export interface RuntimeDeliveryDiagnosticEvent {
type:
| 'runtime_delivery_conflict'
| 'runtime_delivery_failed'
| 'runtime_delivery_recovery_needed';
providerId: 'opencode';
teamName: string;
runId: string;
severity: 'warning' | 'error';
message: string;
data?: Record<string, unknown>;
createdAt: string;
}
export interface RuntimeDeliveryTeamChangeEmitter {
emit(event: RuntimeDeliveryTeamChangeEvent): void;
}
export type RuntimeDeliveryAck =
| {
ok: true;
delivered: boolean;
reason: null | 'duplicate' | 'duplicate_destination_found';
idempotencyKey: string;
location: RuntimeDeliveryLocation;
}
| {
ok: false;
delivered: false;
reason: 'stale_run' | 'idempotency_conflict';
idempotencyKey: string;
};
export class RuntimeDeliveryDestinationRegistry {
private readonly ports = new Map<
RuntimeDeliveryDestinationRef['kind'],
RuntimeDeliveryDestinationPort
>();
constructor(ports: RuntimeDeliveryDestinationPort[]) {
for (const port of ports) {
if (this.ports.has(port.kind)) {
throw new Error(`Duplicate runtime delivery destination port: ${port.kind}`);
}
this.ports.set(port.kind, port);
}
}
get(kind: RuntimeDeliveryDestinationRef['kind']): RuntimeDeliveryDestinationPort {
const port = this.ports.get(kind);
if (!port) {
throw new Error(`Runtime delivery destination port not registered: ${kind}`);
}
return port;
}
}
export class RuntimeDeliveryService {
constructor(
private readonly runState: RuntimeDeliveryRunStateReader,
private readonly journal: RuntimeDeliveryJournalStore,
private readonly destinations: RuntimeDeliveryDestinationRegistry,
private readonly diagnostics: RuntimeDeliveryDiagnosticsSink,
private readonly teamChangeEmitter: RuntimeDeliveryTeamChangeEmitter,
private readonly clock: () => Date = () => new Date()
) {}
async deliver(raw: unknown): Promise<RuntimeDeliveryAck> {
const envelope = normalizeRuntimeDeliveryEnvelope(raw);
const now = this.clock().toISOString();
const currentRunId = await this.runState.getCurrentRunId(envelope.teamName);
if (currentRunId !== envelope.runId) {
return {
ok: false,
delivered: false,
reason: 'stale_run',
idempotencyKey: envelope.idempotencyKey,
};
}
const destination = resolveRuntimeDeliveryDestination(envelope);
const destinationMessageId = buildRuntimeDestinationMessageId(envelope);
const payloadHash = hashRuntimeDeliveryEnvelope(envelope);
const begin = await this.journal.begin({
idempotencyKey: envelope.idempotencyKey,
payloadHash,
runId: envelope.runId,
teamName: envelope.teamName,
fromMemberName: envelope.fromMemberName,
providerId: envelope.providerId,
runtimeSessionId: envelope.runtimeSessionId,
destination,
destinationMessageId,
now,
});
if (begin.state === 'payload_conflict') {
await this.diagnostics.append({
type: 'runtime_delivery_conflict',
providerId: 'opencode',
teamName: envelope.teamName,
runId: envelope.runId,
severity: 'error',
message: 'Runtime delivery idempotency key was reused with a different payload',
data: {
idempotencyKey: envelope.idempotencyKey,
existingPayloadHash: begin.record.payloadHash,
newPayloadHash: payloadHash,
},
createdAt: now,
});
return {
ok: false,
delivered: false,
reason: 'idempotency_conflict',
idempotencyKey: envelope.idempotencyKey,
};
}
if (begin.state === 'already_committed') {
return {
ok: true,
delivered: false,
reason: 'duplicate',
idempotencyKey: envelope.idempotencyKey,
location: buildLocationFromJournal(begin.record),
};
}
const port = this.destinations.get(destination.kind);
const preExisting = await port.verify({ destination, destinationMessageId });
if (preExisting.found && preExisting.location) {
await this.journal.markCommitted({
idempotencyKey: envelope.idempotencyKey,
location: preExisting.location,
committedAt: now,
});
return {
ok: true,
delivered: false,
reason: 'duplicate_destination_found',
idempotencyKey: envelope.idempotencyKey,
location: preExisting.location,
};
}
try {
const location = await port.write({ envelope, destinationMessageId });
const verified = await port.verify({ destination, destinationMessageId });
if (!verified.found) {
throw new Error(
`Delivery destination write was not verifiable for ${destinationMessageId}`
);
}
const committedLocation = verified.location ?? location;
await this.journal.markCommitted({
idempotencyKey: envelope.idempotencyKey,
location: committedLocation,
committedAt: this.clock().toISOString(),
});
const change = port.buildChangeEvent({
teamName: envelope.teamName,
location: committedLocation,
});
if (change) {
this.teamChangeEmitter.emit(change);
}
return {
ok: true,
delivered: true,
reason: null,
idempotencyKey: envelope.idempotencyKey,
location: committedLocation,
};
} catch (error) {
await this.journal.markFailed({
idempotencyKey: envelope.idempotencyKey,
status: 'failed_retryable',
error: stringifyError(error),
updatedAt: this.clock().toISOString(),
});
await this.diagnostics.append({
type: 'runtime_delivery_failed',
providerId: 'opencode',
teamName: envelope.teamName,
runId: envelope.runId,
severity: 'warning',
message: 'Runtime delivery failed and remains retryable',
data: {
idempotencyKey: envelope.idempotencyKey,
destination,
error: stringifyError(error),
},
createdAt: this.clock().toISOString(),
});
throw error;
}
}
}
export class RuntimeDeliveryReconciler {
constructor(
private readonly journal: RuntimeDeliveryJournalStore,
private readonly destinations: RuntimeDeliveryDestinationRegistry,
private readonly diagnostics: RuntimeDeliveryDiagnosticsSink,
private readonly clock: () => Date = () => new Date()
) {}
async reconcileTeam(teamName: string): Promise<void> {
const records = await this.journal.listRecoverable(teamName);
for (const record of records) {
await this.reconcileRecord(record);
}
}
private async reconcileRecord(record: RuntimeDeliveryJournalRecord): Promise<void> {
const port = this.destinations.get(record.destination.kind);
const verified = await port.verify({
destination: record.destination,
destinationMessageId: record.destinationMessageId,
});
if (verified.found && verified.location) {
await this.journal.markCommitted({
idempotencyKey: record.idempotencyKey,
location: verified.location,
committedAt: this.clock().toISOString(),
});
return;
}
await this.diagnostics.append({
type: 'runtime_delivery_recovery_needed',
providerId: 'opencode',
teamName: record.teamName,
runId: record.runId,
severity: 'warning',
message: `Runtime delivery ${record.idempotencyKey} is pending and destination write is not visible`,
data: {
destination: record.destination,
attempts: record.attempts,
lastError: record.lastError,
},
createdAt: this.clock().toISOString(),
});
}
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,596 @@
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1;
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION = 1;
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export const OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS = [
'required_tools_proven',
'delivery_ready',
'member_ready',
'run_ready',
] as const;
export const OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS = [
'app_mcp_tools_visible',
'state_changing_launch_completed',
'session_records_persisted',
'bootstrap_confirmed_alive',
'canonical_log_projection_observed',
'reconcile_completed',
'stop_completed',
'stale_run_rejected',
] as const;
export type OpenCodeProductionE2ERequiredSignal =
(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number];
export interface OpenCodeProductionE2ECheckpointEvidence {
name: string;
observedAt: string;
}
export interface OpenCodeProductionE2ESessionEvidence {
memberName: string;
sessionId: string;
launchState: 'confirmed_alive';
}
export interface OpenCodeProductionE2EEvidence {
schemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION;
evidenceId: string;
createdAt: string;
expiresAt: string;
version: string;
passed: boolean;
artifactPath: string | null;
binaryFingerprint: string;
capabilitySnapshotId: string;
selectedModel: string;
projectPathFingerprint: string | null;
requiredSignals: Record<OpenCodeProductionE2ERequiredSignal, boolean>;
mcpTools: {
requiredTools: string[];
observedTools: string[];
};
launch: {
runId: string;
teamId: string;
teamLaunchState: 'ready';
memberCount: number;
sessions: OpenCodeProductionE2ESessionEvidence[];
durableCheckpoints: OpenCodeProductionE2ECheckpointEvidence[];
};
reconcile: {
runId: string;
teamLaunchState: 'ready';
memberCount: number;
};
stop: {
runId: string;
stopped: true;
stoppedSessionIds: string[];
};
logProjection: {
observed: true;
projectedMessageCount: number;
};
diagnostics?: string[];
}
export interface OpenCodeProductionE2EEvidenceCollection {
collectionSchemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION;
entriesByModel: Record<string, OpenCodeProductionE2EEvidence>;
}
export type OpenCodeProductionE2EEvidenceStoreData =
| OpenCodeProductionE2EEvidence
| OpenCodeProductionE2EEvidenceCollection
| null;
export interface OpenCodeProductionE2EGateExpectation {
opencodeVersion: string | null;
binaryFingerprint: string | null;
capabilitySnapshotId: string | null;
selectedModel: string | null;
requiredMcpTools?: string[];
}
export interface OpenCodeProductionE2EGateResult {
ok: boolean;
diagnostics: string[];
}
export function validateOpenCodeProductionE2EEvidence(
value: unknown
): OpenCodeProductionE2EEvidence {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence must be an object');
}
if (record.schemaVersion !== OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION) {
throw new Error('OpenCode production E2E evidence has unsupported schemaVersion');
}
const evidence: OpenCodeProductionE2EEvidence = {
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
evidenceId: requireString(record.evidenceId, 'evidenceId'),
createdAt: requireIsoDate(record.createdAt, 'createdAt'),
expiresAt: requireIsoDate(record.expiresAt, 'expiresAt'),
version: requireString(record.version, 'version'),
passed: requireBoolean(record.passed, 'passed'),
artifactPath: optionalString(record.artifactPath, 'artifactPath'),
binaryFingerprint: requireString(record.binaryFingerprint, 'binaryFingerprint'),
capabilitySnapshotId: requireString(record.capabilitySnapshotId, 'capabilitySnapshotId'),
selectedModel: requireString(record.selectedModel, 'selectedModel'),
projectPathFingerprint: optionalString(record.projectPathFingerprint, 'projectPathFingerprint'),
requiredSignals: normalizeRequiredSignals(record.requiredSignals),
mcpTools: normalizeMcpTools(record.mcpTools),
launch: normalizeLaunch(record.launch),
reconcile: normalizeReconcile(record.reconcile),
stop: normalizeStop(record.stop),
logProjection: normalizeLogProjection(record.logProjection),
diagnostics: optionalStringArray(record.diagnostics, 'diagnostics'),
};
return evidence;
}
export function validateNullableOpenCodeProductionE2EEvidence(
value: unknown
): OpenCodeProductionE2EEvidence | null {
if (value === null) {
return null;
}
return validateOpenCodeProductionE2EEvidence(value);
}
export function validateOpenCodeProductionE2EEvidenceStoreData(
value: unknown
): OpenCodeProductionE2EEvidenceStoreData {
if (value === null) {
return null;
}
const record = asRecord(value);
if (
record?.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION
) {
return validateOpenCodeProductionE2EEvidenceCollection(record);
}
return validateOpenCodeProductionE2EEvidence(value);
}
export function isOpenCodeProductionE2EEvidenceCollection(
value: OpenCodeProductionE2EEvidenceStoreData
): value is OpenCodeProductionE2EEvidenceCollection {
return (
value !== null &&
typeof value === 'object' &&
'collectionSchemaVersion' in value &&
value.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION
);
}
export function assertOpenCodeProductionE2EEvidenceBasics(input: {
evidence: OpenCodeProductionE2EEvidence | null;
testedVersion: string;
now?: Date;
artifactPath?: string | null;
}): OpenCodeProductionE2EGateResult {
const diagnostics: string[] = [];
const now = input.now ?? new Date();
const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null;
if (!input.evidence) {
return {
ok: false,
diagnostics: [
'OpenCode version is capability-compatible but production E2E evidence is missing',
],
};
}
diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath));
if (input.evidence.version !== input.testedVersion) {
diagnostics.push(
`OpenCode production E2E evidence version ${input.evidence.version} does not match tested version ${input.testedVersion}`
);
}
return {
ok: diagnostics.length === 0,
diagnostics,
};
}
export function assertOpenCodeProductionE2EArtifactGate(input: {
evidence: OpenCodeProductionE2EEvidence | null;
expected: OpenCodeProductionE2EGateExpectation;
now?: Date;
artifactPath?: string | null;
}): OpenCodeProductionE2EGateResult {
const diagnostics: string[] = [];
const now = input.now ?? new Date();
const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null;
if (!input.evidence) {
return {
ok: false,
diagnostics: [
'OpenCode production launch requires a current production E2E evidence artifact',
],
};
}
diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath));
diagnostics.push(...collectExpectedRuntimeDiagnostics(input.evidence, input.expected));
return {
ok: diagnostics.length === 0,
diagnostics,
};
}
function collectArtifactShapeDiagnostics(
evidence: OpenCodeProductionE2EEvidence,
now: Date,
artifactPath: string | null
): string[] {
const diagnostics: string[] = [];
const createdAtMs = Date.parse(evidence.createdAt);
const expiresAtMs = Date.parse(evidence.expiresAt);
if (!evidence.passed) {
diagnostics.push('OpenCode production E2E evidence did not pass');
}
if (!artifactPath) {
diagnostics.push('OpenCode production E2E evidence artifact path is missing');
}
if (!Number.isFinite(createdAtMs)) {
diagnostics.push('OpenCode production E2E evidence createdAt is invalid');
} else if (now.getTime() - createdAtMs > OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS) {
diagnostics.push('OpenCode production E2E evidence is older than the maximum allowed age');
}
if (!Number.isFinite(expiresAtMs)) {
diagnostics.push('OpenCode production E2E evidence expiresAt is invalid');
} else if (expiresAtMs <= now.getTime()) {
diagnostics.push('OpenCode production E2E evidence is expired');
}
const missingSignals = OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.filter(
(signal) => evidence.requiredSignals[signal] !== true
);
if (missingSignals.length > 0) {
diagnostics.push(
`OpenCode production E2E evidence is missing signals: ${missingSignals.join(', ')}`
);
}
const checkpointNames = new Set(
evidence.launch.durableCheckpoints.map((checkpoint) => checkpoint.name)
);
const missingCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.filter(
(checkpoint) => !checkpointNames.has(checkpoint)
);
if (missingCheckpoints.length > 0) {
diagnostics.push(
`OpenCode production E2E evidence is missing durable checkpoints: ${missingCheckpoints.join(', ')}`
);
}
if (
evidence.launch.memberCount <= 0 ||
evidence.launch.sessions.length !== evidence.launch.memberCount
) {
diagnostics.push(
'OpenCode production E2E evidence must include confirmed session evidence for every member'
);
}
if (evidence.reconcile.runId !== evidence.launch.runId) {
diagnostics.push(
'OpenCode production E2E reconcile evidence runId does not match launch runId'
);
}
if (evidence.reconcile.memberCount !== evidence.launch.memberCount) {
diagnostics.push(
'OpenCode production E2E reconcile member count does not match launch member count'
);
}
if (evidence.stop.runId !== evidence.launch.runId) {
diagnostics.push('OpenCode production E2E stop evidence runId does not match launch runId');
}
if (evidence.stop.stoppedSessionIds.length < evidence.launch.sessions.length) {
diagnostics.push(
'OpenCode production E2E evidence does not prove every launched session was stopped'
);
}
if (evidence.logProjection.projectedMessageCount <= 0) {
diagnostics.push('OpenCode production E2E evidence must include projected log messages');
}
const observedTools = new Set(evidence.mcpTools.observedTools);
const missingTools = evidence.mcpTools.requiredTools.filter((tool) => !observedTools.has(tool));
if (missingTools.length > 0) {
diagnostics.push(
`OpenCode production E2E evidence is missing observed MCP tools: ${missingTools.join(', ')}`
);
}
return diagnostics;
}
function collectExpectedRuntimeDiagnostics(
evidence: OpenCodeProductionE2EEvidence,
expected: OpenCodeProductionE2EGateExpectation
): string[] {
const diagnostics: string[] = [];
if (!expected.opencodeVersion) {
diagnostics.push('OpenCode production gate cannot verify runtime version');
} else if (evidence.version !== expected.opencodeVersion) {
diagnostics.push(
`OpenCode production E2E evidence version ${evidence.version} does not match runtime version ${expected.opencodeVersion}`
);
}
if (!expected.binaryFingerprint) {
diagnostics.push('OpenCode production gate cannot verify runtime binary fingerprint');
} else if (evidence.binaryFingerprint !== expected.binaryFingerprint) {
diagnostics.push(
'OpenCode production E2E evidence binary fingerprint does not match runtime binary fingerprint'
);
}
if (!expected.capabilitySnapshotId) {
diagnostics.push('OpenCode production gate cannot verify capability snapshot id');
} else if (evidence.capabilitySnapshotId !== expected.capabilitySnapshotId) {
diagnostics.push(
'OpenCode production E2E evidence capability snapshot does not match current runtime'
);
}
if (!expected.selectedModel) {
diagnostics.push('OpenCode production gate cannot verify selected raw model id');
} else if (evidence.selectedModel !== expected.selectedModel) {
diagnostics.push(
`OpenCode production E2E evidence model ${evidence.selectedModel} does not match selected model ${expected.selectedModel}. Production launch is intentionally scoped to the exact raw model id; regenerate evidence with OPENCODE_E2E_MODEL=${expected.selectedModel}.`
);
}
const requiredTools = expected.requiredMcpTools ?? [];
if (requiredTools.length > 0) {
const observedTools = new Set(evidence.mcpTools.observedTools);
const missingTools = requiredTools.filter((tool) => !observedTools.has(tool));
if (missingTools.length > 0) {
diagnostics.push(
`OpenCode production E2E evidence does not prove required app MCP tools: ${missingTools.join(', ')}`
);
}
}
return diagnostics;
}
function normalizeRequiredSignals(
value: unknown
): Record<OpenCodeProductionE2ERequiredSignal, boolean> {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence requiredSignals must be an object');
}
return Object.fromEntries(
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [
signal,
requireBoolean(record[signal], `requiredSignals.${signal}`),
])
) as Record<OpenCodeProductionE2ERequiredSignal, boolean>;
}
function normalizeMcpTools(value: unknown): OpenCodeProductionE2EEvidence['mcpTools'] {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence mcpTools must be an object');
}
return {
requiredTools: requireStringArray(record.requiredTools, 'mcpTools.requiredTools'),
observedTools: requireStringArray(record.observedTools, 'mcpTools.observedTools'),
};
}
function validateOpenCodeProductionE2EEvidenceCollection(
value: Record<string, unknown>
): OpenCodeProductionE2EEvidenceCollection {
const entriesRecord = asRecord(value.entriesByModel);
if (!entriesRecord) {
throw new Error('OpenCode production E2E evidence collection entriesByModel must be an object');
}
const entries: Record<string, OpenCodeProductionE2EEvidence> = {};
for (const [modelId, rawEvidence] of Object.entries(entriesRecord)) {
const trimmedModelId = modelId.trim();
if (!trimmedModelId) {
throw new Error('OpenCode production E2E evidence collection model id must be non-empty');
}
const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence);
if (evidence.selectedModel !== trimmedModelId) {
throw new Error(
`OpenCode production E2E evidence collection key ${trimmedModelId} does not match selectedModel ${evidence.selectedModel}`
);
}
entries[trimmedModelId] = evidence;
}
return {
collectionSchemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION,
entriesByModel: entries,
};
}
function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence launch must be an object');
}
if (record.teamLaunchState !== 'ready') {
throw new Error('OpenCode production E2E evidence launch.teamLaunchState must be ready');
}
return {
runId: requireString(record.runId, 'launch.runId'),
teamId: requireString(record.teamId, 'launch.teamId'),
teamLaunchState: 'ready',
memberCount: requirePositiveInteger(record.memberCount, 'launch.memberCount'),
sessions: requireArray(record.sessions, 'launch.sessions').map(normalizeSession),
durableCheckpoints: requireArray(record.durableCheckpoints, 'launch.durableCheckpoints').map(
normalizeCheckpoint
),
};
}
function normalizeSession(value: unknown): OpenCodeProductionE2ESessionEvidence {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence launch session must be an object');
}
if (record.launchState !== 'confirmed_alive') {
throw new Error('OpenCode production E2E evidence launch session must be confirmed_alive');
}
return {
memberName: requireString(record.memberName, 'launch.sessions.memberName'),
sessionId: requireString(record.sessionId, 'launch.sessions.sessionId'),
launchState: 'confirmed_alive',
};
}
function normalizeCheckpoint(value: unknown): OpenCodeProductionE2ECheckpointEvidence {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence durable checkpoint must be an object');
}
return {
name: requireString(record.name, 'launch.durableCheckpoints.name'),
observedAt: requireIsoDate(record.observedAt, 'launch.durableCheckpoints.observedAt'),
};
}
function normalizeReconcile(value: unknown): OpenCodeProductionE2EEvidence['reconcile'] {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence reconcile must be an object');
}
if (record.teamLaunchState !== 'ready') {
throw new Error('OpenCode production E2E evidence reconcile.teamLaunchState must be ready');
}
return {
runId: requireString(record.runId, 'reconcile.runId'),
teamLaunchState: 'ready',
memberCount: requirePositiveInteger(record.memberCount, 'reconcile.memberCount'),
};
}
function normalizeStop(value: unknown): OpenCodeProductionE2EEvidence['stop'] {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence stop must be an object');
}
if (record.stopped !== true) {
throw new Error('OpenCode production E2E evidence stop.stopped must be true');
}
return {
runId: requireString(record.runId, 'stop.runId'),
stopped: true,
stoppedSessionIds: requireStringArray(record.stoppedSessionIds, 'stop.stoppedSessionIds'),
};
}
function normalizeLogProjection(value: unknown): OpenCodeProductionE2EEvidence['logProjection'] {
const record = asRecord(value);
if (!record) {
throw new Error('OpenCode production E2E evidence logProjection must be an object');
}
if (record.observed !== true) {
throw new Error('OpenCode production E2E evidence logProjection.observed must be true');
}
return {
observed: true,
projectedMessageCount: requirePositiveInteger(
record.projectedMessageCount,
'logProjection.projectedMessageCount'
),
};
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string`);
}
return value.trim();
}
function optionalString(value: unknown, field: string): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string or null`);
}
return value.trim();
}
function requireBoolean(value: unknown, field: string): boolean {
if (typeof value !== 'boolean') {
throw new Error(`OpenCode production E2E evidence ${field} must be boolean`);
}
return value;
}
function requirePositiveInteger(value: unknown, field: string): number {
if (!Number.isInteger(value) || (value as number) <= 0) {
throw new Error(`OpenCode production E2E evidence ${field} must be a positive integer`);
}
return value as number;
}
function requireIsoDate(value: unknown, field: string): string {
const text = requireString(value, field);
if (!Number.isFinite(Date.parse(text))) {
throw new Error(`OpenCode production E2E evidence ${field} must be an ISO timestamp`);
}
return text;
}
function requireArray(value: unknown, field: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`OpenCode production E2E evidence ${field} must be an array`);
}
return value;
}
function requireStringArray(value: unknown, field: string): string[] {
return requireArray(value, field).map((item, index) => requireString(item, `${field}[${index}]`));
}
function optionalStringArray(value: unknown, field: string): string[] | undefined {
if (value === undefined) {
return undefined;
}
return requireStringArray(value, field);
}

View file

@ -0,0 +1,20 @@
import { join, resolve } from 'path';
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV =
'CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH';
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE = 'production-e2e-evidence.json';
export function resolveOpenCodeProductionE2EEvidencePath(input: {
bridgeControlDir: string;
env?: NodeJS.ProcessEnv;
}): string {
const env = input.env ?? process.env;
const overridePath = env[OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]?.trim();
if (overridePath) {
return resolve(overridePath);
}
return join(input.bridgeControlDir, OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE);
}

View file

@ -0,0 +1,148 @@
import * as path from 'path';
import { VersionedJsonStore } from '../store/VersionedJsonStore';
import {
isOpenCodeProductionE2EEvidenceCollection,
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
type OpenCodeProductionE2EEvidence,
type OpenCodeProductionE2EEvidenceCollection,
type OpenCodeProductionE2EEvidenceStoreData,
validateOpenCodeProductionE2EEvidence,
validateOpenCodeProductionE2EEvidenceStoreData,
} from './OpenCodeProductionE2EEvidence';
export interface OpenCodeProductionE2EEvidenceStoreReadResult {
ok: boolean;
evidence: OpenCodeProductionE2EEvidence | null;
artifactPath: string;
diagnostics: string[];
}
export interface OpenCodeProductionE2EEvidenceStoreOptions {
filePath: string;
clock?: () => Date;
}
export interface OpenCodeProductionE2EEvidenceStoreReadOptions {
selectedModel?: string | null;
}
export class OpenCodeProductionE2EEvidenceStore {
private readonly filePath: string;
private readonly store: VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>;
constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) {
this.filePath = options.filePath;
this.store = new VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>({
filePath: options.filePath,
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
defaultData: () => null,
validate: validateOpenCodeProductionE2EEvidenceStoreData,
clock: options.clock,
quarantineDir: path.dirname(options.filePath),
});
}
async read(
options: OpenCodeProductionE2EEvidenceStoreReadOptions = {}
): Promise<OpenCodeProductionE2EEvidenceStoreReadResult> {
const result = await this.store.read();
if (!result.ok) {
return {
ok: false,
evidence: null,
artifactPath: this.filePath,
diagnostics: [
`OpenCode production E2E evidence store is unreadable: ${result.message}`,
...(result.quarantinePath
? [`Quarantined corrupt evidence at ${result.quarantinePath}`]
: []),
],
};
}
const selection = selectEvidence(result.data, options.selectedModel);
return {
ok: true,
evidence: selection.evidence,
artifactPath: this.filePath,
diagnostics: [
...selection.diagnostics,
...(result.status === 'missing'
? ['OpenCode production E2E evidence artifact has not been written yet']
: []),
],
};
}
async write(evidence: OpenCodeProductionE2EEvidence): Promise<void> {
const validated = validateOpenCodeProductionE2EEvidence(evidence);
await this.store.updateLocked((current) => {
const nextEvidence = {
...validated,
artifactPath: validated.artifactPath ?? this.filePath,
};
return upsertEvidence(current, nextEvidence);
});
}
}
function selectEvidence(
data: OpenCodeProductionE2EEvidenceStoreData,
selectedModel: string | null | undefined
): {
evidence: OpenCodeProductionE2EEvidence | null;
diagnostics: string[];
} {
if (!data) {
return { evidence: null, diagnostics: [] };
}
if (!isOpenCodeProductionE2EEvidenceCollection(data)) {
return { evidence: data, diagnostics: [] };
}
const modelId = selectedModel?.trim() ?? '';
if (modelId) {
return {
evidence: data.entriesByModel[modelId] ?? null,
diagnostics: data.entriesByModel[modelId]
? []
: [`OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`],
};
}
const entries = Object.values(data.entriesByModel);
if (entries.length === 1) {
return { evidence: entries[0] ?? null, diagnostics: [] };
}
return {
evidence: null,
diagnostics:
entries.length === 0
? ['OpenCode production E2E evidence artifact has no model entries']
: [
`OpenCode production E2E evidence artifact contains ${entries.length} model entries; selected model is required`,
],
};
}
function upsertEvidence(
current: OpenCodeProductionE2EEvidenceStoreData,
evidence: OpenCodeProductionE2EEvidence
): OpenCodeProductionE2EEvidenceCollection {
const entriesByModel: Record<string, OpenCodeProductionE2EEvidence> = {};
if (isOpenCodeProductionE2EEvidenceCollection(current)) {
Object.assign(entriesByModel, current.entriesByModel);
} else if (current) {
entriesByModel[current.selectedModel] = current;
}
entriesByModel[evidence.selectedModel] = evidence;
return {
collectionSchemaVersion: 1,
entriesByModel,
};
}

View file

@ -0,0 +1,413 @@
export type OpenCodeEventScope = 'instance' | 'global';
export type OpenCodeNormalizedStatusType = 'idle' | 'busy' | 'retry' | 'error' | 'unknown';
export interface OpenCodeNormalizedSessionStatus {
type: OpenCodeNormalizedStatusType;
retryAttempt: number | null;
retryMessage: string | null;
retryNextAt: number | null;
rawShape: 'v1.14' | 'legacy-string' | 'unknown';
raw: unknown;
}
export type OpenCodeDurableSessionState =
| 'idle'
| 'running'
| 'retrying'
| 'blocked'
| 'reply_pending'
| 'error'
| 'unknown';
export type OpenCodeNormalizedEvent =
| {
kind: 'server_connected' | 'server_heartbeat';
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
| {
kind: 'session_status';
sessionId: string;
status: OpenCodeNormalizedSessionStatus;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
| {
kind: 'session_error';
sessionId: string | null;
errorName: string | null;
errorMessage: string | null;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
| {
kind: 'message_updated';
sessionId: string;
messageId: string | null;
role: 'assistant' | 'user' | 'system' | 'unknown';
info: Record<string, unknown>;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
| {
kind: 'message_part_updated';
sessionId: string;
messageId: string | null;
partId: string | null;
partType: string | null;
textSnapshot: string | null;
part: Record<string, unknown>;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
| {
kind: 'message_part_delta';
sessionId: string;
messageId: string;
partId: string;
field: string;
delta: string;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
| {
kind: 'message_part_removed';
sessionId: string;
messageId: string;
partId: string;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
| {
kind: 'permission_asked' | 'permission_replied';
sessionId: string | null;
requestId: string | null;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
| {
kind: 'unknown';
type: string;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
};
export interface OpenCodeSseEventEnvelope {
type: string;
properties: Record<string, unknown>;
scope: OpenCodeEventScope;
directory: string | null;
raw: unknown;
}
export interface OpenCodeDurableStateProjection {
hasPendingPermission: boolean;
hasLatestAssistantError: boolean;
replyPendingSinceMessageId: string | null;
}
export function normalizeOpenCodeSessionStatus(raw: unknown): OpenCodeNormalizedSessionStatus {
if (typeof raw === 'string') {
return {
type: normalizeLegacyStatusType(raw),
retryAttempt: null,
retryMessage: null,
retryNextAt: null,
rawShape: 'legacy-string',
raw,
};
}
const record = asRecord(raw);
const statusType = asString(record?.type);
if (
statusType === 'idle' ||
statusType === 'busy' ||
statusType === 'retry' ||
statusType === 'error'
) {
return {
type: statusType,
retryAttempt: asNumber(record?.attempt),
retryMessage: asString(record?.message),
retryNextAt: asNumber(record?.next),
rawShape: 'v1.14',
raw,
};
}
return {
type: 'unknown',
retryAttempt: null,
retryMessage: null,
retryNextAt: null,
rawShape: 'unknown',
raw,
};
}
export function mapOpenCodeStatusToDurableState(
status: OpenCodeNormalizedSessionStatus | null,
projection: OpenCodeDurableStateProjection
): OpenCodeDurableSessionState {
if (projection.hasPendingPermission) {
return 'blocked';
}
if (projection.hasLatestAssistantError || status?.type === 'error') {
return 'error';
}
if (status?.type === 'retry') {
return 'retrying';
}
if (status?.type === 'busy') {
return 'running';
}
if (projection.replyPendingSinceMessageId) {
return 'reply_pending';
}
if (status?.type === 'idle') {
return 'idle';
}
return 'unknown';
}
export function unwrapOpenCodeEventEnvelope(raw: unknown): OpenCodeSseEventEnvelope | null {
const record = asRecord(raw);
if (!record) {
return null;
}
const directType = asString(record.type);
if (directType) {
return {
type: directType,
properties: asRecord(record.properties) ?? {},
scope: 'instance',
directory: null,
raw,
};
}
const payload = asRecord(record.payload);
const payloadType = asString(payload?.type);
if (!payloadType) {
return null;
}
return {
type: payloadType,
properties: asRecord(payload?.properties) ?? {},
scope: 'global',
directory: asString(record.directory),
raw,
};
}
export function normalizeOpenCodeEvent(raw: unknown): OpenCodeNormalizedEvent | null {
const event = unwrapOpenCodeEventEnvelope(raw);
if (!event) {
return null;
}
const props = event.properties;
if (event.type === 'server.connected' || event.type === 'server.heartbeat') {
return {
kind: event.type === 'server.connected' ? 'server_connected' : 'server_heartbeat',
scope: event.scope,
directory: event.directory,
raw,
};
}
if (event.type === 'session.status') {
const sessionId = asString(props.sessionID) ?? asString(props.sessionId);
if (!sessionId) {
return unknownEvent(event);
}
return {
kind: 'session_status',
sessionId,
status: normalizeOpenCodeSessionStatus(props.status),
scope: event.scope,
directory: event.directory,
raw,
};
}
if (event.type === 'session.idle') {
const sessionId = asString(props.sessionID) ?? asString(props.sessionId);
if (!sessionId) {
return unknownEvent(event);
}
return {
kind: 'session_status',
sessionId,
status: normalizeOpenCodeSessionStatus({ type: 'idle' }),
scope: event.scope,
directory: event.directory,
raw,
};
}
if (event.type === 'session.error') {
const error = asRecord(props.error);
return {
kind: 'session_error',
sessionId: asString(props.sessionID) ?? asString(props.sessionId),
errorName: asString(error?.name) ?? asString(props.name),
errorMessage: asString(error?.message) ?? asString(props.message),
scope: event.scope,
directory: event.directory,
raw,
};
}
if (event.type === 'message.updated') {
const info = asRecord(props.info) ?? {};
const sessionId =
asString(props.sessionID) ?? asString(props.sessionId) ?? asString(info.sessionID);
if (!sessionId) {
return unknownEvent(event);
}
return {
kind: 'message_updated',
sessionId,
messageId: asString(info.id) ?? asString(info.messageID),
role: normalizeMessageRole(asString(info.role)),
info,
scope: event.scope,
directory: event.directory,
raw,
};
}
if (event.type === 'message.part.updated') {
const part = asRecord(props.part) ?? {};
const sessionId =
asString(props.sessionID) ?? asString(props.sessionId) ?? asString(part.sessionID);
if (!sessionId) {
return unknownEvent(event);
}
return {
kind: 'message_part_updated',
sessionId,
messageId: asString(part.messageID) ?? asString(part.messageId),
partId: asString(part.id) ?? asString(part.partID) ?? asString(part.partId),
partType: asString(part.type),
textSnapshot: asStringAllowEmpty(part.text),
part,
scope: event.scope,
directory: event.directory,
raw,
};
}
if (event.type === 'message.part.delta') {
const sessionId = asString(props.sessionID) ?? asString(props.sessionId);
const messageId = asString(props.messageID) ?? asString(props.messageId);
const partId = asString(props.partID) ?? asString(props.partId);
const field = asString(props.field);
const delta = asStringAllowEmpty(props.delta);
if (!sessionId || !messageId || !partId || !field || delta === null) {
return unknownEvent(event);
}
return {
kind: 'message_part_delta',
sessionId,
messageId,
partId,
field,
delta,
scope: event.scope,
directory: event.directory,
raw,
};
}
if (event.type === 'message.part.removed') {
const sessionId = asString(props.sessionID) ?? asString(props.sessionId);
const messageId = asString(props.messageID) ?? asString(props.messageId);
const partId = asString(props.partID) ?? asString(props.partId);
if (!sessionId || !messageId || !partId) {
return unknownEvent(event);
}
return {
kind: 'message_part_removed',
sessionId,
messageId,
partId,
scope: event.scope,
directory: event.directory,
raw,
};
}
if (event.type === 'permission.asked' || event.type === 'permission.replied') {
return {
kind: event.type === 'permission.asked' ? 'permission_asked' : 'permission_replied',
sessionId: asString(props.sessionID) ?? asString(props.sessionId),
requestId: asString(props.id) ?? asString(props.requestID) ?? asString(props.requestId),
scope: event.scope,
directory: event.directory,
raw,
};
}
return unknownEvent(event);
}
function normalizeLegacyStatusType(raw: string): OpenCodeNormalizedStatusType {
if (raw === 'active') {
return 'busy';
}
if (raw === 'idle' || raw === 'busy' || raw === 'retry' || raw === 'error') {
return raw;
}
return 'unknown';
}
function normalizeMessageRole(role: string | null): 'assistant' | 'user' | 'system' | 'unknown' {
if (role === 'assistant' || role === 'user' || role === 'system') {
return role;
}
return 'unknown';
}
function unknownEvent(event: OpenCodeSseEventEnvelope): OpenCodeNormalizedEvent {
return {
kind: 'unknown',
type: event.type,
scope: event.scope,
directory: event.directory,
raw: event.raw,
};
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function asStringAllowEmpty(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}

View file

@ -0,0 +1,421 @@
export const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [
'runtime_bootstrap_checkin',
'runtime_deliver_message',
'runtime_task_event',
'runtime_heartbeat',
] as const;
export type RequiredAgentTeamsRuntimeTool = (typeof REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS)[number];
export interface OpenCodeToolListItem {
id: string;
description?: string;
parameters?: unknown;
}
export interface OpenCodeInfrastructureToolClient {
listExperimentalToolIds(): Promise<string[]>;
listExperimentalTools(input: {
providerId: string;
modelId: string;
}): Promise<OpenCodeToolListItem[]>;
}
export type OpenCodeMcpToolProofRoute = '/experimental/tool/ids' | '/experimental/tool' | null;
export interface OpenCodeMcpToolProof {
ok: boolean;
route: OpenCodeMcpToolProofRoute;
canonicalServerName: string;
canonicalExpectedIds: Record<string, string>;
observedTools: string[];
missingTools: string[];
matchedByRequiredTool: Record<string, string | null>;
aliasMatchedByRequiredTool: Record<string, string | null>;
diagnostics: string[];
}
export interface AppMcpRuntimeToolContract {
name: RequiredAgentTeamsRuntimeTool;
requiredInputFields: string[];
idempotencyField: string | null;
runScoped: boolean;
handlerKind: 'bootstrap' | 'delivery' | 'task_event' | 'heartbeat';
}
export interface AppMcpToolDefinition {
name: string;
inputSchema: unknown;
}
export interface AppMcpRuntimeToolPreflightResult {
ok: boolean;
observedToolNames: string[];
diagnostics: string[];
}
export interface RuntimeDeliverMessageSchemaDiagnostic {
severity: 'error';
message: string;
missingFields?: string[];
}
export const APP_MCP_RUNTIME_TOOL_CONTRACTS: AppMcpRuntimeToolContract[] = [
{
name: 'runtime_bootstrap_checkin',
requiredInputFields: ['runId', 'teamName', 'memberName', 'runtimeSessionId'],
idempotencyField: null,
runScoped: true,
handlerKind: 'bootstrap',
},
{
name: 'runtime_deliver_message',
requiredInputFields: [
'idempotencyKey',
'runId',
'teamName',
'fromMemberName',
'runtimeSessionId',
'to',
'text',
],
idempotencyField: 'idempotencyKey',
runScoped: true,
handlerKind: 'delivery',
},
{
name: 'runtime_task_event',
requiredInputFields: ['idempotencyKey', 'runId', 'teamName', 'memberName', 'taskId', 'event'],
idempotencyField: 'idempotencyKey',
runScoped: true,
handlerKind: 'task_event',
},
{
name: 'runtime_heartbeat',
requiredInputFields: ['runId', 'teamName', 'memberName', 'runtimeSessionId'],
idempotencyField: null,
runScoped: true,
handlerKind: 'heartbeat',
},
];
export class OpenCodeMcpToolAvailabilityProbe {
constructor(private readonly client: OpenCodeInfrastructureToolClient) {}
async proveRequiredTools(input: {
serverName: string;
requiredTools: string[];
providerId: string;
modelId: string;
}): Promise<OpenCodeMcpToolProof> {
const idsProof = await this.tryToolIdsProof(input);
if (idsProof.ok) {
return idsProof;
}
const definitionsProof = await this.tryToolDefinitionsProof(input);
if (definitionsProof.ok) {
return definitionsProof;
}
return mergeFailedToolProofs({
serverName: input.serverName,
requiredTools: input.requiredTools,
idsProof,
definitionsProof,
});
}
private async tryToolIdsProof(input: {
serverName: string;
requiredTools: string[];
}): Promise<OpenCodeMcpToolProof> {
try {
const observedTools = await this.client.listExperimentalToolIds();
return matchRequiredOpenCodeTools({
route: '/experimental/tool/ids',
serverName: input.serverName,
requiredTools: input.requiredTools,
observedTools,
});
} catch (error) {
return failedToolProof({
route: '/experimental/tool/ids',
serverName: input.serverName,
requiredTools: input.requiredTools,
diagnostics: [`OpenCode /experimental/tool/ids unavailable - ${stringifyError(error)}`],
});
}
}
private async tryToolDefinitionsProof(input: {
serverName: string;
requiredTools: string[];
providerId: string;
modelId: string;
}): Promise<OpenCodeMcpToolProof> {
try {
const tools = await this.client.listExperimentalTools({
providerId: input.providerId,
modelId: input.modelId,
});
return matchRequiredOpenCodeTools({
route: '/experimental/tool',
serverName: input.serverName,
requiredTools: input.requiredTools,
observedTools: tools.map((tool) => tool.id),
});
} catch (error) {
return failedToolProof({
route: '/experimental/tool',
serverName: input.serverName,
requiredTools: input.requiredTools,
diagnostics: [`OpenCode /experimental/tool unavailable - ${stringifyError(error)}`],
});
}
}
}
export function sanitizeOpenCodeMcpToolPart(value: string): string {
const sanitized = value
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_');
return sanitized.length > 0 ? sanitized : 'unknown';
}
export function buildOpenCodeCanonicalMcpToolId(serverName: string, toolName: string): string {
return `${sanitizeOpenCodeMcpToolPart(serverName)}_${sanitizeOpenCodeMcpToolPart(toolName)}`;
}
export function buildOpenCodeToolIdCandidates(serverName: string, toolName: string): string[] {
const dashServerName = serverName.trim();
const underscoreServerName = sanitizeOpenCodeMcpToolPart(serverName);
const canonical = buildOpenCodeCanonicalMcpToolId(serverName, toolName);
return unique([
canonical,
toolName,
`${dashServerName}:${toolName}`,
`${underscoreServerName}:${toolName}`,
`${dashServerName}_${toolName}`,
`${underscoreServerName}_${toolName}`,
`mcp__${dashServerName}__${toolName}`,
`mcp__${underscoreServerName}__${toolName}`,
]);
}
export function matchRequiredOpenCodeTools(input: {
route: Exclude<OpenCodeMcpToolProofRoute, null>;
serverName: string;
requiredTools: string[];
observedTools: string[];
}): OpenCodeMcpToolProof {
const observed = new Set(input.observedTools);
const matchedByRequiredTool: Record<string, string | null> = {};
const aliasMatchedByRequiredTool: Record<string, string | null> = {};
const missingTools: string[] = [];
const diagnostics: string[] = [];
const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools);
for (const requiredTool of input.requiredTools) {
const canonical = canonicalExpectedIds[requiredTool];
const alias = buildOpenCodeToolIdCandidates(input.serverName, requiredTool).find(
(candidate) => candidate !== canonical && observed.has(candidate)
);
matchedByRequiredTool[requiredTool] = observed.has(canonical) ? canonical : null;
aliasMatchedByRequiredTool[requiredTool] = alias ?? null;
if (!observed.has(canonical)) {
missingTools.push(requiredTool);
diagnostics.push(
alias
? `OpenCode observed alias ${alias} but missing canonical app MCP tool id ${canonical}`
: `OpenCode missing canonical app MCP tool id ${canonical}`
);
}
}
return {
ok: missingTools.length === 0,
route: input.route,
canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName),
canonicalExpectedIds,
observedTools: unique(input.observedTools).sort(),
missingTools,
matchedByRequiredTool,
aliasMatchedByRequiredTool,
diagnostics,
};
}
export function verifyAppMcpRuntimeToolContracts(
tools: AppMcpToolDefinition[]
): AppMcpRuntimeToolPreflightResult {
const byName = new Map(tools.map((tool) => [tool.name, tool]));
const diagnostics: string[] = [];
for (const contract of APP_MCP_RUNTIME_TOOL_CONTRACTS) {
const tool = byName.get(contract.name);
if (!tool) {
diagnostics.push(`App MCP tool missing: ${contract.name}`);
continue;
}
const schema = asRecord(tool.inputSchema);
const properties = asRecord(schema?.properties);
const required = asStringArray(schema?.required);
for (const field of contract.requiredInputFields) {
if (!properties?.[field] || !required.includes(field)) {
diagnostics.push(`App MCP tool ${contract.name} missing required field ${field}`);
}
}
if (contract.idempotencyField && !required.includes(contract.idempotencyField)) {
diagnostics.push(
`App MCP tool ${contract.name} idempotency field ${contract.idempotencyField} is not required`
);
}
}
return {
ok: diagnostics.length === 0,
observedToolNames: tools.map((tool) => tool.name).sort(),
diagnostics,
};
}
export function assertRuntimeDeliverMessageSchema(
tools: OpenCodeToolListItem[],
serverName = 'agent-teams'
): RuntimeDeliverMessageSchemaDiagnostic[] {
const deliverToolIds = new Set(
buildOpenCodeToolIdCandidates(serverName, 'runtime_deliver_message')
);
const deliver = tools.find((tool) => deliverToolIds.has(tool.id));
if (!deliver) {
return [{ severity: 'error', message: 'runtime_deliver_message tool is absent' }];
}
const schema = asRecord(deliver.parameters);
const properties = asRecord(schema?.properties);
const required = asStringArray(schema?.required);
const requiredFields = [
'idempotencyKey',
'runId',
'teamName',
'fromMemberName',
'runtimeSessionId',
'to',
'text',
];
const missingFields = requiredFields.filter(
(field) => !properties?.[field] || !required.includes(field)
);
return missingFields.length === 0
? []
: [
{
severity: 'error',
message: `runtime_deliver_message schema missing required fields: ${missingFields.join(', ')}`,
missingFields,
},
];
}
function mergeFailedToolProofs(input: {
serverName: string;
requiredTools: string[];
idsProof: OpenCodeMcpToolProof;
definitionsProof: OpenCodeMcpToolProof;
}): OpenCodeMcpToolProof {
const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools);
const matchedByRequiredTool: Record<string, string | null> = {};
const aliasMatchedByRequiredTool: Record<string, string | null> = {};
for (const tool of input.requiredTools) {
matchedByRequiredTool[tool] =
input.idsProof.matchedByRequiredTool[tool] ??
input.definitionsProof.matchedByRequiredTool[tool] ??
null;
aliasMatchedByRequiredTool[tool] =
input.idsProof.aliasMatchedByRequiredTool[tool] ??
input.definitionsProof.aliasMatchedByRequiredTool[tool] ??
null;
}
return {
ok: false,
route: input.definitionsProof.route ?? input.idsProof.route,
canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName),
canonicalExpectedIds,
observedTools: unique([
...input.idsProof.observedTools,
...input.definitionsProof.observedTools,
]).sort(),
missingTools: unique([
...input.idsProof.missingTools,
...input.definitionsProof.missingTools,
]).sort(),
matchedByRequiredTool,
aliasMatchedByRequiredTool,
diagnostics: [
...input.idsProof.diagnostics,
...input.definitionsProof.diagnostics,
'OpenCode app-owned MCP server is connected but required runtime tools were not proven available',
],
};
}
function failedToolProof(input: {
route: Exclude<OpenCodeMcpToolProofRoute, null>;
serverName: string;
requiredTools: string[];
diagnostics: string[];
}): OpenCodeMcpToolProof {
const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools);
return {
ok: false,
route: input.route,
canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName),
canonicalExpectedIds,
observedTools: [],
missingTools: [...input.requiredTools],
matchedByRequiredTool: Object.fromEntries(input.requiredTools.map((tool) => [tool, null])),
aliasMatchedByRequiredTool: Object.fromEntries(input.requiredTools.map((tool) => [tool, null])),
diagnostics: input.diagnostics,
};
}
function buildCanonicalExpectedIds(
serverName: string,
requiredTools: string[]
): Record<string, string> {
return Object.fromEntries(
requiredTools.map((tool) => [tool, buildOpenCodeCanonicalMcpToolId(serverName, tool)])
);
}
function unique<T>(items: T[]): T[] {
return [...new Set(items)];
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === 'string');
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,936 @@
import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore';
export const RUNTIME_PERMISSION_REQUEST_SCHEMA_VERSION = 1;
export type OpenCodePermissionDecision = 'once' | 'always' | 'reject';
export type OpenCodeRawPermissionRequest = {
id?: unknown;
requestID?: unknown;
sessionID?: unknown;
permission?: unknown;
patterns?: unknown;
metadata?: unknown;
always?: unknown;
tool?: unknown;
title?: unknown;
kind?: unknown;
};
export interface OpenCodeNormalizedPermissionRequest {
requestId: string;
sessionId: string;
permission: string;
patterns: string[];
alwaysPatterns: string[];
toolName: string;
toolCallId: string | null;
messageId: string | null;
title: string;
description: string | null;
metadata: Record<string, unknown>;
rawShape: 'v1.14' | 'legacy' | 'mixed';
raw: OpenCodeRawPermissionRequest;
}
export type RuntimePermissionState =
| 'pending'
| 'answering'
| 'answered'
| 'expired'
| 'stale_run'
| 'provider_missing'
| 'failed_retryable'
| 'failed_terminal';
export type RuntimePermissionAnswerOrigin = 'user_click' | 'provider_side_effect_projection';
export interface RuntimePermissionRequestRecord {
appRequestId: string;
providerRequestId: string;
runId: string;
teamName: string;
memberName: string;
providerId: 'opencode';
runtimeSessionId: string;
permission: string;
patterns: string[];
alwaysPatterns: string[];
toolName: string;
title: string;
description: string | null;
state: RuntimePermissionState;
rawShape: OpenCodeNormalizedPermissionRequest['rawShape'];
requestedAt: string;
updatedAt: string;
expiresAt: string;
answeredAt: string | null;
decision: OpenCodePermissionDecision | null;
answerOrigin: RuntimePermissionAnswerOrigin | null;
lastError: string | null;
}
export type OpenCodePermissionReplySideEffect =
| {
kind: 'answered_clicked_request';
appRequestId: string;
providerRequestId: string;
decision: OpenCodePermissionDecision;
}
| {
kind: 'reject_cancelled_same_session';
appRequestId: string;
providerRequestId: string;
decision: 'reject';
}
| {
kind: 'always_auto_allowed_same_session';
appRequestId: string;
providerRequestId: string;
decision: 'always';
matchedPatterns: string[];
};
export interface RuntimePermissionAnswerProjectionResult {
affectedAppRequestIds: string[];
sideEffects: OpenCodePermissionReplySideEffect[];
}
export interface RuntimePermissionDiagnosticEvent {
type:
| 'opencode_permission_stale_answer_rejected'
| 'opencode_permission_unmatched_session'
| 'opencode_permission_requests_expired'
| 'opencode_permission_answer_failed';
providerId: 'opencode';
teamName: string;
runId: string;
severity: 'info' | 'warning' | 'error';
message: string;
data?: Record<string, unknown>;
createdAt: string;
}
export interface RuntimePermissionDiagnosticsSink {
append(event: RuntimePermissionDiagnosticEvent): Promise<void>;
}
export interface RuntimePermissionLaunchStateStore {
read(teamName: string): Promise<{ runId: string | null } | null>;
updateMember(
teamName: string,
memberName: string,
updater: (member: RuntimePermissionLaunchMemberState) => RuntimePermissionLaunchMemberState
): Promise<void>;
}
export interface RuntimePermissionLaunchMemberState {
launchState?: string;
bootstrapConfirmed?: boolean;
pendingPermissionRequestIds?: string[];
lastRuntimeEventAt?: string;
}
export interface OpenCodePermissionClientPort {
listPendingPermissions(): Promise<OpenCodeNormalizedPermissionRequest[]>;
answerPermission(input: {
requestId: string;
sessionId: string;
decision: OpenCodePermissionDecision;
message?: string;
}): Promise<void>;
}
export interface OpenCodeSessionPermissionRef {
runId: string;
memberName: string;
runtimeSessionId: string;
}
export interface OpenCodePermissionAnswerResult {
ok: boolean;
requestId: string;
diagnostics: string[];
}
export class RuntimePermissionRequestStore {
constructor(private readonly store: VersionedJsonStore<RuntimePermissionRequestRecord[]>) {}
async upsertPending(
input: RuntimePermissionRequestRecord
): Promise<'created' | 'updated' | 'unchanged'> {
let outcome: 'created' | 'updated' | 'unchanged' = 'created';
await this.store.updateLocked((records) => {
const index = records.findIndex((record) => record.appRequestId === input.appRequestId);
if (index < 0) {
return [...records, input];
}
const current = records[index];
if (current.state === 'answered') {
if (current.answerOrigin !== 'provider_side_effect_projection') {
outcome = 'unchanged';
return records;
}
const reopened = {
...current,
...input,
requestedAt: current.requestedAt,
answeredAt: null,
decision: null,
answerOrigin: null,
lastError: null,
};
outcome =
stablePermissionRecordJson(current) === stablePermissionRecordJson(reopened)
? 'unchanged'
: 'updated';
return records.map((record, recordIndex) => (recordIndex === index ? reopened : record));
}
const next = {
...current,
...input,
requestedAt: current.requestedAt,
answeredAt: current.answeredAt,
decision: current.decision,
answerOrigin: current.answerOrigin,
lastError: null,
};
outcome =
stablePermissionRecordJson(current) === stablePermissionRecordJson(next)
? 'unchanged'
: 'updated';
return records.map((record, recordIndex) => (recordIndex === index ? next : record));
});
return outcome;
}
async beginAnswer(input: {
appRequestId: string;
runId: string;
now: string;
}): Promise<
| { state: 'locked'; record: RuntimePermissionRequestRecord }
| { state: 'missing' }
| { state: 'stale_run'; record: RuntimePermissionRequestRecord }
| { state: 'already_answered'; record: RuntimePermissionRequestRecord }
| { state: 'already_answering'; record: RuntimePermissionRequestRecord }
> {
let result:
| { state: 'locked'; record: RuntimePermissionRequestRecord }
| { state: 'missing' }
| { state: 'stale_run'; record: RuntimePermissionRequestRecord }
| { state: 'already_answered'; record: RuntimePermissionRequestRecord }
| { state: 'already_answering'; record: RuntimePermissionRequestRecord }
| null = null;
await this.store.updateLocked((records) => {
const existing = records.find((record) => record.appRequestId === input.appRequestId);
if (!existing) {
result = { state: 'missing' };
return records;
}
if (existing.runId !== input.runId) {
result = { state: 'stale_run', record: existing };
return records;
}
if (existing.state === 'answered') {
result = { state: 'already_answered', record: existing };
return records;
}
if (existing.state === 'answering') {
result = { state: 'already_answering', record: existing };
return records;
}
const locked = {
...existing,
state: 'answering' as const,
updatedAt: input.now,
lastError: null,
};
result = { state: 'locked', record: locked };
return records.map((record) =>
record.appRequestId === input.appRequestId ? locked : record
);
});
if (!result) {
throw new Error('Runtime permission begin answer failed');
}
return result;
}
async markAnsweredWithSideEffects(input: {
appRequestId: string;
decision: OpenCodePermissionDecision;
answeredAt: string;
}): Promise<RuntimePermissionAnswerProjectionResult> {
let result: RuntimePermissionAnswerProjectionResult | null = null;
await this.store.updateLocked((records) => {
const clicked = records.find((record) => record.appRequestId === input.appRequestId);
if (!clicked) {
throw new Error(`Runtime permission request not found: ${input.appRequestId}`);
}
const affectedAppRequestIds = new Set<string>([input.appRequestId]);
const sideEffects: OpenCodePermissionReplySideEffect[] = [
{
kind: 'answered_clicked_request',
appRequestId: clicked.appRequestId,
providerRequestId: clicked.providerRequestId,
decision: input.decision,
},
];
const nextRecords = records.map((record) => {
if (record.appRequestId === input.appRequestId) {
return answerPermissionRecord({
record,
decision: input.decision,
answeredAt: input.answeredAt,
answerOrigin: 'user_click',
});
}
if (!isProjectableProviderSideEffectPeer(clicked, record)) {
return record;
}
if (input.decision === 'reject') {
affectedAppRequestIds.add(record.appRequestId);
sideEffects.push({
kind: 'reject_cancelled_same_session',
appRequestId: record.appRequestId,
providerRequestId: record.providerRequestId,
decision: 'reject',
});
return answerPermissionRecord({
record,
decision: 'reject',
answeredAt: input.answeredAt,
answerOrigin: 'provider_side_effect_projection',
});
}
if (input.decision === 'always') {
const matchedPatterns = findAlwaysProjectionMatches(clicked, record);
if (matchedPatterns.length === 0) {
return record;
}
affectedAppRequestIds.add(record.appRequestId);
sideEffects.push({
kind: 'always_auto_allowed_same_session',
appRequestId: record.appRequestId,
providerRequestId: record.providerRequestId,
decision: 'always',
matchedPatterns,
});
return answerPermissionRecord({
record,
decision: 'always',
answeredAt: input.answeredAt,
answerOrigin: 'provider_side_effect_projection',
});
}
return record;
});
result = {
affectedAppRequestIds: [...affectedAppRequestIds],
sideEffects,
};
return nextRecords;
});
if (!result) {
throw new Error('Runtime permission answer projection failed');
}
return result;
}
async markFailed(input: {
appRequestId: string;
state: 'failed_retryable' | 'failed_terminal' | 'provider_missing';
error: string;
updatedAt: string;
}): Promise<void> {
await this.updateExisting(input.appRequestId, (record) => ({
...record,
state: input.state,
updatedAt: input.updatedAt,
lastError: input.error,
}));
}
async expireMissingProviderRequests(input: {
runId: string;
teamName: string;
visibleProviderRequestIds: Set<string>;
now: string;
}): Promise<string[]> {
const expired: string[] = [];
await this.store.updateLocked((records) =>
records.map((record) => {
if (
record.runId !== input.runId ||
record.teamName !== input.teamName ||
record.state !== 'pending' ||
input.visibleProviderRequestIds.has(record.providerRequestId)
) {
return record;
}
expired.push(record.appRequestId);
return {
...record,
state: 'provider_missing' as const,
updatedAt: input.now,
lastError: 'Provider no longer lists this permission request',
};
})
);
return expired;
}
async listPendingForTeam(teamName: string): Promise<RuntimePermissionRequestRecord[]> {
const records = await this.readRequired();
return records.filter((record) => record.teamName === teamName && record.state === 'pending');
}
async get(appRequestId: string): Promise<RuntimePermissionRequestRecord | null> {
const records = await this.readRequired();
return records.find((record) => record.appRequestId === appRequestId) ?? null;
}
async list(): Promise<RuntimePermissionRequestRecord[]> {
return this.readRequired();
}
private async updateExisting(
appRequestId: string,
updater: (record: RuntimePermissionRequestRecord) => RuntimePermissionRequestRecord
): Promise<void> {
let found = false;
await this.store.updateLocked((records) =>
records.map((record) => {
if (record.appRequestId !== appRequestId) {
return record;
}
found = true;
return updater(record);
})
);
if (!found) {
throw new Error(`Runtime permission request not found: ${appRequestId}`);
}
}
private async readRequired(): Promise<RuntimePermissionRequestRecord[]> {
const result = await this.store.read();
if (!result.ok) {
throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath);
}
return result.data;
}
}
export class RuntimePermissionAnswerService {
constructor(
private readonly store: RuntimePermissionRequestStore,
private readonly launchStateStore: RuntimePermissionLaunchStateStore,
private readonly openCodeClient: OpenCodePermissionClientPort,
private readonly diagnostics: RuntimePermissionDiagnosticsSink,
private readonly clock: () => Date = () => new Date()
) {}
async answer(input: {
appRequestId: string;
runId: string;
decision: OpenCodePermissionDecision;
message?: string;
}): Promise<OpenCodePermissionAnswerResult> {
const now = this.clock().toISOString();
const begin = await this.store.beginAnswer({
appRequestId: input.appRequestId,
runId: input.runId,
now,
});
if (begin.state === 'missing') {
return {
ok: false,
requestId: input.appRequestId,
diagnostics: ['Permission request not found'],
};
}
if (begin.state === 'stale_run') {
await this.diagnostics.append({
type: 'opencode_permission_stale_answer_rejected',
providerId: 'opencode',
teamName: begin.record.teamName,
runId: input.runId,
severity: 'warning',
message: 'OpenCode permission answer rejected because request belongs to another run',
data: { appRequestId: input.appRequestId, requestRunId: begin.record.runId },
createdAt: now,
});
return { ok: false, requestId: input.appRequestId, diagnostics: ['Stale runId rejected'] };
}
if (begin.state === 'already_answered') {
return {
ok: true,
requestId: input.appRequestId,
diagnostics: ['Permission already answered'],
};
}
if (begin.state === 'already_answering') {
return {
ok: false,
requestId: input.appRequestId,
diagnostics: ['Permission answer already in progress'],
};
}
const record = begin.record;
const launchState = await this.launchStateStore.read(record.teamName);
if (launchState?.runId !== record.runId) {
await this.store.markFailed({
appRequestId: record.appRequestId,
state: 'failed_terminal',
error: 'Launch state moved to another run before permission answer',
updatedAt: now,
});
return {
ok: false,
requestId: record.appRequestId,
diagnostics: ['Launch state moved to another run'],
};
}
try {
await this.openCodeClient.answerPermission({
requestId: record.providerRequestId,
sessionId: record.runtimeSessionId,
decision: input.decision,
message: input.message,
});
const answeredAt = this.clock().toISOString();
await this.store.markAnsweredWithSideEffects({
appRequestId: record.appRequestId,
decision: input.decision,
answeredAt,
});
const remainingMemberPendingIds = (await this.store.listPendingForTeam(record.teamName))
.filter(
(pendingRecord) =>
pendingRecord.runId === record.runId && pendingRecord.memberName === record.memberName
)
.map((pendingRecord) => pendingRecord.appRequestId);
await this.launchStateStore.updateMember(record.teamName, record.memberName, (member) => ({
...member,
pendingPermissionRequestIds: remainingMemberPendingIds,
lastRuntimeEventAt: answeredAt,
}));
return { ok: true, requestId: record.appRequestId, diagnostics: [] };
} catch (error) {
await this.store.markFailed({
appRequestId: record.appRequestId,
state: 'failed_retryable',
error: stringifyError(error),
updatedAt: this.clock().toISOString(),
});
await this.diagnostics.append({
type: 'opencode_permission_answer_failed',
providerId: 'opencode',
teamName: record.teamName,
runId: record.runId,
severity: 'warning',
message: 'OpenCode permission answer failed and remains retryable',
data: { appRequestId: record.appRequestId, error: stringifyError(error) },
createdAt: this.clock().toISOString(),
});
throw error;
}
}
}
export class RuntimePermissionReconciler {
constructor(
private readonly client: OpenCodePermissionClientPort,
private readonly store: RuntimePermissionRequestStore,
private readonly launchStateStore: RuntimePermissionLaunchStateStore,
private readonly diagnostics: RuntimePermissionDiagnosticsSink,
private readonly clock: () => Date = () => new Date()
) {}
async reconcile(input: {
runId: string;
teamName: string;
sessionsByOpenCodeId: Map<string, OpenCodeSessionPermissionRef>;
}): Promise<void> {
const now = this.clock().toISOString();
const pending = await this.client.listPendingPermissions();
const visibleProviderRequestIds = new Set<string>();
const pendingByMember = new Map<string, string[]>();
for (const permission of pending) {
visibleProviderRequestIds.add(permission.requestId);
const session = input.sessionsByOpenCodeId.get(permission.sessionId);
if (!session || session.runId !== input.runId) {
await this.diagnostics.append({
type: 'opencode_permission_unmatched_session',
providerId: 'opencode',
teamName: input.teamName,
runId: input.runId,
severity: 'warning',
message: 'OpenCode permission request did not match a current runtime session',
data: { providerRequestId: permission.requestId, sessionId: permission.sessionId },
createdAt: now,
});
continue;
}
const appRequestId = createOpenCodePermissionAppRequestId(input.runId, permission.requestId);
await this.store.upsertPending({
appRequestId,
providerRequestId: permission.requestId,
runId: input.runId,
teamName: input.teamName,
memberName: session.memberName,
providerId: 'opencode',
runtimeSessionId: permission.sessionId,
permission: permission.permission,
patterns: permission.patterns,
alwaysPatterns: permission.alwaysPatterns,
toolName: permission.toolName,
title: permission.title,
description: permission.description,
state: 'pending',
rawShape: permission.rawShape,
requestedAt: now,
updatedAt: now,
expiresAt: new Date(Date.parse(now) + 15 * 60_000).toISOString(),
answeredAt: null,
decision: null,
answerOrigin: null,
lastError: null,
});
pendingByMember.set(session.memberName, [
...(pendingByMember.get(session.memberName) ?? []),
appRequestId,
]);
}
const expired = await this.store.expireMissingProviderRequests({
runId: input.runId,
teamName: input.teamName,
visibleProviderRequestIds,
now,
});
if (expired.length > 0) {
await this.diagnostics.append({
type: 'opencode_permission_requests_expired',
providerId: 'opencode',
teamName: input.teamName,
runId: input.runId,
severity: 'info',
message: 'OpenCode permission requests disappeared from provider and were expired locally',
data: { expiredCount: expired.length },
createdAt: now,
});
}
for (const [memberName, requestIds] of pendingByMember) {
await this.launchStateStore.updateMember(input.teamName, memberName, (member) => ({
...member,
launchState:
member.launchState === 'confirmed_alive'
? member.launchState
: 'runtime_pending_permission',
pendingPermissionRequestIds: [...new Set(requestIds)],
lastRuntimeEventAt: now,
}));
}
}
}
export function createRuntimePermissionRequestStore(options: {
filePath: string;
clock?: () => Date;
}): RuntimePermissionRequestStore {
const clock = options.clock ?? (() => new Date());
return new RuntimePermissionRequestStore(
new VersionedJsonStore<RuntimePermissionRequestRecord[]>({
filePath: options.filePath,
schemaVersion: RUNTIME_PERMISSION_REQUEST_SCHEMA_VERSION,
defaultData: () => [],
validate: validateRuntimePermissionRequestRecords,
clock,
})
);
}
export function normalizeOpenCodePermissionRequest(
raw: OpenCodeRawPermissionRequest
): OpenCodeNormalizedPermissionRequest | null {
const requestId = asString(raw.id) ?? asString(raw.requestID);
const sessionId = asString(raw.sessionID);
if (!requestId || !sessionId) {
return null;
}
const toolObject = isRecord(raw.tool) ? raw.tool : null;
const legacyToolName = asString(raw.tool);
const permission = asString(raw.permission) ?? asString(raw.kind) ?? legacyToolName ?? 'unknown';
const patterns = asStringArray(raw.patterns);
const alwaysPatterns = asStringArray(raw.always);
const metadata = asRecord(raw.metadata);
const toolName =
legacyToolName ?? asString(toolObject?.name) ?? asString(metadata.toolName) ?? permission;
const messageId = asString(toolObject?.messageID) ?? asString(metadata.messageID);
const toolCallId = asString(toolObject?.callID) ?? asString(metadata.callID);
return {
requestId,
sessionId,
permission,
patterns,
alwaysPatterns,
toolName,
toolCallId,
messageId,
title: asString(raw.title) ?? buildOpenCodePermissionTitle({ permission, toolName, patterns }),
description:
asString(raw.kind) ??
buildOpenCodePermissionDescription({ patterns, alwaysPatterns, metadata }),
metadata,
rawShape: detectPermissionRawShape(raw),
raw,
};
}
export function createOpenCodePermissionAppRequestId(
runId: string,
providerRequestId: string
): string {
return `opencode:${runId}:${providerRequestId}`;
}
export function validateRuntimePermissionRequestRecords(
value: unknown
): RuntimePermissionRequestRecord[] {
if (!Array.isArray(value)) {
throw new Error('Runtime permission requests must be an array');
}
const seen = new Set<string>();
return value.map((record, index) => {
if (!isRuntimePermissionRequestRecord(record)) {
throw new Error(`Invalid runtime permission request at index ${index}`);
}
const normalized = normalizeRuntimePermissionRequestRecord(record);
if (seen.has(normalized.appRequestId)) {
throw new Error(`Duplicate runtime permission request id: ${normalized.appRequestId}`);
}
seen.add(normalized.appRequestId);
return normalized;
});
}
function detectPermissionRawShape(
raw: OpenCodeRawPermissionRequest
): OpenCodeNormalizedPermissionRequest['rawShape'] {
const hasV114Fields =
typeof raw.id === 'string' || typeof raw.permission === 'string' || Array.isArray(raw.patterns);
const hasLegacyFields =
typeof raw.requestID === 'string' ||
typeof raw.title === 'string' ||
typeof raw.kind === 'string';
if (hasV114Fields && hasLegacyFields) {
return 'mixed';
}
if (hasV114Fields) {
return 'v1.14';
}
return 'legacy';
}
function buildOpenCodePermissionTitle(input: {
permission: string;
toolName: string;
patterns: string[];
}): string {
if (input.patterns.length > 0) {
return `OpenCode wants ${input.permission} permission for ${input.patterns[0]}`;
}
if (input.toolName !== 'unknown') {
return `OpenCode wants to use ${input.toolName}`;
}
return `OpenCode permission request: ${input.permission}`;
}
function buildOpenCodePermissionDescription(input: {
patterns: string[];
alwaysPatterns: string[];
metadata: Record<string, unknown>;
}): string | null {
const parts: string[] = [];
if (input.patterns.length > 0) {
parts.push(`Patterns: ${input.patterns.join(', ')}`);
}
if (input.alwaysPatterns.length > 0) {
parts.push(`Always candidates: ${input.alwaysPatterns.join(', ')}`);
}
const reason = asString(input.metadata.reason);
if (reason) {
parts.push(`Reason: ${reason}`);
}
return parts.length > 0 ? parts.join('\n') : null;
}
function answerPermissionRecord(input: {
record: RuntimePermissionRequestRecord;
decision: OpenCodePermissionDecision;
answeredAt: string;
answerOrigin: RuntimePermissionAnswerOrigin;
}): RuntimePermissionRequestRecord {
return {
...input.record,
state: 'answered',
answeredAt: input.answeredAt,
decision: input.decision,
answerOrigin: input.answerOrigin,
updatedAt: input.answeredAt,
lastError: null,
};
}
function isProjectableProviderSideEffectPeer(
clicked: RuntimePermissionRequestRecord,
candidate: RuntimePermissionRequestRecord
): boolean {
return (
candidate.appRequestId !== clicked.appRequestId &&
candidate.providerId === 'opencode' &&
candidate.runId === clicked.runId &&
candidate.teamName === clicked.teamName &&
candidate.runtimeSessionId === clicked.runtimeSessionId &&
candidate.state === 'pending'
);
}
function findAlwaysProjectionMatches(
clicked: RuntimePermissionRequestRecord,
candidate: RuntimePermissionRequestRecord
): string[] {
const allowedPatterns = new Set([...clicked.alwaysPatterns, ...clicked.patterns]);
if (allowedPatterns.size === 0) {
return [];
}
return [...new Set(candidate.patterns.filter((pattern) => allowedPatterns.has(pattern)))];
}
function normalizeRuntimePermissionRequestRecord(
record: RuntimePermissionRequestRecord
): RuntimePermissionRequestRecord {
return {
...record,
permission: isNonEmptyString(record.permission) ? record.permission : record.toolName,
patterns: isStringArray(record.patterns) ? record.patterns : [],
alwaysPatterns: isStringArray(record.alwaysPatterns) ? record.alwaysPatterns : [],
answerOrigin: isRuntimePermissionAnswerOrigin(record.answerOrigin) ? record.answerOrigin : null,
};
}
function isRuntimePermissionRequestRecord(value: unknown): value is RuntimePermissionRequestRecord {
return (
isRecord(value) &&
isNonEmptyString(value.appRequestId) &&
isNonEmptyString(value.providerRequestId) &&
isNonEmptyString(value.runId) &&
isNonEmptyString(value.teamName) &&
isNonEmptyString(value.memberName) &&
value.providerId === 'opencode' &&
isNonEmptyString(value.runtimeSessionId) &&
(value.permission === undefined || isNonEmptyString(value.permission)) &&
(value.patterns === undefined || isStringArray(value.patterns)) &&
(value.alwaysPatterns === undefined || isStringArray(value.alwaysPatterns)) &&
isNonEmptyString(value.toolName) &&
isNonEmptyString(value.title) &&
(value.description === null || typeof value.description === 'string') &&
isRuntimePermissionState(value.state) &&
(value.rawShape === 'v1.14' || value.rawShape === 'legacy' || value.rawShape === 'mixed') &&
isNonEmptyString(value.requestedAt) &&
isNonEmptyString(value.updatedAt) &&
isNonEmptyString(value.expiresAt) &&
(value.answeredAt === null || isNonEmptyString(value.answeredAt)) &&
(value.decision === null || isOpenCodePermissionDecision(value.decision)) &&
(value.answerOrigin === undefined ||
value.answerOrigin === null ||
isRuntimePermissionAnswerOrigin(value.answerOrigin)) &&
(value.lastError === null || typeof value.lastError === 'string')
);
}
function isRuntimePermissionState(value: unknown): value is RuntimePermissionState {
return (
value === 'pending' ||
value === 'answering' ||
value === 'answered' ||
value === 'expired' ||
value === 'stale_run' ||
value === 'provider_missing' ||
value === 'failed_retryable' ||
value === 'failed_terminal'
);
}
function isOpenCodePermissionDecision(value: unknown): value is OpenCodePermissionDecision {
return value === 'once' || value === 'always' || value === 'reject';
}
function isRuntimePermissionAnswerOrigin(value: unknown): value is RuntimePermissionAnswerOrigin {
return value === 'user_click' || value === 'provider_side_effect_projection';
}
function stablePermissionRecordJson(value: RuntimePermissionRequestRecord): string {
return JSON.stringify(value);
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,378 @@
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract';
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
import {
evaluateOpenCodeSupport,
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
type OpenCodeInstallMethod,
type OpenCodeProductionE2EEvidence,
type OpenCodeSupportLevel,
type OpenCodeSupportedVersionPolicy,
} from '../version/OpenCodeVersionPolicy';
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
export type OpenCodeTeamLaunchReadinessState =
| 'ready'
| 'not_installed'
| 'not_authenticated'
| 'unsupported_version'
| 'capabilities_missing'
| 'e2e_missing'
| 'runtime_store_blocked'
| 'mcp_unavailable'
| 'model_unavailable'
| 'adapter_disabled'
| 'unknown_error';
export interface OpenCodeRuntimeInventory {
detected: boolean;
binaryPath: string | null;
installMethod: OpenCodeInstallMethod;
version: string | null;
authenticated: boolean;
connectedProviders: string[];
models: string[];
diagnostics: string[];
}
export interface OpenCodeModelExecutionProbeResult {
outcome: 'available' | 'unavailable' | 'unknown';
reason: string | null;
diagnostics: string[];
}
export interface OpenCodeTeamLaunchReadiness {
state: OpenCodeTeamLaunchReadinessState;
launchAllowed: boolean;
modelId: string | null;
opencodeVersion: string | null;
installMethod: OpenCodeInstallMethod | null;
binaryPath: string | null;
hostHealthy: boolean;
appMcpConnected: boolean;
requiredToolsPresent: boolean;
permissionBridgeReady: boolean;
runtimeStoresReady: boolean;
supportLevel: OpenCodeSupportLevel | null;
missing: string[];
diagnostics: string[];
evidence: {
capabilitiesReady: boolean;
mcpToolProofRoute: OpenCodeMcpToolProof['route'];
observedMcpTools: string[];
runtimeStoreReadinessReason: RuntimeStoreReadinessCheck['reason'] | null;
};
}
export interface OpenCodeRuntimeInventoryPort {
probe(input: { projectPath: string }): Promise<OpenCodeRuntimeInventory>;
}
export interface OpenCodeApiCapabilityPort {
detect(input: {
projectPath: string;
inventory: OpenCodeRuntimeInventory;
}): Promise<OpenCodeApiCapabilities>;
}
export interface OpenCodeMcpToolProofPort {
prove(input: {
projectPath: string;
modelId: string;
inventory: OpenCodeRuntimeInventory;
capabilities: OpenCodeApiCapabilities;
}): Promise<OpenCodeMcpToolProof>;
}
export interface OpenCodeRuntimeStoreReadinessPort {
check(input: { projectPath: string }): Promise<RuntimeStoreReadinessCheck>;
}
export interface OpenCodeModelExecutionProbePort {
verify(input: {
projectPath: string;
modelId: string;
inventory: OpenCodeRuntimeInventory;
}): Promise<OpenCodeModelExecutionProbeResult>;
}
export interface OpenCodeProductionE2EEvidencePort {
read(input: {
projectPath: string;
inventory: OpenCodeRuntimeInventory;
capabilities: OpenCodeApiCapabilities;
}): Promise<OpenCodeProductionE2EEvidence | null>;
}
export interface OpenCodeTeamLaunchReadinessServiceOptions {
versionPolicy?: OpenCodeSupportedVersionPolicy;
launchMode?: OpenCodeTeamLaunchMode;
/**
* @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate.
*/
adapterEnabled?: boolean;
}
export class OpenCodeTeamLaunchReadinessService {
constructor(
private readonly inventory: OpenCodeRuntimeInventoryPort,
private readonly capabilities: OpenCodeApiCapabilityPort,
private readonly mcpTools: OpenCodeMcpToolProofPort,
private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort,
private readonly modelExecution: OpenCodeModelExecutionProbePort,
private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort,
private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {}
) {}
async check(input: {
projectPath: string;
selectedModel: string | null;
requireExecutionProbe: boolean;
launchMode?: OpenCodeTeamLaunchMode;
}): Promise<OpenCodeTeamLaunchReadiness> {
const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options);
const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
const dogfoodWarnings: string[] = [];
if (launchMode === 'disabled') {
return readiness({
state: 'adapter_disabled',
inventory: null,
modelId: input.selectedModel,
missing: ['OpenCode team launch adapter is disabled by feature gate'],
diagnostics: ['OpenCode team launch adapter is disabled by feature gate'],
});
}
try {
const inventory = await this.inventory.probe({ projectPath: input.projectPath });
if (!inventory.detected) {
return readiness({
state: 'not_installed',
inventory,
modelId: input.selectedModel,
diagnostics: appendDiagnostics(inventory.diagnostics, [
'OpenCode CLI not detected on PATH',
]),
});
}
if (!inventory.authenticated || inventory.connectedProviders.length === 0) {
return readiness({
state: 'not_authenticated',
inventory,
modelId: input.selectedModel,
diagnostics: appendDiagnostics(inventory.diagnostics, [
'No connected OpenCode providers found',
]),
});
}
const modelId = input.selectedModel ?? inventory.models[0] ?? null;
if (!modelId) {
return readiness({
state: 'model_unavailable',
inventory,
modelId: null,
diagnostics: appendDiagnostics(inventory.diagnostics, ['No OpenCode model is available']),
});
}
const capabilities = await this.capabilities.detect({
projectPath: input.projectPath,
inventory,
});
const evidence = await this.e2eEvidence.read({
projectPath: input.projectPath,
inventory,
capabilities,
});
const support = evaluateOpenCodeSupport({
version: inventory.version ?? '0.0.0',
capabilities,
evidence,
policy,
});
if (!support.supported) {
if (launchMode === 'dogfood' && support.supportLevel === 'supported_e2e_pending') {
dogfoodWarnings.push(
'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.'
);
} else {
return readiness({
state: mapSupportLevelToReadinessState(support.supportLevel),
inventory,
modelId,
capabilities,
supportLevel: support.supportLevel,
missing: support.diagnostics,
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
});
}
}
const runtimeStoreReadiness = await this.runtimeStores.check({
projectPath: input.projectPath,
});
if (!runtimeStoreReadiness.ok) {
return readiness({
state: 'runtime_store_blocked',
inventory,
modelId,
capabilities,
runtimeStoreReadiness,
supportLevel: support.supportLevel,
missing: runtimeStoreReadiness.diagnostics,
diagnostics: appendDiagnostics(inventory.diagnostics, runtimeStoreReadiness.diagnostics),
});
}
const toolProof = await this.mcpTools.prove({
projectPath: input.projectPath,
modelId,
inventory,
capabilities,
});
if (!toolProof.ok) {
return readiness({
state: 'mcp_unavailable',
inventory,
modelId,
capabilities,
toolProof,
runtimeStoreReadiness,
supportLevel: support.supportLevel,
missing: toolProof.missingTools,
diagnostics: appendDiagnostics(inventory.diagnostics, toolProof.diagnostics),
});
}
if (input.requireExecutionProbe) {
const modelProbe = await this.modelExecution.verify({
projectPath: input.projectPath,
modelId,
inventory,
});
if (modelProbe.outcome !== 'available') {
return readiness({
state: 'model_unavailable',
inventory,
modelId,
capabilities,
toolProof,
runtimeStoreReadiness,
supportLevel: support.supportLevel,
missing: [modelProbe.reason ?? 'OpenCode selected model execution is unavailable'],
diagnostics: appendDiagnostics(inventory.diagnostics, modelProbe.diagnostics),
});
}
}
return readiness({
state: 'ready',
inventory,
modelId,
capabilities,
toolProof,
runtimeStoreReadiness,
supportLevel: support.supportLevel,
launchAllowed: true,
diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings),
});
} catch (error) {
return readiness({
state: 'unknown_error',
inventory: null,
modelId: input.selectedModel,
diagnostics: [`OpenCode readiness check failed: ${stringifyError(error)}`],
});
}
}
}
function resolveReadinessLaunchMode(
requested: OpenCodeTeamLaunchMode | undefined,
options: OpenCodeTeamLaunchReadinessServiceOptions
): OpenCodeTeamLaunchMode {
if (requested) {
return requested;
}
if (options.launchMode) {
return options.launchMode;
}
if (options.adapterEnabled === true) {
return 'production';
}
return 'disabled';
}
function readiness(input: {
state: OpenCodeTeamLaunchReadinessState;
inventory: OpenCodeRuntimeInventory | null;
modelId: string | null;
capabilities?: OpenCodeApiCapabilities;
toolProof?: OpenCodeMcpToolProof;
runtimeStoreReadiness?: RuntimeStoreReadinessCheck;
supportLevel?: OpenCodeSupportLevel | null;
launchAllowed?: boolean;
missing?: string[];
diagnostics: string[];
}): OpenCodeTeamLaunchReadiness {
const toolProof = input.toolProof ?? null;
const capabilitiesReady = input.capabilities?.requiredForTeamLaunch.ready === true;
return {
state: input.state,
launchAllowed: input.launchAllowed === true,
modelId: input.modelId,
opencodeVersion: input.inventory?.version ?? null,
installMethod: input.inventory?.installMethod ?? null,
binaryPath: input.inventory?.binaryPath ?? null,
hostHealthy: input.inventory?.detected === true,
appMcpConnected: toolProof !== null,
requiredToolsPresent: toolProof?.ok === true,
permissionBridgeReady:
input.capabilities?.endpoints.permissionList === true &&
(input.capabilities.endpoints.permissionReply === true ||
input.capabilities.endpoints.permissionLegacySessionRespond === true),
runtimeStoresReady: input.runtimeStoreReadiness?.ok === true,
supportLevel: input.supportLevel ?? null,
missing: dedupe(input.missing ?? []),
diagnostics: dedupe(input.diagnostics),
evidence: {
capabilitiesReady,
mcpToolProofRoute: toolProof?.route ?? null,
observedMcpTools: toolProof?.observedTools ?? [],
runtimeStoreReadinessReason: input.runtimeStoreReadiness?.reason ?? null,
},
};
}
function mapSupportLevelToReadinessState(
supportLevel: OpenCodeSupportLevel
): OpenCodeTeamLaunchReadinessState {
switch (supportLevel) {
case 'unsupported_too_old':
case 'unsupported_prerelease':
return 'unsupported_version';
case 'supported_capabilities_pending':
return 'capabilities_missing';
case 'supported_e2e_pending':
return 'e2e_missing';
case 'production_supported':
return 'ready';
}
}
function appendDiagnostics(left: string[], right: string[]): string[] {
return dedupe([...left, ...right]);
}
function dedupe(values: string[]): string[] {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,399 @@
import { createHash } from 'crypto';
import { stableJsonStringify } from '../bridge/OpenCodeBridgeCommandContract';
import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore';
export const OPENCODE_LAUNCH_TRANSACTION_SCHEMA_VERSION = 1;
export type OpenCodeLaunchCheckpointName =
| 'run_created'
| 'host_ready'
| 'lead_session_recorded'
| 'member_session_recorded'
| 'mcp_connected'
| 'required_tools_proven'
| 'prompt_sent'
| 'bootstrap_confirmed'
| 'permission_blocked'
| 'delivery_ready'
| 'member_ready'
| 'run_ready'
| 'run_failed'
| 'run_cancelled';
export interface OpenCodeLaunchCheckpoint {
name: OpenCodeLaunchCheckpointName;
teamName: string;
runId: string;
memberName: string | null;
runtimeSessionId: string | null;
hostKey: string | null;
evidenceHash: string;
createdAt: string;
diagnostics: string[];
}
export interface OpenCodeLaunchTransaction {
teamName: string;
runId: string;
providerId: 'opencode';
startedAt: string;
updatedAt: string;
status: 'active' | 'ready' | 'failed' | 'cancelled' | 'reconciled';
checkpoints: OpenCodeLaunchCheckpoint[];
}
export interface OpenCodeRunReadyInput {
members: Array<{ name: string; launchState?: string }>;
transaction: OpenCodeLaunchTransaction;
toolProof: { ok: boolean };
deliveryReady: boolean;
}
export class OpenCodeLaunchTransactionStore {
constructor(
private readonly store: VersionedJsonStore<OpenCodeLaunchTransaction[]>,
private readonly clock: () => Date = () => new Date()
) {}
async beginRun(input: {
teamName: string;
runId: string;
startedAt?: string;
}): Promise<
| { state: 'created'; transaction: OpenCodeLaunchTransaction }
| { state: 'already_active'; transaction: OpenCodeLaunchTransaction }
> {
let result:
| { state: 'created'; transaction: OpenCodeLaunchTransaction }
| { state: 'already_active'; transaction: OpenCodeLaunchTransaction }
| null = null;
const startedAt = input.startedAt ?? this.clock().toISOString();
await this.store.updateLocked((transactions) => {
const active = transactions.find(
(transaction) => transaction.teamName === input.teamName && transaction.status === 'active'
);
if (active) {
result = { state: 'already_active', transaction: active };
return transactions;
}
const transaction: OpenCodeLaunchTransaction = {
teamName: input.teamName,
runId: input.runId,
providerId: 'opencode',
startedAt,
updatedAt: startedAt,
status: 'active',
checkpoints: [],
};
result = { state: 'created', transaction };
return [...transactions, transaction];
});
if (!result) {
throw new Error('OpenCode launch transaction begin failed');
}
return result;
}
async addCheckpoint(input: OpenCodeLaunchCheckpoint): Promise<'created' | 'unchanged'> {
let outcome: 'created' | 'unchanged' = 'created';
await this.store.updateLocked((transactions) =>
transactions.map((transaction) => {
if (transaction.teamName !== input.teamName || transaction.runId !== input.runId) {
return transaction;
}
if (transaction.status !== 'active') {
throw new Error(`OpenCode launch transaction is not active: ${input.runId}`);
}
const duplicate = transaction.checkpoints.some(
(checkpoint) =>
checkpoint.name === input.name &&
checkpoint.memberName === input.memberName &&
checkpoint.evidenceHash === input.evidenceHash
);
if (duplicate) {
outcome = 'unchanged';
return transaction;
}
return {
...transaction,
updatedAt: input.createdAt,
checkpoints: [...transaction.checkpoints, normalizeCheckpoint(input)],
};
})
);
if (!(await this.hasTransaction(input.teamName, input.runId))) {
throw new Error(`OpenCode launch transaction not found: ${input.runId}`);
}
return outcome;
}
async hasCheckpoint(input: {
teamName: string;
runId: string;
memberName: string | null;
name: OpenCodeLaunchCheckpointName;
evidenceHash?: string;
}): Promise<boolean> {
const transaction = await this.read(input.teamName, input.runId);
return (
transaction?.checkpoints.some(
(checkpoint) =>
checkpoint.name === input.name &&
checkpoint.memberName === input.memberName &&
(input.evidenceHash === undefined || checkpoint.evidenceHash === input.evidenceHash)
) ?? false
);
}
async readActive(teamName: string): Promise<OpenCodeLaunchTransaction | null> {
const transactions = await this.readRequired();
return (
transactions.find(
(transaction) => transaction.teamName === teamName && transaction.status === 'active'
) ?? null
);
}
async read(teamName: string, runId: string): Promise<OpenCodeLaunchTransaction | null> {
const transactions = await this.readRequired();
return (
transactions.find(
(transaction) => transaction.teamName === teamName && transaction.runId === runId
) ?? null
);
}
async finish(input: {
teamName: string;
runId: string;
status: 'ready' | 'failed' | 'cancelled' | 'reconciled';
updatedAt?: string;
}): Promise<'finished' | 'unchanged'> {
let found = false;
let outcome: 'finished' | 'unchanged' = 'finished';
const updatedAt = input.updatedAt ?? this.clock().toISOString();
await this.store.updateLocked((transactions) =>
transactions.map((transaction) => {
if (transaction.teamName !== input.teamName || transaction.runId !== input.runId) {
return transaction;
}
found = true;
if (transaction.status !== 'active') {
outcome = 'unchanged';
return transaction;
}
return {
...transaction,
status: input.status,
updatedAt,
};
})
);
if (!found) {
const active = await this.readActive(input.teamName);
if (active) {
throw new Error(
`OpenCode launch transaction ${input.runId} is stale; active run is ${active.runId}`
);
}
throw new Error(`OpenCode launch transaction not found: ${input.runId}`);
}
return outcome;
}
async list(): Promise<OpenCodeLaunchTransaction[]> {
return this.readRequired();
}
private async hasTransaction(teamName: string, runId: string): Promise<boolean> {
return (await this.read(teamName, runId)) !== null;
}
private async readRequired(): Promise<OpenCodeLaunchTransaction[]> {
const result = await this.store.read();
if (!result.ok) {
throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath);
}
return result.data;
}
}
export function canMarkOpenCodeRunReady(input: OpenCodeRunReadyInput): {
ok: boolean;
missing: string[];
} {
const missing: string[] = [];
for (const member of input.members) {
if (!hasMemberCheckpoint(input.transaction, member.name, 'member_session_recorded')) {
missing.push(`${member.name}:member_session_recorded`);
}
if (!hasMemberCheckpoint(input.transaction, member.name, 'required_tools_proven')) {
missing.push(`${member.name}:required_tools_proven`);
}
if (member.launchState !== 'confirmed_alive') {
missing.push(`${member.name}:bootstrap_confirmed`);
}
}
if (!input.toolProof.ok) {
missing.push('required_runtime_tools');
}
if (!input.deliveryReady) {
missing.push('runtime_delivery_service');
}
return {
ok: missing.length === 0,
missing,
};
}
export function hasMemberCheckpoint(
transaction: OpenCodeLaunchTransaction,
memberName: string,
name: OpenCodeLaunchCheckpointName
): boolean {
return transaction.checkpoints.some(
(checkpoint) => checkpoint.memberName === memberName && checkpoint.name === name
);
}
export function createOpenCodeLaunchEvidenceHash(evidence: unknown): string {
return `sha256:${createHash('sha256')
.update(stableJsonStringify(redactOpenCodeLaunchEvidence(evidence)))
.digest('hex')}`;
}
export function redactOpenCodeLaunchEvidence(evidence: unknown): unknown {
if (evidence === null || typeof evidence !== 'object') {
return evidence;
}
if (Array.isArray(evidence)) {
return evidence.map(redactOpenCodeLaunchEvidence);
}
const output: Record<string, unknown> = {};
for (const [key, value] of Object.entries(evidence)) {
if (/token|secret|password|api[_-]?key|authorization/i.test(key)) {
output[key] = '[redacted]';
} else {
output[key] = redactOpenCodeLaunchEvidence(value);
}
}
return output;
}
export function createOpenCodeLaunchTransactionStore(options: {
filePath: string;
clock?: () => Date;
}): OpenCodeLaunchTransactionStore {
const clock = options.clock ?? (() => new Date());
return new OpenCodeLaunchTransactionStore(
new VersionedJsonStore<OpenCodeLaunchTransaction[]>({
filePath: options.filePath,
schemaVersion: OPENCODE_LAUNCH_TRANSACTION_SCHEMA_VERSION,
defaultData: () => [],
validate: validateOpenCodeLaunchTransactions,
clock,
}),
clock
);
}
export function validateOpenCodeLaunchTransactions(value: unknown): OpenCodeLaunchTransaction[] {
if (!Array.isArray(value)) {
throw new Error('OpenCode launch transactions must be an array');
}
return value.map((transaction, index) => {
if (!isLaunchTransaction(transaction)) {
throw new Error(`Invalid OpenCode launch transaction at index ${index}`);
}
return transaction;
});
}
function normalizeCheckpoint(input: OpenCodeLaunchCheckpoint): OpenCodeLaunchCheckpoint {
return {
...input,
diagnostics: [...input.diagnostics],
};
}
function isLaunchTransaction(value: unknown): value is OpenCodeLaunchTransaction {
return (
isRecord(value) &&
isNonEmptyString(value.teamName) &&
isNonEmptyString(value.runId) &&
value.providerId === 'opencode' &&
isNonEmptyString(value.startedAt) &&
isNonEmptyString(value.updatedAt) &&
(value.status === 'active' ||
value.status === 'ready' ||
value.status === 'failed' ||
value.status === 'cancelled' ||
value.status === 'reconciled') &&
Array.isArray(value.checkpoints) &&
value.checkpoints.every(isLaunchCheckpoint)
);
}
function isLaunchCheckpoint(value: unknown): value is OpenCodeLaunchCheckpoint {
return (
isRecord(value) &&
isLaunchCheckpointName(value.name) &&
isNonEmptyString(value.teamName) &&
isNonEmptyString(value.runId) &&
isNullableString(value.memberName) &&
isNullableString(value.runtimeSessionId) &&
isNullableString(value.hostKey) &&
isNonEmptyString(value.evidenceHash) &&
isNonEmptyString(value.createdAt) &&
Array.isArray(value.diagnostics) &&
value.diagnostics.every((item) => typeof item === 'string')
);
}
function isLaunchCheckpointName(value: unknown): value is OpenCodeLaunchCheckpointName {
return (
value === 'run_created' ||
value === 'host_ready' ||
value === 'lead_session_recorded' ||
value === 'member_session_recorded' ||
value === 'mcp_connected' ||
value === 'required_tools_proven' ||
value === 'prompt_sent' ||
value === 'bootstrap_confirmed' ||
value === 'permission_blocked' ||
value === 'delivery_ready' ||
value === 'member_ready' ||
value === 'run_ready' ||
value === 'run_failed' ||
value === 'run_cancelled'
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}

View file

@ -0,0 +1,48 @@
import * as path from 'path';
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
import { createRuntimeStoreManifestStore } from './RuntimeStoreManifest';
export interface OpenCodeRuntimeManifestEvidenceReaderOptions {
teamsBasePath: string;
clock?: () => Date;
}
const OPENCODE_TEAM_RUNTIME_DIR = '.opencode-runtime';
const OPENCODE_RUNTIME_MANIFEST_FILE = 'manifest.json';
export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManifestReader {
private readonly teamsBasePath: string;
private readonly clock: () => Date;
constructor(options: OpenCodeRuntimeManifestEvidenceReaderOptions) {
this.teamsBasePath = options.teamsBasePath;
this.clock = options.clock ?? (() => new Date());
}
async read(teamName: string): Promise<RuntimeStoreManifestEvidence> {
const manifest = await createRuntimeStoreManifestStore({
filePath: getOpenCodeRuntimeManifestPath(this.teamsBasePath, teamName),
teamName,
clock: this.clock,
}).read();
return {
highWatermark: manifest.highWatermark,
activeRunId: manifest.activeRunId,
capabilitySnapshotId: manifest.activeCapabilitySnapshotId,
};
}
}
export function getOpenCodeTeamRuntimeDirectory(teamsBasePath: string, teamName: string): string {
return path.join(teamsBasePath, teamName, OPENCODE_TEAM_RUNTIME_DIR);
}
export function getOpenCodeRuntimeManifestPath(teamsBasePath: string, teamName: string): string {
return path.join(
getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName),
OPENCODE_RUNTIME_MANIFEST_FILE
);
}

View file

@ -0,0 +1,313 @@
import { randomUUID } from 'crypto';
import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore';
export const OPENCODE_RUNTIME_RUN_TOMBSTONE_SCHEMA_VERSION = 1;
export type RuntimeEvidenceKind =
| 'sse_event'
| 'permission_reply'
| 'delivery_call'
| 'prompt_error'
| 'bootstrap_checkin'
| 'launch_checkpoint'
| 'heartbeat'
| 'bridge_result'
| 'recovery_result';
export type RuntimeRunTombstoneReason =
| 'stop_requested'
| 'relaunch_started'
| 'run_replaced'
| 'provider_session_aborted'
| 'recovery_rejected';
export interface RuntimeRunTombstone {
tombstoneId: string;
teamName: string;
runId: string;
reason: RuntimeRunTombstoneReason;
evidenceKinds: RuntimeEvidenceKind[];
createdAt: string;
expiresAt: string | null;
diagnostic: string | null;
}
export interface RuntimeEvidenceAcceptanceInput {
teamName: string;
runId: string | null;
currentRunId: string | null;
evidenceKind: RuntimeEvidenceKind;
}
export class RuntimeStaleEvidenceError extends Error {
constructor(
message: string,
readonly reason: 'missing_run_id' | 'current_run_missing' | 'run_mismatch' | 'run_tombstoned',
readonly evidenceKind: RuntimeEvidenceKind,
readonly runId: string | null
) {
super(message);
this.name = 'RuntimeStaleEvidenceError';
}
}
export class RuntimeRunTombstoneStore {
constructor(
private readonly store: VersionedJsonStore<RuntimeRunTombstone[]>,
private readonly options: {
idFactory?: () => string;
clock?: () => Date;
} = {}
) {}
async add(input: {
teamName: string;
runId: string;
reason: RuntimeRunTombstoneReason;
evidenceKinds?: RuntimeEvidenceKind[];
ttlMs?: number;
diagnostic?: string | null;
}): Promise<RuntimeRunTombstone> {
const clock = this.options.clock ?? (() => new Date());
const now = clock();
let created: RuntimeRunTombstone | null = null;
await this.store.updateLocked((records) => {
const compacted = compactRuntimeRunTombstones(records, now);
const existing = compacted.find(
(record) =>
record.teamName === input.teamName &&
record.runId === input.runId &&
record.reason === input.reason
);
if (existing) {
created = existing;
return compacted;
}
created = {
tombstoneId: this.options.idFactory?.() ?? `opencode-run-tombstone-${randomUUID()}`,
teamName: input.teamName,
runId: input.runId,
reason: input.reason,
evidenceKinds: normalizeEvidenceKinds(input.evidenceKinds),
createdAt: now.toISOString(),
expiresAt:
typeof input.ttlMs === 'number'
? new Date(now.getTime() + input.ttlMs).toISOString()
: null,
diagnostic: input.diagnostic ?? null,
};
return [...compacted, created];
});
if (!created) {
throw new Error('Runtime run tombstone was not created');
}
return created;
}
async list(teamName: string): Promise<RuntimeRunTombstone[]> {
const records = await this.readRequired();
const now = (this.options.clock ?? (() => new Date()))();
return compactRuntimeRunTombstones(records, now).filter(
(record) => record.teamName === teamName
);
}
async find(input: {
teamName: string;
runId: string;
evidenceKind?: RuntimeEvidenceKind;
}): Promise<RuntimeRunTombstone | null> {
const records = await this.list(input.teamName);
return (
records.find(
(record) =>
record.runId === input.runId &&
(!input.evidenceKind || record.evidenceKinds.includes(input.evidenceKind))
) ?? null
);
}
async assertEvidenceAccepted(input: RuntimeEvidenceAcceptanceInput): Promise<void> {
assertRuntimeEvidenceRunMatches(input);
const tombstone = input.runId
? await this.find({
teamName: input.teamName,
runId: input.runId,
evidenceKind: input.evidenceKind,
})
: null;
if (tombstone) {
throw new RuntimeStaleEvidenceError(
`Rejected stale runtime evidence: ${input.evidenceKind}`,
'run_tombstoned',
input.evidenceKind,
input.runId
);
}
}
async compact(): Promise<number> {
const now = (this.options.clock ?? (() => new Date()))();
let removed = 0;
await this.store.updateLocked((records) => {
const compacted = compactRuntimeRunTombstones(records, now);
removed = records.length - compacted.length;
return compacted;
});
return removed;
}
private async readRequired(): Promise<RuntimeRunTombstone[]> {
const result = await this.store.read();
if (!result.ok) {
throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath);
}
return result.data;
}
}
export function assertRuntimeEvidenceRunMatches(input: RuntimeEvidenceAcceptanceInput): void {
if (!input.runId) {
throw new RuntimeStaleEvidenceError(
`Rejected runtime evidence without run id: ${input.evidenceKind}`,
'missing_run_id',
input.evidenceKind,
input.runId
);
}
if (!input.currentRunId) {
throw new RuntimeStaleEvidenceError(
`Rejected runtime evidence without current run: ${input.evidenceKind}`,
'current_run_missing',
input.evidenceKind,
input.runId
);
}
if (input.runId !== input.currentRunId) {
throw new RuntimeStaleEvidenceError(
`Rejected stale runtime evidence: ${input.evidenceKind}`,
'run_mismatch',
input.evidenceKind,
input.runId
);
}
}
export function createRuntimeRunTombstoneStore(options: {
filePath: string;
idFactory?: () => string;
clock?: () => Date;
}): RuntimeRunTombstoneStore {
const clock = options.clock ?? (() => new Date());
return new RuntimeRunTombstoneStore(
new VersionedJsonStore<RuntimeRunTombstone[]>({
filePath: options.filePath,
schemaVersion: OPENCODE_RUNTIME_RUN_TOMBSTONE_SCHEMA_VERSION,
defaultData: () => [],
validate: validateRuntimeRunTombstones,
clock,
}),
{
idFactory: options.idFactory,
clock,
}
);
}
export function validateRuntimeRunTombstones(value: unknown): RuntimeRunTombstone[] {
if (!Array.isArray(value)) {
throw new Error('Runtime run tombstones must be an array');
}
const seen = new Set<string>();
return value.map((record, index) => {
if (!isRuntimeRunTombstone(record)) {
throw new Error(`Invalid runtime run tombstone at index ${index}`);
}
if (seen.has(record.tombstoneId)) {
throw new Error(`Duplicate runtime run tombstone id: ${record.tombstoneId}`);
}
seen.add(record.tombstoneId);
return record;
});
}
export function compactRuntimeRunTombstones(
records: RuntimeRunTombstone[],
now: Date
): RuntimeRunTombstone[] {
const nowMs = now.getTime();
return records.filter(
(record) => record.expiresAt === null || Date.parse(record.expiresAt) > nowMs
);
}
function normalizeEvidenceKinds(input: RuntimeEvidenceKind[] | undefined): RuntimeEvidenceKind[] {
const all: RuntimeEvidenceKind[] = [
'sse_event',
'permission_reply',
'delivery_call',
'prompt_error',
'bootstrap_checkin',
'launch_checkpoint',
'heartbeat',
'bridge_result',
'recovery_result',
];
const source = input && input.length > 0 ? input : all;
return [...new Set(source)].sort();
}
function isRuntimeRunTombstone(value: unknown): value is RuntimeRunTombstone {
return (
isRecord(value) &&
isNonEmptyString(value.tombstoneId) &&
isNonEmptyString(value.teamName) &&
isNonEmptyString(value.runId) &&
isRuntimeRunTombstoneReason(value.reason) &&
Array.isArray(value.evidenceKinds) &&
value.evidenceKinds.length > 0 &&
value.evidenceKinds.every(isRuntimeEvidenceKind) &&
isNonEmptyString(value.createdAt) &&
(value.expiresAt === null || isNonEmptyString(value.expiresAt)) &&
(value.diagnostic === null || typeof value.diagnostic === 'string')
);
}
function isRuntimeRunTombstoneReason(value: unknown): value is RuntimeRunTombstoneReason {
return (
value === 'stop_requested' ||
value === 'relaunch_started' ||
value === 'run_replaced' ||
value === 'provider_session_aborted' ||
value === 'recovery_rejected'
);
}
function isRuntimeEvidenceKind(value: unknown): value is RuntimeEvidenceKind {
return (
value === 'sse_event' ||
value === 'permission_reply' ||
value === 'delivery_call' ||
value === 'prompt_error' ||
value === 'bootstrap_checkin' ||
value === 'launch_checkpoint' ||
value === 'heartbeat' ||
value === 'bridge_result' ||
value === 'recovery_result'
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,292 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { withFileLock } from '../../fileLock';
export interface VersionedJsonStoreEnvelope<TData> {
schemaVersion: number;
updatedAt: string;
data: TData;
}
export type VersionedJsonStoreReadStatus = 'missing' | 'loaded';
export type VersionedJsonStoreFailureReason =
| 'invalid_json'
| 'invalid_envelope'
| 'invalid_data'
| 'future_schema';
export type VersionedJsonStoreReadResult<TData> =
| {
ok: true;
status: VersionedJsonStoreReadStatus;
data: TData;
envelope: VersionedJsonStoreEnvelope<TData> | null;
}
| {
ok: false;
reason: VersionedJsonStoreFailureReason;
message: string;
quarantinePath: string | null;
};
export interface VersionedJsonStoreUpdateResult<TData> {
changed: boolean;
data: TData;
envelope: VersionedJsonStoreEnvelope<TData>;
}
export interface VersionedJsonStoreOptions<TData> {
filePath: string;
schemaVersion: number;
defaultData: () => TData;
validate: (value: unknown) => TData;
clock?: () => Date;
quarantineDir?: string;
}
export class VersionedJsonStoreError extends Error {
constructor(
message: string,
readonly reason: VersionedJsonStoreFailureReason,
readonly quarantinePath: string | null
) {
super(message);
this.name = 'VersionedJsonStoreError';
}
}
export class VersionedJsonStore<TData> {
private readonly filePath: string;
private readonly schemaVersion: number;
private readonly defaultData: () => TData;
private readonly validate: (value: unknown) => TData;
private readonly clock: () => Date;
private readonly quarantineDir: string | null;
constructor(options: VersionedJsonStoreOptions<TData>) {
this.filePath = options.filePath;
this.schemaVersion = options.schemaVersion;
this.defaultData = options.defaultData;
this.validate = options.validate;
this.clock = options.clock ?? (() => new Date());
this.quarantineDir = options.quarantineDir ?? null;
}
async read(): Promise<VersionedJsonStoreReadResult<TData>> {
return this.readUnlocked();
}
async updateLocked(
updater: (current: TData) => TData | Promise<TData>
): Promise<VersionedJsonStoreUpdateResult<TData>> {
return withFileLock(this.filePath, async () => {
const current = await this.readUnlocked();
if (!current.ok) {
throw new VersionedJsonStoreError(current.message, current.reason, current.quarantinePath);
}
const nextData = await updater(cloneJson(current.data));
const validatedNextData = this.validate(nextData);
const currentJson = stableJsonStringify(current.data);
const nextJson = stableJsonStringify(validatedNextData);
const changed = current.status === 'missing' || currentJson !== nextJson;
const envelope: VersionedJsonStoreEnvelope<TData> = {
schemaVersion: this.schemaVersion,
updatedAt: changed
? this.clock().toISOString()
: (current.envelope?.updatedAt ?? this.clock().toISOString()),
data: changed ? validatedNextData : current.data,
};
if (changed) {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`);
}
return {
changed,
data: envelope.data,
envelope,
};
});
}
private async readUnlocked(): Promise<VersionedJsonStoreReadResult<TData>> {
let raw: string;
try {
raw = await fs.readFile(this.filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
const data = this.validate(this.defaultData());
return {
ok: true,
status: 'missing',
data,
envelope: null,
};
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
const quarantinePath = await this.quarantine(raw, 'invalid_json');
return {
ok: false,
reason: 'invalid_json',
message: `Invalid JSON in versioned store ${this.filePath}: ${stringifyError(error)}`,
quarantinePath,
};
}
const envelopeResult = this.normalizeEnvelope(parsed);
if (!envelopeResult.ok) {
const quarantinePath = await this.quarantine(raw, envelopeResult.reason);
return {
ok: false,
reason: envelopeResult.reason,
message: envelopeResult.message,
quarantinePath,
};
}
if (envelopeResult.envelope.schemaVersion > this.schemaVersion) {
const quarantinePath = await this.quarantine(raw, 'future_schema');
return {
ok: false,
reason: 'future_schema',
message: `Future schema ${envelopeResult.envelope.schemaVersion} in ${this.filePath}; supported ${this.schemaVersion}`,
quarantinePath,
};
}
try {
const data = this.validate(envelopeResult.envelope.data);
return {
ok: true,
status: 'loaded',
data,
envelope: {
schemaVersion: envelopeResult.envelope.schemaVersion,
updatedAt: envelopeResult.envelope.updatedAt,
data,
},
};
} catch (error) {
const quarantinePath = await this.quarantine(raw, 'invalid_data');
return {
ok: false,
reason: 'invalid_data',
message: `Invalid data in versioned store ${this.filePath}: ${stringifyError(error)}`,
quarantinePath,
};
}
}
private normalizeEnvelope(
value: unknown
):
| { ok: true; envelope: VersionedJsonStoreEnvelope<unknown> }
| { ok: false; reason: VersionedJsonStoreFailureReason; message: string } {
if (!isRecord(value)) {
return {
ok: false,
reason: 'invalid_envelope',
message: `Versioned store ${this.filePath} must contain a JSON object`,
};
}
const schemaVersion = value.schemaVersion;
if (!Number.isInteger(schemaVersion) || (schemaVersion as number) < 1) {
return {
ok: false,
reason: 'invalid_envelope',
message: `Versioned store ${this.filePath} has invalid schemaVersion`,
};
}
if (typeof value.updatedAt !== 'string' || !value.updatedAt.trim()) {
return {
ok: false,
reason: 'invalid_envelope',
message: `Versioned store ${this.filePath} has invalid updatedAt`,
};
}
if (!Object.prototype.hasOwnProperty.call(value, 'data')) {
return {
ok: false,
reason: 'invalid_envelope',
message: `Versioned store ${this.filePath} is missing data`,
};
}
return {
ok: true,
envelope: {
schemaVersion: schemaVersion as number,
updatedAt: value.updatedAt,
data: value.data,
},
};
}
private async quarantine(
raw: string,
reason: VersionedJsonStoreFailureReason
): Promise<string | null> {
const dir = this.quarantineDir ?? path.dirname(this.filePath);
const baseName = path.basename(this.filePath);
const stamp = this.clock().toISOString().replace(/[:.]/g, '-');
const quarantinePath = path.join(dir, `${baseName}.${reason}.${stamp}.quarantine`);
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(quarantinePath, raw, 'utf8');
return quarantinePath;
} catch {
return null;
}
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function stableJsonStringify(value: unknown): string {
return JSON.stringify(normalizeStableJson(value));
}
function normalizeStableJson(value: unknown): unknown {
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(normalizeStableJson);
}
const output: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
const nested = (value as Record<string, unknown>)[key];
if (nested !== undefined) {
output[key] = normalizeStableJson(nested);
}
}
return output;
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -0,0 +1,284 @@
import { createHash } from 'crypto';
import { promises as fs } from 'fs';
import type {
OpenCodeApiCapabilities,
OpenCodeApiEndpointKey,
OpenCodeEndpointEvidence,
} from '../capabilities/OpenCodeApiCapabilities';
import {
assertOpenCodeProductionE2EEvidenceBasics,
type OpenCodeProductionE2EEvidence,
} from '../e2e/OpenCodeProductionE2EEvidence';
export interface OpenCodeSupportedVersionPolicy {
minimumVersion: string;
testedVersion: string;
allowedPrerelease: boolean;
requireCapabilities: boolean;
requireE2EArtifactsForTestedVersion: boolean;
}
export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = {
minimumVersion: '1.14.19',
testedVersion: '1.14.19',
allowedPrerelease: false,
requireCapabilities: true,
requireE2EArtifactsForTestedVersion: true,
};
export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown';
export interface OpenCodeSemver {
major: number;
minor: number;
patch: number;
prerelease: string[];
}
export type OpenCodeSupportLevel =
| 'unsupported_too_old'
| 'unsupported_prerelease'
| 'supported_capabilities_pending'
| 'supported_e2e_pending'
| 'production_supported';
export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence';
export interface OpenCodeCompatibilitySnapshot {
schemaVersion: 1;
createdAt: string;
binaryPath: string;
binaryFingerprint: string;
installMethod: OpenCodeInstallMethod;
version: string;
semver: OpenCodeSemver;
supported: boolean;
supportLevel: OpenCodeSupportLevel;
apiCapabilities: OpenCodeApiCapabilities;
testedEvidencePath: string | null;
diagnostics: string[];
}
export interface OpenCodeSupportDecision {
supported: boolean;
supportLevel: OpenCodeSupportLevel;
semver: OpenCodeSemver | null;
diagnostics: string[];
}
export interface OpenCodeRouteCompatibilityCache {
binaryFingerprint: string;
version: string;
routes: Record<
OpenCodeApiEndpointKey,
{
available: boolean;
evidence: OpenCodeEndpointEvidence;
lastVerifiedAt: string;
}
>;
}
export type OpenCodePermissionReplyRoute =
| {
kind: 'primary_permission_reply';
method: 'POST';
pathTemplate: '/permission/:requestID/reply';
bodyShape: { reply: 'once' };
}
| {
kind: 'deprecated_session_permission';
method: 'POST';
pathTemplate: '/session/:sessionID/permissions/:permissionID';
bodyShape: { response: 'once' };
};
export async function buildOpenCodeBinaryFingerprint(binaryPath: string): Promise<string> {
const stat = await fs.stat(binaryPath);
return stableHash({
binaryPath,
realPath: await fs.realpath(binaryPath),
size: stat.size,
mtimeMs: stat.mtimeMs,
});
}
export function shouldReuseCompatibilitySnapshot(input: {
cached: OpenCodeCompatibilitySnapshot | null;
binaryPath: string;
binaryFingerprint: string;
version: string;
}): boolean {
return Boolean(
input.cached &&
input.cached.binaryPath === input.binaryPath &&
input.cached.binaryFingerprint === input.binaryFingerprint &&
input.cached.version === input.version
);
}
export function evaluateOpenCodeSupport(input: {
version: string;
capabilities: OpenCodeApiCapabilities;
evidence: OpenCodeProductionE2EEvidence | null;
policy?: OpenCodeSupportedVersionPolicy;
}): OpenCodeSupportDecision {
const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
const parsed = parseOpenCodeSemver(input.version);
if (!parsed || semverCoreLt(parsed, policy.minimumVersion)) {
return {
supported: false,
supportLevel: 'unsupported_too_old',
semver: parsed,
diagnostics: [
`OpenCode ${input.version} is below supported minimum ${policy.minimumVersion}`,
],
};
}
if (parsed.prerelease.length > 0 && !policy.allowedPrerelease) {
return {
supported: false,
supportLevel: 'unsupported_prerelease',
semver: parsed,
diagnostics: [
`OpenCode prerelease ${input.version} is not enabled for production team launch`,
],
};
}
if (policy.requireCapabilities && !input.capabilities.requiredForTeamLaunch.ready) {
return {
supported: false,
supportLevel: 'supported_capabilities_pending',
semver: parsed,
diagnostics: input.capabilities.requiredForTeamLaunch.missing,
};
}
if (policy.requireE2EArtifactsForTestedVersion) {
const evidenceDecision = assertOpenCodeProductionE2EGate({
evidence: input.evidence,
testedVersion: policy.testedVersion,
});
if (!evidenceDecision.ok) {
return {
supported: false,
supportLevel: 'supported_e2e_pending',
semver: parsed,
diagnostics: evidenceDecision.diagnostics,
};
}
}
return {
supported: true,
supportLevel: 'production_supported',
semver: parsed,
diagnostics: [],
};
}
export function assertOpenCodeProductionE2EGate(input: {
evidence: OpenCodeProductionE2EEvidence | null;
testedVersion: string;
now?: Date;
}): { ok: boolean; diagnostics: string[] } {
return assertOpenCodeProductionE2EEvidenceBasics(input);
}
export function selectPermissionReplyRouteFromCache(
cache: OpenCodeRouteCompatibilityCache
): OpenCodePermissionReplyRoute | null {
if (cache.routes.permissionReply?.available) {
return {
kind: 'primary_permission_reply',
method: 'POST',
pathTemplate: '/permission/:requestID/reply',
bodyShape: { reply: 'once' },
};
}
if (cache.routes.permissionLegacySessionRespond?.available) {
return {
kind: 'deprecated_session_permission',
method: 'POST',
pathTemplate: '/session/:sessionID/permissions/:permissionID',
bodyShape: { response: 'once' },
};
}
return null;
}
export function parseOpenCodeSemver(version: string): OpenCodeSemver | null {
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
if (!match) {
return null;
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4]?.split('.').filter(Boolean) ?? [],
};
}
export function semverLt(left: OpenCodeSemver, right: string | OpenCodeSemver): boolean {
const parsedRight = typeof right === 'string' ? parseOpenCodeSemver(right) : right;
if (!parsedRight) {
return true;
}
for (const key of ['major', 'minor', 'patch'] as const) {
if (left[key] < parsedRight[key]) {
return true;
}
if (left[key] > parsedRight[key]) {
return false;
}
}
if (left.prerelease.length > 0 && parsedRight.prerelease.length === 0) {
return true;
}
return false;
}
function semverCoreLt(left: OpenCodeSemver, right: string | OpenCodeSemver): boolean {
const parsedRight = typeof right === 'string' ? parseOpenCodeSemver(right) : right;
if (!parsedRight) {
return true;
}
for (const key of ['major', 'minor', 'patch'] as const) {
if (left[key] < parsedRight[key]) {
return true;
}
if (left[key] > parsedRight[key]) {
return false;
}
}
return false;
}
function stableHash(value: unknown): string {
return createHash('sha256').update(stableJsonStringify(value)).digest('hex');
}
function stableJsonStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(stableJsonStringify).join(',')}]`;
}
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`)
.join(',')}}`;
}

View file

@ -0,0 +1,499 @@
import { randomUUID } from 'crypto';
import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness';
import type {
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeBridgeRuntimeSnapshot,
OpenCodeReconcileTeamCommandBody,
OpenCodeStopTeamCommandBody,
OpenCodeStopTeamCommandData,
OpenCodeTeamLaunchMode,
OpenCodeTeamMemberLaunchBridgeState,
} from '../opencode/bridge/OpenCodeBridgeCommandContract';
import type {
TeamLaunchRuntimeAdapter,
TeamRuntimeLaunchInput,
TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberStopEvidence,
TeamRuntimePrepareResult,
TeamRuntimeReconcileInput,
TeamRuntimeReconcileResult,
TeamRuntimeStopInput,
TeamRuntimeStopResult,
} from './TeamRuntimeAdapter';
export interface OpenCodeTeamRuntimeBridgePort {
checkOpenCodeTeamLaunchReadiness(input: {
projectPath: string;
selectedModel: string | null;
requireExecutionProbe: boolean;
launchMode?: OpenCodeTeamLaunchMode;
}): Promise<OpenCodeTeamLaunchReadiness>;
getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null;
launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise<OpenCodeLaunchTeamCommandData>;
reconcileOpenCodeTeam?(
input: OpenCodeReconcileTeamCommandBody
): Promise<OpenCodeLaunchTeamCommandData>;
stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise<OpenCodeStopTeamCommandData>;
}
export interface OpenCodeTeamRuntimeAdapterOptions {
launchMode?: OpenCodeTeamLaunchMode;
/**
* @deprecated Use launchMode. Kept for older tests/callers until the production gate is fully wired.
*/
launchEnabled?: boolean;
}
export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract';
const REQUIRED_READY_CHECKPOINTS = new Set([
'required_tools_proven',
'delivery_ready',
'member_ready',
'run_ready',
]);
export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
readonly providerId = 'opencode' as const;
private readonly lastProjectPathByTeamName = new Map<string, string>();
constructor(
private readonly bridge: OpenCodeTeamRuntimeBridgePort,
private readonly options: OpenCodeTeamRuntimeAdapterOptions = {}
) {}
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
const launchMode = resolveOpenCodeTeamLaunchMode(this.options);
if (launchMode === 'disabled') {
return {
ok: false,
providerId: this.providerId,
reason: 'opencode_team_launch_disabled',
retryable: false,
diagnostics: [
'OpenCode team launch mode is disabled. Set CLAUDE_TEAM_OPENCODE_LAUNCH_MODE=dogfood for local dogfood testing or production after strict readiness evidence exists.',
],
warnings: [],
};
}
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
projectPath: input.cwd,
selectedModel: input.model ?? null,
requireExecutionProbe: true,
launchMode,
});
if (!readiness.launchAllowed) {
return {
ok: false,
providerId: this.providerId,
reason: readiness.state,
retryable: isRetryableReadinessState(readiness.state),
diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing),
warnings: [],
};
}
const warnings =
launchMode === 'dogfood'
? [
'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.',
]
: [];
if (launchMode === 'production' && readiness.supportLevel !== 'production_supported') {
return {
ok: false,
providerId: this.providerId,
reason: 'opencode_production_e2e_evidence_missing',
retryable: false,
diagnostics: [
'OpenCode production launch requires strict production E2E evidence before enabling team launch.',
],
warnings,
};
}
return {
ok: true,
providerId: this.providerId,
modelId: readiness.modelId,
diagnostics: readiness.diagnostics,
warnings,
};
}
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
const prepared = await this.prepare(input);
if (!prepared.ok) {
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings);
}
if (!this.bridge.launchOpenCodeTeam) {
return blockedLaunchResult(input, 'opencode_launch_bridge_missing', [
'OpenCode readiness passed, but the state-changing launch bridge is not registered.',
]);
}
const selectedModel = prepared.modelId ?? input.model?.trim() ?? '';
if (!selectedModel) {
return blockedLaunchResult(input, 'opencode_model_unavailable', [
'OpenCode launch requires a selected raw model id.',
]);
}
const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null;
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
const data = await this.bridge.launchOpenCodeTeam({
mode: resolveOpenCodeTeamLaunchMode(this.options),
runId: input.runId,
teamId: input.teamName,
teamName: input.teamName,
projectPath: input.cwd,
selectedModel,
members: input.expectedMembers.map((member) => ({
name: member.name,
role: member.role?.trim() || member.workflow?.trim() || 'teammate',
prompt: buildMemberBootstrapPrompt(input, member.name),
})),
leadPrompt: input.prompt?.trim() ?? '',
expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null,
manifestHighWatermark: null,
});
return mapOpenCodeLaunchDataToRuntimeResult(input, data, prepared.warnings);
}
async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> {
if (this.bridge.reconcileOpenCodeTeam) {
const projectPath =
input.expectedMembers[0]?.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
const runtimeSnapshot = projectPath
? (this.bridge.getLastOpenCodeRuntimeSnapshot?.(projectPath) ?? null)
: null;
const data = await this.bridge.reconcileOpenCodeTeam({
runId: input.runId,
teamId: input.teamName,
teamName: input.teamName,
projectPath,
expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null,
manifestHighWatermark: null,
reconcileAttemptId: `opencode-reconcile-${randomUUID()}`,
expectedMembers: input.expectedMembers.map((member) => ({
name: member.name,
model: member.model ?? null,
})),
reason: input.reason,
});
const mapped = mapOpenCodeLaunchDataToRuntimeResult(
{
runId: input.runId,
teamName: input.teamName,
cwd: input.expectedMembers[0]?.cwd ?? '',
providerId: this.providerId,
skipPermissions: false,
expectedMembers: input.expectedMembers,
previousLaunchState: input.previousLaunchState,
},
data,
[]
);
return {
...mapped,
snapshot: input.previousLaunchState,
};
}
const snapshot = input.previousLaunchState;
if (!snapshot) {
return {
runId: input.runId,
teamName: input.teamName,
launchPhase: 'reconciled',
teamLaunchState: 'partial_pending',
members: {},
snapshot: null,
warnings: [],
diagnostics: ['No previous OpenCode launch snapshot was available for reconciliation.'],
};
}
return {
runId: input.runId,
teamName: input.teamName,
launchPhase: snapshot.launchPhase,
teamLaunchState: snapshot.teamLaunchState,
members: Object.fromEntries(
Object.entries(snapshot.members).map(([memberName, member]) => [
memberName,
{
memberName,
providerId: this.providerId,
launchState: member.launchState,
agentToolAccepted: member.agentToolAccepted,
runtimeAlive: member.runtimeAlive,
bootstrapConfirmed: member.bootstrapConfirmed,
hardFailure: member.hardFailure,
hardFailureReason: member.hardFailureReason,
diagnostics: member.diagnostics ?? [],
} satisfies TeamRuntimeMemberLaunchEvidence,
])
),
snapshot,
warnings: [],
diagnostics: [`OpenCode launch snapshot reconciled from ${input.reason}.`],
};
}
async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> {
if (this.bridge.stopOpenCodeTeam) {
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
const runtimeSnapshot = projectPath
? (this.bridge.getLastOpenCodeRuntimeSnapshot?.(projectPath) ?? null)
: null;
const data = await this.bridge.stopOpenCodeTeam({
runId: input.runId,
teamId: input.teamName,
teamName: input.teamName,
projectPath,
expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null,
manifestHighWatermark: null,
reason: input.reason,
force: input.force,
});
if (data.stopped) {
this.lastProjectPathByTeamName.delete(input.teamName);
}
return {
runId: input.runId,
teamName: input.teamName,
stopped: data.stopped,
members: Object.fromEntries(
Object.entries(data.members).map(([memberName, member]) => [
memberName,
{
memberName,
providerId: this.providerId,
stopped: member.stopped,
sessionId: member.sessionId,
diagnostics: member.diagnostics,
} satisfies TeamRuntimeMemberStopEvidence,
])
),
warnings: data.warnings.map((warning) => warning.message),
diagnostics: data.diagnostics.map(formatOpenCodeBridgeDiagnostic),
};
}
const members = input.previousLaunchState
? Object.fromEntries(
Object.keys(input.previousLaunchState.members).map((memberName) => [
memberName,
{
memberName,
providerId: this.providerId,
stopped: true,
diagnostics: [
'No live OpenCode session stop command is wired in this adapter shell.',
],
} satisfies TeamRuntimeMemberStopEvidence,
])
)
: {};
return {
runId: input.runId,
teamName: input.teamName,
stopped: true,
members,
warnings: [],
diagnostics: input.previousLaunchState
? ['OpenCode stop was acknowledged without live session ownership changes.']
: ['No previous OpenCode launch snapshot was available to stop.'],
};
}
}
export function resolveOpenCodeTeamLaunchMode(
options: OpenCodeTeamRuntimeAdapterOptions = {}
): OpenCodeTeamLaunchMode {
if (options.launchMode) {
return options.launchMode;
}
if (options.launchEnabled === true) {
return 'production';
}
return 'disabled';
}
function mapOpenCodeLaunchDataToRuntimeResult(
input: TeamRuntimeLaunchInput,
data: OpenCodeLaunchTeamCommandData,
prepareWarnings: string[]
): TeamRuntimeLaunchResult {
const checkpointNames = extractCheckpointNames(data);
const readyCheckpointsPresent = [...REQUIRED_READY_CHECKPOINTS].every((name) =>
checkpointNames.has(name)
);
const bridgeReady = data.teamLaunchState === 'ready';
const success = bridgeReady && readyCheckpointsPresent;
const checkpointDiagnostic = success
? []
: bridgeReady
? [
`OpenCode bridge reported ready without all required durable checkpoints: missing ${[
...REQUIRED_READY_CHECKPOINTS,
]
.filter((name) => !checkpointNames.has(name))
.join(', ')}`,
]
: [];
const members = Object.fromEntries(
input.expectedMembers.map((member) => {
const bridgeMember = data.members[member.name];
return [
member.name,
mapBridgeMemberToRuntimeEvidence(
member.name,
bridgeMember?.launchState ?? 'failed',
bridgeMember?.sessionId,
[
...(bridgeMember?.evidence ?? []).map(
(evidence) => `${evidence.kind} at ${evidence.observedAt}`
),
...checkpointDiagnostic,
]
),
];
})
);
return {
runId: input.runId,
teamName: input.teamName,
launchPhase: success
? 'finished'
: data.teamLaunchState === 'launching'
? 'active'
: 'finished',
teamLaunchState: success
? 'clean_success'
: data.teamLaunchState === 'launching' || data.teamLaunchState === 'permission_blocked'
? 'partial_pending'
: 'partial_failure',
members,
warnings: [...prepareWarnings, ...data.warnings.map((warning) => warning.message)],
diagnostics: [...data.diagnostics.map(formatOpenCodeBridgeDiagnostic), ...checkpointDiagnostic],
};
}
function mapBridgeMemberToRuntimeEvidence(
memberName: string,
launchState: OpenCodeTeamMemberLaunchBridgeState,
sessionId: string | undefined,
diagnostics: string[]
): TeamRuntimeMemberLaunchEvidence {
const confirmed = launchState === 'confirmed_alive';
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
const failed = launchState === 'failed';
return {
memberName,
providerId: 'opencode',
launchState: failed
? 'failed_to_start'
: confirmed
? 'confirmed_alive'
: 'runtime_pending_bootstrap',
agentToolAccepted: confirmed || createdOrBlocked,
runtimeAlive: confirmed || createdOrBlocked,
bootstrapConfirmed: confirmed,
hardFailure: failed,
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
sessionId,
diagnostics,
};
}
function extractCheckpointNames(data: OpenCodeLaunchTeamCommandData): Set<string> {
const names = new Set<string>();
for (const checkpoint of data.durableCheckpoints ?? []) {
if (checkpoint.name.trim()) names.add(checkpoint.name);
}
for (const member of Object.values(data.members)) {
for (const evidence of member.evidence) {
if (evidence.kind.trim()) names.add(evidence.kind);
}
}
return names;
}
function buildMemberBootstrapPrompt(input: TeamRuntimeLaunchInput, memberName: string): string {
const shared = input.prompt?.trim();
if (shared) {
return shared;
}
return `Join team "${input.teamName}" as "${memberName}" and wait for app MCP task delivery.`;
}
function formatOpenCodeBridgeDiagnostic(diagnostic: {
code: string;
severity: 'info' | 'warning' | 'error';
message: string;
}): string {
return `${diagnostic.severity}:${diagnostic.code}: ${diagnostic.message}`;
}
function blockedLaunchResult(
input: TeamRuntimeLaunchInput,
reason: string,
diagnostics: string[],
warnings: string[] = []
): TeamRuntimeLaunchResult {
const members = Object.fromEntries(
input.expectedMembers.map((member) => [
member.name,
{
memberName: member.name,
providerId: 'opencode' as const,
launchState: 'failed_to_start' as const,
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: reason,
diagnostics,
},
])
);
return {
runId: input.runId,
teamName: input.teamName,
launchPhase: 'finished',
teamLaunchState: 'partial_failure',
members,
warnings,
diagnostics,
};
}
function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']): boolean {
return (
state === 'not_installed' ||
state === 'not_authenticated' ||
state === 'e2e_missing' ||
state === 'runtime_store_blocked' ||
state === 'mcp_unavailable' ||
state === 'model_unavailable' ||
state === 'unknown_error'
);
}
function mergeDiagnostics(left: string[], right: string[]): string[] {
return [...new Set([...left, ...right].filter((value) => value.trim().length > 0))];
}

View file

@ -0,0 +1,184 @@
import type {
EffortLevel,
MemberLaunchState,
PersistedTeamLaunchPhase,
PersistedTeamLaunchSnapshot,
TeamAgentRuntimeBackendType,
TeamLaunchAggregateState,
} from '@shared/types';
export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const;
export type TeamRuntimeProviderId = (typeof TEAM_RUNTIME_PROVIDER_IDS)[number];
export interface TeamRuntimeMemberSpec {
name: string;
role?: string;
workflow?: string;
providerId: TeamRuntimeProviderId;
model?: string;
effort?: EffortLevel;
cwd: string;
}
export interface TeamRuntimeLaunchInput {
runId: string;
teamName: string;
cwd: string;
prompt?: string;
providerId: TeamRuntimeProviderId;
model?: string;
effort?: EffortLevel;
skipPermissions: boolean;
expectedMembers: TeamRuntimeMemberSpec[];
previousLaunchState: PersistedTeamLaunchSnapshot | null;
}
export interface TeamRuntimePrepareSuccess {
ok: true;
providerId: TeamRuntimeProviderId;
modelId: string | null;
diagnostics: string[];
warnings: string[];
}
export interface TeamRuntimePrepareFailure {
ok: false;
providerId: TeamRuntimeProviderId;
reason: string;
diagnostics: string[];
warnings: string[];
retryable: boolean;
}
export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePrepareFailure;
export interface TeamRuntimeMemberLaunchEvidence {
memberName: string;
providerId: TeamRuntimeProviderId;
launchState: MemberLaunchState;
agentToolAccepted: boolean;
runtimeAlive: boolean;
bootstrapConfirmed: boolean;
hardFailure: boolean;
hardFailureReason?: string;
sessionId?: string;
backendType?: TeamAgentRuntimeBackendType;
diagnostics: string[];
}
export interface TeamRuntimeLaunchResult {
runId: string;
teamName: string;
leadSessionId?: string;
launchPhase: PersistedTeamLaunchPhase;
teamLaunchState: TeamLaunchAggregateState;
members: Record<string, TeamRuntimeMemberLaunchEvidence>;
warnings: string[];
diagnostics: string[];
}
export type TeamRuntimeReconcileReason =
| 'startup_recovery'
| 'manual_refresh'
| 'launch_progress'
| 'provider_event'
| 'watcher_event'
| 'stop';
export interface TeamRuntimeReconcileInput {
runId: string;
teamName: string;
providerId: TeamRuntimeProviderId;
expectedMembers: TeamRuntimeMemberSpec[];
previousLaunchState: PersistedTeamLaunchSnapshot | null;
reason: TeamRuntimeReconcileReason;
}
export interface TeamRuntimeReconcileResult {
runId: string;
teamName: string;
launchPhase: PersistedTeamLaunchPhase;
teamLaunchState: TeamLaunchAggregateState;
members: Record<string, TeamRuntimeMemberLaunchEvidence>;
snapshot: PersistedTeamLaunchSnapshot | null;
warnings: string[];
diagnostics: string[];
}
export type TeamRuntimeStopReason = 'user_requested' | 'relaunch' | 'cleanup' | 'app_shutdown';
export interface TeamRuntimeStopInput {
runId: string;
teamName: string;
cwd?: string;
providerId: TeamRuntimeProviderId;
reason: TeamRuntimeStopReason;
previousLaunchState: PersistedTeamLaunchSnapshot | null;
force?: boolean;
}
export interface TeamRuntimeMemberStopEvidence {
memberName: string;
providerId: TeamRuntimeProviderId;
stopped: boolean;
sessionId?: string;
diagnostics: string[];
}
export interface TeamRuntimeStopResult {
runId: string;
teamName: string;
stopped: boolean;
members: Record<string, TeamRuntimeMemberStopEvidence>;
warnings: string[];
diagnostics: string[];
}
export interface TeamLaunchRuntimeAdapter {
readonly providerId: TeamRuntimeProviderId;
prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult>;
launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult>;
reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult>;
stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult>;
}
export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId {
return value === 'anthropic' || value === 'codex' || value === 'gemini' || value === 'opencode';
}
export class TeamRuntimeAdapterRegistry {
private readonly adapters = new Map<TeamRuntimeProviderId, TeamLaunchRuntimeAdapter>();
constructor(adapters: readonly TeamLaunchRuntimeAdapter[] = []) {
for (const adapter of adapters) {
this.register(adapter);
}
}
register(adapter: TeamLaunchRuntimeAdapter): void {
if (!isTeamRuntimeProviderId(adapter.providerId)) {
throw new Error(`Invalid runtime adapter provider: ${String(adapter.providerId)}`);
}
if (this.adapters.has(adapter.providerId)) {
throw new Error(`Runtime adapter already registered: ${adapter.providerId}`);
}
this.adapters.set(adapter.providerId, adapter);
}
get(providerId: TeamRuntimeProviderId): TeamLaunchRuntimeAdapter {
const adapter = this.adapters.get(providerId);
if (!adapter) {
throw new Error(`Runtime adapter is not available for provider ${providerId}`);
}
return adapter;
}
has(providerId: TeamRuntimeProviderId): boolean {
return this.adapters.has(providerId);
}
providers(): TeamRuntimeProviderId[] {
return Array.from(this.adapters.keys());
}
}

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