chore(team): checkpoint current frontend work

This commit is contained in:
777genius 2026-05-08 21:48:27 +03:00
parent 9d7542e9c4
commit f6e95f5b2f
64 changed files with 4620 additions and 413 deletions

View file

@ -66,6 +66,33 @@ function normalizeActorKey(value) {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
} }
function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) {
const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : '';
if (!reviewerName) return false;
const reviewerKey = normalizeActorKey(reviewerName);
const intervals = Array.isArray(task.reviewIntervals) ? [...task.reviewIntervals] : [];
const hasOpenForReviewer = intervals.some(
(interval) => !interval.completedAt && normalizeActorKey(interval.reviewer) === reviewerKey
);
if (hasOpenForReviewer) {
task.reviewIntervals = intervals;
return false;
}
task.reviewIntervals = [...intervals, { reviewer: reviewerName, startedAt: timestamp }];
return true;
}
function closeReviewIntervals(task, timestamp = new Date().toISOString()) {
if (!Array.isArray(task.reviewIntervals)) return false;
let changed = false;
task.reviewIntervals = task.reviewIntervals.map((interval) => {
if (interval.completedAt) return interval;
changed = true;
return { ...interval, completedAt: timestamp };
});
return changed;
}
function resolveKnownActorName(context, value, label) { function resolveKnownActorName(context, value, label) {
const actor = typeof value === 'string' && value.trim() ? value.trim() : ''; const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
if (!actor) return null; if (!actor) return null;
@ -130,7 +157,9 @@ function getReviewStartActor(context, task, flags) {
return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer'); return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer');
} }
throw new Error(`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`); throw new Error(
`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`
);
} }
function getLatestReviewStartedActor(task) { function getLatestReviewStartedActor(task) {
@ -155,18 +184,25 @@ function getLatestReviewStartedActor(task) {
function getReviewDecisionActor(context, task, flags, actionName) { function getReviewDecisionActor(context, task, flags, actionName) {
const explicit = resolveKnownActorName(context, flags.from, 'review actor'); const explicit = resolveKnownActorName(context, flags.from, 'review actor');
const startedActor = tryResolveKnownActorName(context, getLatestReviewStartedActor(task), 'review actor'); const startedActor = tryResolveKnownActorName(
const assignedReviewer = tryResolveKnownActorName(context, getLatestReviewRequestedReviewer(task), 'reviewer'); context,
getLatestReviewStartedActor(task),
'review actor'
);
const assignedReviewer = tryResolveKnownActorName(
context,
getLatestReviewRequestedReviewer(task),
'reviewer'
);
const inferredActor = const inferredActor =
startedActor && startedActor &&
(!assignedReviewer || (!assignedReviewer ||
resolveActorIdentityKey(context, startedActor) === resolveActorIdentityKey(context, assignedReviewer)) resolveActorIdentityKey(context, startedActor) ===
resolveActorIdentityKey(context, assignedReviewer))
? startedActor ? startedActor
: assignedReviewer; : assignedReviewer;
const actor = const actor =
explicit || explicit || inferredActor || resolveKnownActorName(context, 'team-lead', 'review actor');
inferredActor ||
resolveKnownActorName(context, 'team-lead', 'review actor');
assertMatchesAssignedReviewer(context, task, actor, actionName); assertMatchesAssignedReviewer(context, task, actor, actionName);
return actor; return actor;
} }
@ -176,12 +212,16 @@ function assertReviewTransitionAllowed(context, task, transitionName) {
throw new Error(`Task #${task.displayId || task.id} is deleted`); throw new Error(`Task #${task.displayId || task.id} is deleted`);
} }
if (task.status !== 'completed') { if (task.status !== 'completed') {
throw new Error(`Task #${task.displayId || task.id} must be completed before ${transitionName}`); throw new Error(
`Task #${task.displayId || task.id} must be completed before ${transitionName}`
);
} }
const reviewState = getEffectiveReviewState(context, task); const reviewState = getEffectiveReviewState(context, task);
if (reviewState !== 'review') { if (reviewState !== 'review') {
throw new Error(`Task #${task.displayId || task.id} must be in review before ${transitionName}`); throw new Error(
`Task #${task.displayId || task.id} must be in review before ${transitionName}`
);
} }
return reviewState; return reviewState;
} }
@ -223,9 +263,14 @@ function startReview(context, taskId, flags = {}) {
if (latestReviewEvent && latestReviewEvent.type === 'review_started') { if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
assertReviewTransitionAllowed(context, task, 'starting review'); assertReviewTransitionAllowed(context, task, 'starting review');
const existingActor = typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : ''; const existingActor =
typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
const existingActorValid = existingActor const existingActorValid = existingActor
? Boolean(runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { allowLeadAliases: true })) ? Boolean(
runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, {
allowLeadAliases: true,
})
)
: false; : false;
const assignedReviewer = tryResolveKnownActorName( const assignedReviewer = tryResolveKnownActorName(
context, context,
@ -235,7 +280,8 @@ function startReview(context, taskId, flags = {}) {
const existingMatchesAssigned = const existingMatchesAssigned =
!assignedReviewer || !assignedReviewer ||
(existingActorValid && (existingActorValid &&
resolveActorIdentityKey(context, existingActor) === resolveActorIdentityKey(context, assignedReviewer)); resolveActorIdentityKey(context, existingActor) ===
resolveActorIdentityKey(context, assignedReviewer));
const requestedActor = const requestedActor =
typeof flags.from === 'string' && flags.from.trim() typeof flags.from === 'string' && flags.from.trim()
? getReviewStartActor(context, task, flags) ? getReviewStartActor(context, task, flags)
@ -244,38 +290,52 @@ function startReview(context, taskId, flags = {}) {
existingActorValid && existingActorValid &&
existingMatchesAssigned && existingMatchesAssigned &&
requestedActor && requestedActor &&
resolveActorIdentityKey(context, existingActor) !== resolveActorIdentityKey(context, requestedActor) resolveActorIdentityKey(context, existingActor) !==
resolveActorIdentityKey(context, requestedActor)
) { ) {
throw new Error(`Task #${task.displayId || task.id} review is already started by ${existingActor}`); throw new Error(
`Task #${task.displayId || task.id} review is already started by ${existingActor}`
);
} }
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' }); kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
if (!existingActorValid || !existingMatchesAssigned) { if (!existingActorValid || !existingMatchesAssigned) {
const repairedActor = requestedActor || getReviewStartActor(context, task, flags); const repairedActor = requestedActor || getReviewStartActor(context, task, flags);
const timestamp = new Date().toISOString();
tasks.updateTask(context, task.id, (t) => { tasks.updateTask(context, task.id, (t) => {
openReviewInterval(t, repairedActor, timestamp);
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_started', type: 'review_started',
from: prevReviewState, from: prevReviewState,
to: 'review', to: 'review',
actor: repairedActor, actor: repairedActor,
timestamp,
}); });
t.reviewState = 'review'; t.reviewState = 'review';
return t; return t;
}); });
} else {
tasks.updateTask(context, task.id, (t) => {
openReviewInterval(t, existingActor);
return t;
});
} }
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' }; return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
} }
assertReviewTransitionAllowed(context, task, 'starting review'); assertReviewTransitionAllowed(context, task, 'starting review');
const from = getReviewStartActor(context, task, flags); const from = getReviewStartActor(context, task, flags);
const timestamp = new Date().toISOString();
try { try {
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' }); kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
tasks.updateTask(context, task.id, (t) => { tasks.updateTask(context, task.id, (t) => {
openReviewInterval(t, from, timestamp);
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_started', type: 'review_started',
from: prevReviewState, from: prevReviewState,
to: 'review', to: 'review',
actor: from, actor: from,
timestamp,
}); });
t.reviewState = 'review'; t.reviewState = 'review';
return t; return t;
@ -285,7 +345,10 @@ function startReview(context, taskId, flags = {}) {
try { try {
kanban.clearKanban(context, task.id, { transition: 'rollback' }); kanban.clearKanban(context, task.id, { transition: 'rollback' });
} catch (rollbackError) { } catch (rollbackError) {
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError); warnNonCritical(
`[review] rollback failed while starting review for ${task.id}`,
rollbackError
);
} }
throw error; throw error;
} }
@ -296,17 +359,23 @@ function requestReview(context, taskId, flags = {}) {
const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => { const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => {
const currentTask = tasks.getTask(context, taskId); const currentTask = tasks.getTask(context, taskId);
if (currentTask.status !== 'completed') { if (currentTask.status !== 'completed') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`); throw new Error(
`Task #${currentTask.displayId || currentTask.id} must be completed before review`
);
} }
const nextFrom = const nextFrom =
resolveKnownActorName(context, flags.from, 'review requester') || resolveKnownActorName(context, flags.from, 'review requester') ||
resolveKnownActorName(context, 'team-lead', 'review requester'); resolveKnownActorName(context, 'team-lead', 'review requester');
const rawReviewer = getReviewer(context, flags); const rawReviewer = getReviewer(context, flags);
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null; const nextReviewer = rawReviewer
? resolveKnownActorName(context, rawReviewer, 'reviewer')
: null;
const prevReviewState = getEffectiveReviewState(context, currentTask); const prevReviewState = getEffectiveReviewState(context, currentTask);
if (prevReviewState === 'approved') { if (prevReviewState === 'approved') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`); throw new Error(
`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`
);
} }
try { try {
@ -326,7 +395,10 @@ function requestReview(context, taskId, flags = {}) {
try { try {
kanban.clearKanban(context, currentTask.id, { transition: 'rollback' }); kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
} catch (rollbackError) { } catch (rollbackError) {
warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError); warnNonCritical(
`[review] rollback failed while requesting review for ${currentTask.id}`,
rollbackError
);
} }
throw error; throw error;
} }
@ -383,7 +455,9 @@ function approveReview(context, taskId, flags = {}) {
if (prevReviewState === 'approved') { if (prevReviewState === 'approved') {
if (currentTask.status !== 'completed') { if (currentTask.status !== 'completed') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before approval`); throw new Error(
`Task #${currentTask.displayId || currentTask.id} must be completed before approval`
);
} }
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' }); kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
return { return {
@ -399,15 +473,18 @@ function approveReview(context, taskId, flags = {}) {
} }
assertReviewTransitionAllowed(context, currentTask, 'approval'); assertReviewTransitionAllowed(context, currentTask, 'approval');
const timestamp = new Date().toISOString();
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' }); kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
tasks.updateTask(context, currentTask.id, (t) => { tasks.updateTask(context, currentTask.id, (t) => {
closeReviewIntervals(t, timestamp);
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_approved', type: 'review_approved',
from: prevReviewState, from: prevReviewState,
to: 'approved', to: 'approved',
...(nextNote ? { note: nextNote } : {}), ...(nextNote ? { note: nextNote } : {}),
actor: nextFrom, actor: nextFrom,
timestamp,
}); });
t.reviewState = 'approved'; t.reviewState = 'approved';
return t; return t;
@ -472,15 +549,22 @@ function requestChanges(context, taskId, flags = {}) {
typeof flags.comment === 'string' && flags.comment.trim() typeof flags.comment === 'string' && flags.comment.trim()
? flags.comment.trim() ? flags.comment.trim()
: 'Reviewer requested changes.'; : 'Reviewer requested changes.';
const prevReviewState = assertReviewTransitionAllowed(context, currentTask, 'requesting changes'); const prevReviewState = assertReviewTransitionAllowed(
context,
currentTask,
'requesting changes'
);
const timestamp = new Date().toISOString();
tasks.updateTask(context, currentTask.id, (t) => { tasks.updateTask(context, currentTask.id, (t) => {
closeReviewIntervals(t, timestamp);
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_changes_requested', type: 'review_changes_requested',
from: prevReviewState, from: prevReviewState,
to: 'needsFix', to: 'needsFix',
...(nextComment ? { note: nextComment } : {}), ...(nextComment ? { note: nextComment } : {}),
actor: nextFrom, actor: nextFrom,
timestamp,
}); });
t.reviewState = 'needsFix'; t.reviewState = 'needsFix';
return t; return t;

View file

@ -66,7 +66,9 @@ function normalizeTask(rawTask, filePath) {
}; };
if (!TASK_STATUSES.has(String(task.status || '').trim())) { if (!TASK_STATUSES.has(String(task.status || '').trim())) {
throw new Error(`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`); throw new Error(
`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`
);
} }
task.status = String(task.status).trim(); task.status = String(task.status).trim();
@ -121,10 +123,14 @@ function listTaskRows(paths, options = {}) {
} }
tasks.sort((a, b) => { tasks.sort((a, b) => {
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { const byDisplay = String(a.displayId || a.id).localeCompare(
numeric: true, String(b.displayId || b.id),
sensitivity: 'base', undefined,
}); {
numeric: true,
sensitivity: 'base',
}
);
if (byDisplay !== 0) return byDisplay; if (byDisplay !== 0) return byDisplay;
return String(a.id).localeCompare(String(b.id), undefined, { return String(a.id).localeCompare(String(b.id), undefined, {
numeric: true, numeric: true,
@ -144,7 +150,9 @@ function listTasks(paths, options = {}) {
} }
function resolveTaskRef(paths, taskRef, options = {}) { function resolveTaskRef(paths, taskRef, options = {}) {
const normalizedRef = String(taskRef || '').trim().replace(/^#/, ''); const normalizedRef = String(taskRef || '')
.trim()
.replace(/^#/, '');
if (!normalizedRef) { if (!normalizedRef) {
throw new Error('Missing taskId'); throw new Error('Missing taskId');
} }
@ -168,9 +176,7 @@ function resolveTaskRef(paths, taskRef, options = {}) {
} }
const byDisplay = tasks.find( const byDisplay = tasks.find(
(task) => (task) => task.displayId === normalizedRef && (includeDeleted || task.status !== 'deleted')
task.displayId === normalizedRef &&
(includeDeleted || task.status !== 'deleted')
); );
if (byDisplay) { if (byDisplay) {
return byDisplay.id; return byDisplay.id;
@ -195,6 +201,17 @@ function appendHistoryEvent(events, event) {
return list; return list;
} }
function closeOpenReviewIntervals(task, timestamp) {
if (!Array.isArray(task.reviewIntervals)) return false;
let changed = false;
task.reviewIntervals = task.reviewIntervals.map((interval) => {
if (interval.completedAt) return interval;
changed = true;
return { ...interval, completedAt: timestamp };
});
return changed;
}
function normalizeStatus(status) { function normalizeStatus(status) {
const normalized = String(status || '').trim(); const normalized = String(status || '').trim();
return TASK_STATUSES.has(normalized) ? normalized : null; return TASK_STATUSES.has(normalized) ? normalized : null;
@ -204,7 +221,10 @@ function parseRelationshipList(paths, value) {
const rawValues = Array.isArray(value) const rawValues = Array.isArray(value)
? value ? value
: typeof value === 'string' : typeof value === 'string'
? value.split(',').map((entry) => entry.trim()).filter(Boolean) ? value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
: []; : [];
return rawValues.map((entry) => resolveTaskRef(paths, entry)); return rawValues.map((entry) => resolveTaskRef(paths, entry));
@ -248,7 +268,9 @@ function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) {
? explicitDisplayId.trim() ? explicitDisplayId.trim()
: deriveDisplayId(canonicalId); : deriveDisplayId(canonicalId);
const existing = new Set(listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id))); const existing = new Set(
listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id))
);
if (!existing.has(preferred)) { if (!existing.has(preferred)) {
return preferred; return preferred;
} }
@ -310,7 +332,9 @@ function createTask(paths, input = {}) {
? input.createdBy.trim() ? input.createdBy.trim()
: undefined; : undefined;
const createdAt = const createdAt =
typeof input.createdAt === 'string' && input.createdAt.trim() ? input.createdAt.trim() : nowIso(); typeof input.createdAt === 'string' && input.createdAt.trim()
? input.createdAt.trim()
: nowIso();
const status = computeInitialStatus(paths, input, owner, blockedByIds); const status = computeInitialStatus(paths, input, owner, blockedByIds);
const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId); const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId);
@ -429,7 +453,10 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
if (task.status === status) { if (task.status === status) {
if (status === 'deleted' || status === 'in_progress') { if (status === 'deleted' || status === 'in_progress') {
task.reviewState = 'none'; task.reviewState = 'none';
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') { } else if (
status === 'pending' &&
normalizeTaskReviewState(task.reviewState) !== 'needsFix'
) {
task.reviewState = 'none'; task.reviewState = 'none';
} }
return task; return task;
@ -447,6 +474,9 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
lastInterval.completedAt = timestamp; lastInterval.completedAt = timestamp;
} }
} }
if (status === 'pending' || status === 'in_progress' || status === 'deleted') {
closeOpenReviewIntervals(task, timestamp);
}
task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined; task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
task.historyEvents = appendHistoryEvent(task.historyEvents, { task.historyEvents = appendHistoryEvent(task.historyEvents, {
@ -531,16 +561,16 @@ function addTaskComment(paths, taskRef, text, options = {}) {
const comment = { const comment = {
id: options.id || crypto.randomUUID(), id: options.id || crypto.randomUUID(),
author: author:
typeof options.author === 'string' && options.author.trim() typeof options.author === 'string' && options.author.trim() ? options.author.trim() : 'user',
? options.author.trim()
: 'user',
text, text,
createdAt: createdAt:
typeof options.createdAt === 'string' && options.createdAt.trim() typeof options.createdAt === 'string' && options.createdAt.trim()
? options.createdAt.trim() ? options.createdAt.trim()
: nowIso(), : nowIso(),
type: options.type || 'regular', type: options.type || 'regular',
...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}), ...(normalizeTaskRefs(options.taskRefs)
? { taskRefs: normalizeTaskRefs(options.taskRefs) }
: {}),
...(Array.isArray(options.attachments) && options.attachments.length > 0 ...(Array.isArray(options.attachments) && options.attachments.length > 0
? { attachments: options.attachments } ? { attachments: options.attachments }
: {}), : {}),
@ -711,10 +741,14 @@ function getTaskFreshness(task) {
function compareTasksByFreshness(a, b) { function compareTasksByFreshness(a, b) {
const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a); const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a);
if (freshnessDiff !== 0) return freshnessDiff; if (freshnessDiff !== 0) return freshnessDiff;
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { const byDisplay = String(a.displayId || a.id).localeCompare(
numeric: true, String(b.displayId || b.id),
sensitivity: 'base', undefined,
}); {
numeric: true,
sensitivity: 'base',
}
);
if (byDisplay !== 0) return byDisplay; if (byDisplay !== 0) return byDisplay;
return String(a.id).localeCompare(String(b.id), undefined, { return String(a.id).localeCompare(String(b.id), undefined, {
numeric: true, numeric: true,
@ -756,7 +790,9 @@ function formatTaskBriefing(paths, teamName, memberName) {
in_progress: activeTasks.filter((task) => task.status === 'in_progress'), in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
needs_fix: activeTasks.filter((task) => { needs_fix: activeTasks.filter((task) => {
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
return task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix'; return (
task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix'
);
}), }),
pending: activeTasks.filter((task) => { pending: activeTasks.filter((task) => {
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;

View file

@ -54,7 +54,10 @@ describe('agent-teams-controller API', () => {
const address = server.address(); const address = server.address();
return { return {
baseUrl: `http://127.0.0.1:${address.port}`, baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), close: async () =>
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
),
}; };
} }
@ -146,8 +149,12 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain('Implement carefully'); expect(briefing).toContain('Implement carefully');
expect(briefing).toContain('Working directory: /tmp/project-x'); expect(briefing).toContain('Working directory: /tmp/project-x');
expect(briefing).toContain('Task briefing for bob:'); 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(
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.'); '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('member_work_sync_status and member_work_sync_report'); expect(briefing).toContain('member_work_sync_status and member_work_sync_report');
expect(briefing).toContain( 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.' '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.'
@ -175,9 +182,7 @@ describe('agent-teams-controller API', () => {
); );
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send'); expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
expect(briefing).toContain('OpenCode bootstrap silence rule'); expect(briefing).toContain('OpenCode bootstrap silence rule');
expect(briefing).toContain( expect(briefing).toContain('If it shows no actionable tasks, stop and wait silently.');
'If it shows no actionable tasks, stop and wait silently.'
);
expect(briefing).toContain( expect(briefing).toContain(
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"' 'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
); );
@ -478,7 +483,10 @@ describe('agent-teams-controller API', () => {
owner: 'bob', owner: 'bob',
}); });
controller.tasks.completeTask(completedTask.id, 'bob'); controller.tasks.completeTask(completedTask.id, 'bob');
controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' }); controller.tasks.addTaskComment(activeTask.id, {
from: 'bob',
text: 'Resumed work with latest context.',
});
const needsFixTask = controller.tasks.createTask({ const needsFixTask = controller.tasks.createTask({
subject: 'Fix after review', subject: 'Fix after review',
owner: 'bob', owner: 'bob',
@ -517,7 +525,9 @@ describe('agent-teams-controller API', () => {
expect(ownerInbox[0].text).toContain('task_get'); expect(ownerInbox[0].text).toContain('task_get');
expect(ownerInbox[0].text).toContain('task_start'); expect(ownerInbox[0].text).toContain('task_start');
expect(ownerInbox[0].text).toContain('task_add_comment'); expect(ownerInbox[0].text).toContain('task_add_comment');
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.'); expect(ownerInbox[0].text).toContain(
'If you are idle and this task is ready to start, start it now.'
);
expect(ownerInbox[0].text).toContain( expect(ownerInbox[0].text).toContain(
'If you are busy, blocked, or still need more context, immediately add a short task comment' 'If you are busy, blocked, or still need more context, immediately add a short task comment'
); );
@ -527,7 +537,9 @@ describe('agent-teams-controller API', () => {
expect(ownerInbox[0].text).toContain('Check the migration plan first.'); expect(ownerInbox[0].text).toContain('Check the migration plan first.');
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1'); expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`); expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.'); expect(ownerInbox[3].text).toContain(
'If you are idle and this task is ready to start, start it now.'
);
expect(ownerInbox[3].text).toContain('task_add_comment'); expect(ownerInbox[3].text).toContain('task_add_comment');
const briefing = await controller.tasks.taskBriefing('bob'); const briefing = await controller.tasks.taskBriefing('bob');
@ -549,9 +561,7 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain(`#${reviewTask.displayId}`); expect(briefing).toContain(`#${reviewTask.displayId}`);
expect(briefing).toContain('reason=review_reviewer_missing'); expect(briefing).toContain('reason=review_reviewer_missing');
expect(briefing).toContain(`#${completedTask.displayId}`); expect(briefing).toContain(`#${completedTask.displayId}`);
expect(briefing).not.toContain( expect(briefing).not.toContain('Completed task description should stay out of compact rows');
'Completed task description should stay out of compact rows'
);
expect(briefing).toContain(`#${approvedTask.displayId}`); expect(briefing).toContain(`#${approvedTask.displayId}`);
expect(briefing).toContain('Counters: actionable=4, awareness=3'); expect(briefing).toContain('Counters: actionable=4, awareness=3');
}); });
@ -709,12 +719,7 @@ describe('agent-teams-controller API', () => {
const firstEvent = restored.historyEvents[0]; const firstEvent = restored.historyEvents[0];
expect(firstEvent.status).toBe('pending'); expect(firstEvent.status).toBe('pending');
const statusChanges = restored.historyEvents.slice(1).map((e) => e.to); const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
expect(statusChanges).toEqual([ expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']);
'in_progress',
'completed',
'deleted',
'pending',
]);
}); });
it('tracks owner assignment history without duplicate same-owner events', () => { it('tracks owner assignment history without duplicate same-owner events', () => {
@ -804,6 +809,10 @@ describe('agent-teams-controller API', () => {
expect(reviewEvent.from).toBe('review'); expect(reviewEvent.from).toBe('review');
expect(reviewEvent.to).toBe('review'); expect(reviewEvent.to).toBe('review');
expect(reviewEvent.actor).toBe('alice'); expect(reviewEvent.actor).toBe('alice');
expect(updatedTask.reviewIntervals).toHaveLength(1);
expect(updatedTask.reviewIntervals[0].reviewer).toBe('alice');
expect(updatedTask.reviewIntervals[0].startedAt).toBeTruthy();
expect(updatedTask.reviewIntervals[0].completedAt).toBeUndefined();
// Idempotent: calling again should also succeed without duplicate events // Idempotent: calling again should also succeed without duplicate events
const again = controller.review.startReview(task.id, { from: 'alice' }); const again = controller.review.startReview(task.id, { from: 'alice' });
@ -811,6 +820,35 @@ describe('agent-teams-controller API', () => {
const reloaded = controller.tasks.getTask(task.id); const reloaded = controller.tasks.getTask(task.id);
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started'); const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
expect(startedEvents).toHaveLength(1); expect(startedEvents).toHaveLength(1);
expect(reloaded.reviewIntervals).toHaveLength(1);
});
it('closes review intervals when review is approved or changes are requested', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const approvedTask = controller.tasks.createTask({ subject: 'Approve review', owner: 'bob' });
controller.tasks.completeTask(approvedTask.id, 'bob');
controller.review.requestReview(approvedTask.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(approvedTask.id, { from: 'alice' });
const approved = controller.review.approveReview(approvedTask.id, { from: 'alice' });
expect(approved.reviewIntervals).toHaveLength(1);
expect(approved.reviewIntervals[0].reviewer).toBe('alice');
expect(approved.reviewIntervals[0].completedAt).toBeTruthy();
const changesTask = controller.tasks.createTask({ subject: 'Request changes', owner: 'bob' });
controller.tasks.completeTask(changesTask.id, 'bob');
controller.review.requestReview(changesTask.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(changesTask.id, { from: 'alice' });
const changed = controller.review.requestChanges(changesTask.id, {
from: 'alice',
comment: 'Needs a fix.',
});
expect(changed.reviewIntervals).toHaveLength(1);
expect(changed.reviewIntervals[0].reviewer).toBe('alice');
expect(changed.reviewIntervals[0].completedAt).toBeTruthy();
}); });
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => { it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
@ -841,7 +879,10 @@ describe('agent-teams-controller API', () => {
it('uses the assigned reviewer when review_start omits from', async () => { it('uses the assigned reviewer when review_start omits from', async () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Queued for implicit reviewer', owner: 'bob' }); const task = controller.tasks.createTask({
subject: 'Queued for implicit reviewer',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob'); controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }); controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
@ -866,15 +907,23 @@ describe('agent-teams-controller API', () => {
'must be completed before approval' 'must be completed before approval'
); );
const completedTask = controller.tasks.createTask({ subject: 'Completed but not review', owner: 'bob' }); const completedTask = controller.tasks.createTask({
subject: 'Completed but not review',
owner: 'bob',
});
controller.tasks.completeTask(completedTask.id, 'bob'); controller.tasks.completeTask(completedTask.id, 'bob');
expect(() => expect(() =>
controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' }) controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' })
).toThrow('must be in review before requesting changes'); ).toThrow('must be in review before requesting changes');
const deletedTask = controller.tasks.createTask({ subject: 'Deleted review task', owner: 'bob' }); const deletedTask = controller.tasks.createTask({
subject: 'Deleted review task',
owner: 'bob',
});
controller.tasks.softDeleteTask(deletedTask.id, 'bob'); controller.tasks.softDeleteTask(deletedTask.id, 'bob');
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow('is deleted'); expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow(
'is deleted'
);
expect(() => expect(() =>
controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' }) controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' })
).toThrow('is deleted'); ).toThrow('is deleted');
@ -885,13 +934,19 @@ describe('agent-teams-controller API', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const pendingTask = controller.tasks.createTask({ subject: 'Pending implementation', owner: 'bob' }); const pendingTask = controller.tasks.createTask({
subject: 'Pending implementation',
owner: 'bob',
});
expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow( expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow(
'must be completed before starting review' 'must be completed before starting review'
); );
expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none'); expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none');
const completedTask = controller.tasks.createTask({ subject: 'Completed without review request', owner: 'bob' }); const completedTask = controller.tasks.createTask({
subject: 'Completed without review request',
owner: 'bob',
});
controller.tasks.completeTask(completedTask.id, 'bob'); controller.tasks.completeTask(completedTask.id, 'bob');
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow( expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
'must be in review before starting review' 'must be in review before starting review'
@ -907,12 +962,18 @@ describe('agent-teams-controller API', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const pendingTask = controller.tasks.createTask({ subject: 'Kanban bypass pending', owner: 'bob' }); const pendingTask = controller.tasks.createTask({
subject: 'Kanban bypass pending',
owner: 'bob',
});
expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow( expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow(
'must be completed before moving to APPROVED column' 'must be completed before moving to APPROVED column'
); );
const completedTask = controller.tasks.createTask({ subject: 'Kanban bypass completed', owner: 'bob' }); const completedTask = controller.tasks.createTask({
subject: 'Kanban bypass completed',
owner: 'bob',
});
controller.tasks.completeTask(completedTask.id, 'bob'); controller.tasks.completeTask(completedTask.id, 'bob');
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow( expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
'must be in review before moving to REVIEW column' 'must be in review before moving to REVIEW column'
@ -938,9 +999,9 @@ describe('agent-teams-controller API', () => {
controller.review.startReview(task.id, { from: 'alice' }); controller.review.startReview(task.id, { from: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' }); controller.review.approveReview(task.id, { from: 'alice' });
expect(() => controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })).toThrow( expect(() =>
'is already approved' controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })
); ).toThrow('is already approved');
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved'); expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved'); expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
}); });
@ -963,7 +1024,9 @@ describe('agent-teams-controller API', () => {
controller.review.startReview(task.id, { from: 'alice' }); controller.review.startReview(task.id, { from: 'alice' });
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review'); expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review');
expect( expect(
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_started') controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_started')
).toHaveLength(1); ).toHaveLength(1);
controller.review.approveReview(task.id, { from: 'alice' }); controller.review.approveReview(task.id, { from: 'alice' });
@ -976,7 +1039,9 @@ describe('agent-teams-controller API', () => {
expect(approvedAgain.alreadyApproved).toBe(true); expect(approvedAgain.alreadyApproved).toBe(true);
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved'); expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
expect( expect(
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_approved') controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_approved')
).toHaveLength(1); ).toHaveLength(1);
}); });
@ -1189,7 +1254,11 @@ describe('agent-teams-controller API', () => {
it('wakes task owner on regular comment from another member', () => { it('wakes task owner on regular comment from another member', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false }); const task = controller.tasks.createTask({
subject: 'Investigate',
owner: 'bob',
notifyOwner: false,
});
const commented = controller.tasks.addTaskComment(task.id, { const commented = controller.tasks.addTaskComment(task.id, {
from: 'alice', from: 'alice',
@ -1354,7 +1423,10 @@ describe('agent-teams-controller API', () => {
it('rejects task comments from unknown authors', () => { it('rejects task comments from unknown authors', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Reject unknown author', notifyOwner: false }); const task = controller.tasks.createTask({
subject: 'Reject unknown author',
notifyOwner: false,
});
expect(() => expect(() =>
controller.tasks.addTaskComment(task.id, { controller.tasks.addTaskComment(task.id, {
@ -1374,7 +1446,10 @@ describe('agent-teams-controller API', () => {
claudeDir, claudeDir,
allowUserMessageSender: false, allowUserMessageSender: false,
}); });
const task = appController.tasks.createTask({ subject: 'Reserved comment authors', notifyOwner: false }); const task = appController.tasks.createTask({
subject: 'Reserved comment authors',
notifyOwner: false,
});
const appComment = appController.tasks.addTaskComment(task.id, { const appComment = appController.tasks.addTaskComment(task.id, {
from: 'user', from: 'user',
@ -1803,11 +1878,19 @@ describe('agent-teams-controller API', () => {
); );
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' }); const leadOwnedTask = controller.tasks.createTask({
subject: 'Lead alias owner',
owner: 'lead',
});
expect(leadOwnedTask.owner).toBe('leadbot'); expect(leadOwnedTask.owner).toBe('leadbot');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false); expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
false
);
const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' }); const reassignedTask = controller.tasks.createTask({
subject: 'Reassign alias owner',
owner: 'bob',
});
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot'); expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
controller.kanban.addReviewer('lead'); controller.kanban.addReviewer('lead');
@ -1822,8 +1905,12 @@ describe('agent-teams-controller API', () => {
.historyEvents.filter((event) => event.type === 'review_requested') .historyEvents.filter((event) => event.type === 'review_requested')
.at(-1); .at(-1);
expect(requested.reviewer).toBe('leadbot'); expect(requested.reviewer).toBe('leadbot');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true); expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false); true
);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
false
);
}); });
it('rejects task_briefing for unknown members', async () => { it('rejects task_briefing for unknown members', async () => {
@ -1879,7 +1966,10 @@ describe('agent-teams-controller API', () => {
it('clears kanban tasks and column order when task_set_status deletes a review task', () => { it('clears kanban tasks and column order when task_set_status deletes a review task', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Generic status delete cleanup', owner: 'bob' }); const task = controller.tasks.createTask({
subject: 'Generic status delete cleanup',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob'); controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }); controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
@ -1956,7 +2046,10 @@ describe('agent-teams-controller API', () => {
it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => { it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Do not unapprove directly', owner: 'bob' }); const task = controller.tasks.createTask({
subject: 'Do not unapprove directly',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob'); controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' }); controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
@ -1980,11 +2073,13 @@ describe('agent-teams-controller API', () => {
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' }); const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' });
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow('Unknown task owner: boob'); expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow(
controller.tasks.completeTask(task.id, 'bob'); 'Unknown task owner: boob'
expect(() => controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })).toThrow(
'Unknown reviewer: boob'
); );
controller.tasks.completeTask(task.id, 'bob');
expect(() =>
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })
).toThrow('Unknown reviewer: boob');
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`); const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8')); const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
@ -2006,8 +2101,12 @@ describe('agent-teams-controller API', () => {
controller.tasks.softDeleteTask(task.id, 'bob'); controller.tasks.softDeleteTask(task.id, 'bob');
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow('use task_restore before starting work'); expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow(
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow('use task_restore before changing status'); 'use task_restore before starting work'
);
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow(
'use task_restore before changing status'
);
expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow( expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
'use task_restore before changing status' 'use task_restore before changing status'
); );
@ -2020,7 +2119,10 @@ describe('agent-teams-controller API', () => {
it('rejects task_restore for non-deleted tasks', () => { it('rejects task_restore for non-deleted tasks', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' }); const task = controller.tasks.createTask({
subject: 'Approved task must stay approved',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob'); controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' }); controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
@ -2047,7 +2149,9 @@ describe('agent-teams-controller API', () => {
delete state.tasks[task.id]; delete state.tasks[task.id];
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2)); fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)).toContain(task.id); expect(
controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)
).toContain(task.id);
expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0); expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0);
}); });
@ -2090,7 +2194,10 @@ describe('agent-teams-controller API', () => {
config.members.push({ name: 'carol', role: 'reviewer' }); config.members.push({ name: 'carol', role: 'reviewer' });
fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Repair mismatched reviewer actor', owner: 'bob' }); const task = controller.tasks.createTask({
subject: 'Repair mismatched reviewer actor',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob'); controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' }); controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
@ -2124,7 +2231,11 @@ describe('agent-teams-controller API', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
const longSubject = `Long subject ${'x'.repeat(5000)}`; const longSubject = `Long subject ${'x'.repeat(5000)}`;
const task = controller.tasks.createTask({ subject: longSubject, owner: 'bob', notifyOwner: false }); const task = controller.tasks.createTask({
subject: longSubject,
owner: 'bob',
notifyOwner: false,
});
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json'); const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
fs.writeFileSync( fs.writeFileSync(
kanbanPath, kanbanPath,
@ -2147,7 +2258,11 @@ describe('agent-teams-controller API', () => {
'utf8' 'utf8'
); );
for (let index = 0; index < 30; index += 1) { for (let index = 0; index < 30; index += 1) {
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`), '{ bad json', 'utf8'); fs.writeFileSync(
path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`),
'{ bad json',
'utf8'
);
} }
const briefing = await controller.tasks.leadBriefing(); const briefing = await controller.tasks.leadBriefing();

View file

@ -0,0 +1,189 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
<defs>
<radialGradient id="bg" cx="50%" cy="48%" r="70%">
<stop offset="0%" stop-color="#111827"/>
<stop offset="58%" stop-color="#08091a"/>
<stop offset="100%" stop-color="#050510"/>
</radialGradient>
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="10" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<style>
.title{font:700 30px Inter,Arial,sans-serif;fill:#f8fafc;letter-spacing:0}
.sub{font:500 18px Inter,Arial,sans-serif;fill:#94a3b8;letter-spacing:0}
.edge{fill:none;stroke:#1d4ed8;stroke-width:2;opacity:.42}
.card{fill:#090b1d;stroke:#26314f;stroke-width:1.5}
.lead{fill:#142006;stroke:#9bef13;stroke-width:2;filter:url(#glow)}
.label{font:700 18px Inter,Arial,sans-serif;fill:#f8fafc;text-anchor:middle;letter-spacing:0}
.small{font:500 14px Inter,Arial,sans-serif;fill:#94a3b8;text-anchor:middle;letter-spacing:0}
.badge{font:700 12px Inter,Arial,sans-serif;fill:#020617;text-anchor:middle;letter-spacing:0}
</style>
<rect width="100%" height="100%" fill="url(#bg)"/>
<circle cx="0" cy="0" r="0.60" fill="#dbeafe" opacity="0.25"/>
<circle cx="97" cy="211" r="1.54" fill="#dbeafe" opacity="0.56"/>
<circle cx="194" cy="422" r="1.49" fill="#dbeafe" opacity="0.32"/>
<circle cx="291" cy="633" r="1.43" fill="#dbeafe" opacity="0.63"/>
<circle cx="388" cy="844" r="1.38" fill="#dbeafe" opacity="0.39"/>
<circle cx="485" cy="55" r="1.32" fill="#dbeafe" opacity="0.70"/>
<circle cx="582" cy="266" r="1.27" fill="#dbeafe" opacity="0.46"/>
<circle cx="679" cy="477" r="1.21" fill="#dbeafe" opacity="0.77"/>
<circle cx="776" cy="688" r="1.16" fill="#dbeafe" opacity="0.53"/>
<circle cx="873" cy="899" r="1.10" fill="#dbeafe" opacity="0.29"/>
<circle cx="970" cy="110" r="1.04" fill="#dbeafe" opacity="0.60"/>
<circle cx="1067" cy="321" r="0.99" fill="#dbeafe" opacity="0.36"/>
<circle cx="1164" cy="532" r="0.93" fill="#dbeafe" opacity="0.67"/>
<circle cx="1261" cy="743" r="0.88" fill="#dbeafe" opacity="0.43"/>
<circle cx="1358" cy="954" r="0.82" fill="#dbeafe" opacity="0.74"/>
<circle cx="55" cy="165" r="0.77" fill="#dbeafe" opacity="0.50"/>
<circle cx="152" cy="376" r="0.71" fill="#dbeafe" opacity="0.26"/>
<circle cx="249" cy="587" r="0.66" fill="#dbeafe" opacity="0.57"/>
<circle cx="346" cy="798" r="0.60" fill="#dbeafe" opacity="0.33"/>
<circle cx="443" cy="9" r="1.54" fill="#dbeafe" opacity="0.64"/>
<circle cx="540" cy="220" r="1.49" fill="#dbeafe" opacity="0.40"/>
<circle cx="637" cy="431" r="1.43" fill="#dbeafe" opacity="0.71"/>
<circle cx="734" cy="642" r="1.38" fill="#dbeafe" opacity="0.47"/>
<circle cx="831" cy="853" r="1.32" fill="#dbeafe" opacity="0.78"/>
<circle cx="928" cy="64" r="1.27" fill="#dbeafe" opacity="0.54"/>
<circle cx="1025" cy="275" r="1.21" fill="#dbeafe" opacity="0.30"/>
<circle cx="1122" cy="486" r="1.16" fill="#dbeafe" opacity="0.61"/>
<circle cx="1219" cy="697" r="1.10" fill="#dbeafe" opacity="0.37"/>
<circle cx="1316" cy="908" r="1.04" fill="#dbeafe" opacity="0.68"/>
<circle cx="13" cy="119" r="0.99" fill="#dbeafe" opacity="0.44"/>
<circle cx="110" cy="330" r="0.93" fill="#dbeafe" opacity="0.75"/>
<circle cx="207" cy="541" r="0.88" fill="#dbeafe" opacity="0.51"/>
<circle cx="304" cy="752" r="0.82" fill="#dbeafe" opacity="0.27"/>
<circle cx="401" cy="963" r="0.77" fill="#dbeafe" opacity="0.58"/>
<circle cx="498" cy="174" r="0.71" fill="#dbeafe" opacity="0.34"/>
<circle cx="595" cy="385" r="0.66" fill="#dbeafe" opacity="0.65"/>
<circle cx="692" cy="596" r="0.60" fill="#dbeafe" opacity="0.41"/>
<circle cx="789" cy="807" r="1.54" fill="#dbeafe" opacity="0.72"/>
<circle cx="886" cy="18" r="1.49" fill="#dbeafe" opacity="0.48"/>
<circle cx="983" cy="229" r="1.43" fill="#dbeafe" opacity="0.79"/>
<circle cx="1080" cy="440" r="1.38" fill="#dbeafe" opacity="0.55"/>
<circle cx="1177" cy="651" r="1.32" fill="#dbeafe" opacity="0.31"/>
<circle cx="1274" cy="862" r="1.27" fill="#dbeafe" opacity="0.62"/>
<circle cx="1371" cy="73" r="1.21" fill="#dbeafe" opacity="0.38"/>
<circle cx="68" cy="284" r="1.16" fill="#dbeafe" opacity="0.69"/>
<circle cx="165" cy="495" r="1.10" fill="#dbeafe" opacity="0.45"/>
<circle cx="262" cy="706" r="1.04" fill="#dbeafe" opacity="0.76"/>
<circle cx="359" cy="917" r="0.99" fill="#dbeafe" opacity="0.52"/>
<circle cx="456" cy="128" r="0.93" fill="#dbeafe" opacity="0.28"/>
<circle cx="553" cy="339" r="0.88" fill="#dbeafe" opacity="0.59"/>
<circle cx="650" cy="550" r="0.82" fill="#dbeafe" opacity="0.35"/>
<circle cx="747" cy="761" r="0.77" fill="#dbeafe" opacity="0.66"/>
<circle cx="844" cy="972" r="0.71" fill="#dbeafe" opacity="0.42"/>
<circle cx="941" cy="183" r="0.66" fill="#dbeafe" opacity="0.73"/>
<circle cx="1038" cy="394" r="0.60" fill="#dbeafe" opacity="0.49"/>
<circle cx="1135" cy="605" r="1.54" fill="#dbeafe" opacity="0.25"/>
<circle cx="1232" cy="816" r="1.49" fill="#dbeafe" opacity="0.56"/>
<circle cx="1329" cy="27" r="1.43" fill="#dbeafe" opacity="0.32"/>
<circle cx="26" cy="238" r="1.38" fill="#dbeafe" opacity="0.63"/>
<circle cx="123" cy="449" r="1.32" fill="#dbeafe" opacity="0.39"/>
<circle cx="220" cy="660" r="1.27" fill="#dbeafe" opacity="0.70"/>
<circle cx="317" cy="871" r="1.21" fill="#dbeafe" opacity="0.46"/>
<circle cx="414" cy="82" r="1.16" fill="#dbeafe" opacity="0.77"/>
<circle cx="511" cy="293" r="1.10" fill="#dbeafe" opacity="0.53"/>
<circle cx="608" cy="504" r="1.04" fill="#dbeafe" opacity="0.29"/>
<circle cx="705" cy="715" r="0.99" fill="#dbeafe" opacity="0.60"/>
<circle cx="802" cy="926" r="0.93" fill="#dbeafe" opacity="0.36"/>
<circle cx="899" cy="137" r="0.88" fill="#dbeafe" opacity="0.67"/>
<circle cx="996" cy="348" r="0.82" fill="#dbeafe" opacity="0.43"/>
<circle cx="1093" cy="559" r="0.77" fill="#dbeafe" opacity="0.74"/>
<circle cx="1190" cy="770" r="0.71" fill="#dbeafe" opacity="0.50"/>
<circle cx="1287" cy="981" r="0.66" fill="#dbeafe" opacity="0.26"/>
<circle cx="1384" cy="192" r="0.60" fill="#dbeafe" opacity="0.57"/>
<circle cx="81" cy="403" r="1.54" fill="#dbeafe" opacity="0.33"/>
<circle cx="178" cy="614" r="1.49" fill="#dbeafe" opacity="0.64"/>
<circle cx="275" cy="825" r="1.43" fill="#dbeafe" opacity="0.40"/>
<circle cx="372" cy="36" r="1.38" fill="#dbeafe" opacity="0.71"/>
<circle cx="469" cy="247" r="1.32" fill="#dbeafe" opacity="0.47"/>
<circle cx="566" cy="458" r="1.27" fill="#dbeafe" opacity="0.78"/>
<circle cx="663" cy="669" r="1.21" fill="#dbeafe" opacity="0.54"/>
<circle cx="760" cy="880" r="1.16" fill="#dbeafe" opacity="0.30"/>
<circle cx="857" cy="91" r="1.10" fill="#dbeafe" opacity="0.61"/>
<circle cx="954" cy="302" r="1.04" fill="#dbeafe" opacity="0.37"/>
<circle cx="1051" cy="513" r="0.99" fill="#dbeafe" opacity="0.68"/>
<circle cx="1148" cy="724" r="0.93" fill="#dbeafe" opacity="0.44"/>
<circle cx="1245" cy="935" r="0.88" fill="#dbeafe" opacity="0.75"/>
<circle cx="1342" cy="146" r="0.82" fill="#dbeafe" opacity="0.51"/>
<circle cx="39" cy="357" r="0.77" fill="#dbeafe" opacity="0.27"/>
<circle cx="136" cy="568" r="0.71" fill="#dbeafe" opacity="0.58"/>
<circle cx="233" cy="779" r="0.66" fill="#dbeafe" opacity="0.34"/>
<circle cx="330" cy="990" r="0.60" fill="#dbeafe" opacity="0.65"/>
<circle cx="427" cy="201" r="1.54" fill="#dbeafe" opacity="0.41"/>
<circle cx="524" cy="412" r="1.49" fill="#dbeafe" opacity="0.72"/>
<circle cx="621" cy="623" r="1.43" fill="#dbeafe" opacity="0.48"/>
<circle cx="718" cy="834" r="1.38" fill="#dbeafe" opacity="0.79"/>
<circle cx="815" cy="45" r="1.32" fill="#dbeafe" opacity="0.55"/>
<circle cx="912" cy="256" r="1.27" fill="#dbeafe" opacity="0.31"/>
<circle cx="1009" cy="467" r="1.21" fill="#dbeafe" opacity="0.62"/>
<circle cx="1106" cy="678" r="1.16" fill="#dbeafe" opacity="0.38"/>
<circle cx="1203" cy="889" r="1.10" fill="#dbeafe" opacity="0.69"/>
<circle cx="1300" cy="100" r="1.04" fill="#dbeafe" opacity="0.45"/>
<circle cx="1397" cy="311" r="0.99" fill="#dbeafe" opacity="0.76"/>
<circle cx="94" cy="522" r="0.93" fill="#dbeafe" opacity="0.52"/>
<circle cx="191" cy="733" r="0.88" fill="#dbeafe" opacity="0.28"/>
<circle cx="288" cy="944" r="0.82" fill="#dbeafe" opacity="0.59"/>
<circle cx="385" cy="155" r="0.77" fill="#dbeafe" opacity="0.35"/>
<circle cx="482" cy="366" r="0.71" fill="#dbeafe" opacity="0.66"/>
<circle cx="579" cy="577" r="0.66" fill="#dbeafe" opacity="0.42"/>
<circle cx="676" cy="788" r="0.60" fill="#dbeafe" opacity="0.73"/>
<circle cx="773" cy="999" r="1.54" fill="#dbeafe" opacity="0.49"/>
<circle cx="870" cy="210" r="1.49" fill="#dbeafe" opacity="0.25"/>
<circle cx="967" cy="421" r="1.43" fill="#dbeafe" opacity="0.56"/>
<circle cx="1064" cy="632" r="1.38" fill="#dbeafe" opacity="0.32"/>
<circle cx="1161" cy="843" r="1.32" fill="#dbeafe" opacity="0.63"/>
<circle cx="1258" cy="54" r="1.27" fill="#dbeafe" opacity="0.39"/>
<circle cx="1355" cy="265" r="1.21" fill="#dbeafe" opacity="0.70"/>
<circle cx="52" cy="476" r="1.16" fill="#dbeafe" opacity="0.46"/>
<circle cx="149" cy="687" r="1.10" fill="#dbeafe" opacity="0.77"/>
<circle cx="246" cy="898" r="1.04" fill="#dbeafe" opacity="0.53"/>
<circle cx="343" cy="109" r="0.99" fill="#dbeafe" opacity="0.29"/>
<text x="70" y="76" class="title">4 participants - current radial layout</text>
<text x="70" y="110" class="sub">Strict small-team preset: top / right / bottom / left around Lead</text>
<path d="M 700 500 C 700 500, 700 235, 700 235" class="edge"/>
<path d="M 700 500 C 895 500, 895 500, 1090 500" class="edge"/>
<path d="M 700 500 C 700 500, 700 765, 700 765" class="edge"/>
<path d="M 700 500 C 505 500, 505 500, 310 500" class="edge"/>
<rect x="615" y="457" width="170" height="86" rx="16" class="lead"/>
<text x="700" y="496" class="label">Lead</text>
<text x="700" y="524" class="small">center reserved zone</text>
<rect x="570" y="160" width="260" height="150" rx="10" class="card"/>
<circle cx="700" cy="187" r="20" fill="#38bdf8" opacity=".18" filter="url(#glow)"/>
<circle cx="700" cy="187" r="14" fill="#38bdf8"/>
<text x="700" y="227" class="label">Participant 1</text>
<text x="700" y="255" class="small">top side</text>
<rect x="658" y="278" width="84" height="24" rx="6" fill="#38bdf8"/>
<text x="700" y="295" class="badge">slot 1</text>
<rect x="960" y="425" width="260" height="150" rx="10" class="card"/>
<circle cx="1090" cy="452" r="20" fill="#facc15" opacity=".18" filter="url(#glow)"/>
<circle cx="1090" cy="452" r="14" fill="#facc15"/>
<text x="1090" y="492" class="label">Participant 2</text>
<text x="1090" y="520" class="small">right side</text>
<rect x="1048" y="543" width="84" height="24" rx="6" fill="#facc15"/>
<text x="1090" y="560" class="badge">slot 2</text>
<rect x="570" y="690" width="260" height="150" rx="10" class="card"/>
<circle cx="700" cy="717" r="20" fill="#ef4444" opacity=".18" filter="url(#glow)"/>
<circle cx="700" cy="717" r="14" fill="#ef4444"/>
<text x="700" y="757" class="label">Participant 3</text>
<text x="700" y="785" class="small">bottom side</text>
<rect x="658" y="808" width="84" height="24" rx="6" fill="#ef4444"/>
<text x="700" y="825" class="badge">slot 3</text>
<rect x="180" y="425" width="260" height="150" rx="10" class="card"/>
<circle cx="310" cy="452" r="20" fill="#a78bfa" opacity=".18" filter="url(#glow)"/>
<circle cx="310" cy="452" r="14" fill="#a78bfa"/>
<text x="310" y="492" class="label">Participant 4</text>
<text x="310" y="520" class="small">left side</text>
<rect x="268" y="543" width="84" height="24" rx="6" fill="#a78bfa"/>
<text x="310" y="560" class="badge">slot 4</text>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,125 @@
<svg width="1800" height="1050" viewBox="0 0 1800 1050" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
<stop offset="0%" stop-color="#111733"/>
<stop offset="55%" stop-color="#090d20"/>
<stop offset="100%" stop-color="#050717"/>
</radialGradient>
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="10" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.25 0 0 0 0 0.75 0 0 0 0 1 0 0 0 0.65 0"/>
<feBlend in="SourceGraphic"/>
</filter>
<style>
.panel-title { font: 700 28px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #e6f0ff; letter-spacing: 0; }
.panel-subtitle { font: 500 15px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
.label { font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #eff6ff; letter-spacing: 0; }
.role { font: 500 10px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8ea0bd; letter-spacing: 0; }
.hint { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
.card { fill: rgba(11, 16, 36, 0.84); stroke: rgba(148, 163, 184, 0.22); stroke-width: 1; }
.slot { fill: rgba(59, 130, 246, 0.035); stroke: rgba(125, 211, 252, 0.16); stroke-width: 1; stroke-dasharray: 5 7; }
.edge { stroke: rgba(96, 165, 250, 0.18); stroke-width: 2; }
.row-guide { stroke: rgba(148, 163, 184, 0.14); stroke-width: 1; stroke-dasharray: 6 10; }
.divider { stroke: rgba(148, 163, 184, 0.16); stroke-width: 1; }
</style>
</defs>
<rect width="1800" height="1050" fill="url(#bg)"/>
<g opacity="0.85">
<circle cx="108" cy="102" r="1.5" fill="#94a3b8"/>
<circle cx="238" cy="860" r="1.2" fill="#64748b"/>
<circle cx="385" cy="145" r="1.3" fill="#94a3b8"/>
<circle cx="520" cy="950" r="1.4" fill="#cbd5e1"/>
<circle cx="690" cy="372" r="1.1" fill="#94a3b8"/>
<circle cx="823" cy="787" r="1.2" fill="#64748b"/>
<circle cx="982" cy="210" r="1.1" fill="#94a3b8"/>
<circle cx="1112" cy="902" r="1.5" fill="#cbd5e1"/>
<circle cx="1286" cy="118" r="1.2" fill="#94a3b8"/>
<circle cx="1458" cy="730" r="1.3" fill="#64748b"/>
<circle cx="1632" cy="340" r="1.5" fill="#cbd5e1"/>
<circle cx="1748" cy="934" r="1.1" fill="#94a3b8"/>
</g>
<line x1="900" y1="70" x2="900" y2="980" class="divider"/>
<text x="70" y="72" class="panel-title">8 participants</text>
<text x="70" y="101" class="panel-subtitle">3 top / 2 at lead level / 3 bottom</text>
<text x="970" y="72" class="panel-title">12 participants</text>
<text x="970" y="101" class="panel-subtitle">4 top / 2 + lead + 2 middle / 4 bottom</text>
<g id="eight-layout">
<line x1="110" y1="245" x2="790" y2="245" class="row-guide"/>
<line x1="110" y1="525" x2="790" y2="525" class="row-guide"/>
<line x1="110" y1="805" x2="790" y2="805" class="row-guide"/>
<text x="118" y="232" class="hint">top row</text>
<text x="118" y="512" class="hint">lead row</text>
<text x="118" y="792" class="hint">bottom row</text>
<path d="M450 525 C370 430 305 330 245 245" class="edge"/>
<path d="M450 525 C450 425 450 335 450 245" class="edge"/>
<path d="M450 525 C530 430 595 330 655 245" class="edge"/>
<path d="M450 525 C360 515 285 515 200 525" class="edge"/>
<path d="M450 525 C540 515 615 515 700 525" class="edge"/>
<path d="M450 525 C370 620 305 720 245 805" class="edge"/>
<path d="M450 525 C450 625 450 715 450 805" class="edge"/>
<path d="M450 525 C530 620 595 720 655 805" class="edge"/>
<g transform="translate(450 525)">
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
<circle r="17" fill="#84cc16"/>
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
</g>
<g transform="translate(245 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text><text y="34" text-anchor="middle" class="role">reviewer</text></g>
<g transform="translate(450 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text><text y="34" text-anchor="middle" class="role">developer</text></g>
<g transform="translate(655 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text><text y="34" text-anchor="middle" class="role">developer</text></g>
<g transform="translate(200 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text><text y="34" text-anchor="middle" class="role">developer</text></g>
<g transform="translate(700 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text><text y="34" text-anchor="middle" class="role">assistant</text></g>
<g transform="translate(245 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text><text y="34" text-anchor="middle" class="role">developer</text></g>
<g transform="translate(450 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text><text y="34" text-anchor="middle" class="role">qa</text></g>
<g transform="translate(655 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text><text y="34" text-anchor="middle" class="role">ops</text></g>
</g>
<g id="twelve-layout">
<line x1="970" y1="245" x2="1730" y2="245" class="row-guide"/>
<line x1="970" y1="525" x2="1730" y2="525" class="row-guide"/>
<line x1="970" y1="805" x2="1730" y2="805" class="row-guide"/>
<text x="978" y="232" class="hint">top row</text>
<text x="978" y="512" class="hint">lead row</text>
<text x="978" y="792" class="hint">bottom row</text>
<path d="M1350 525 C1245 425 1135 330 1030 245" class="edge"/>
<path d="M1350 525 C1295 420 1270 335 1243 245" class="edge"/>
<path d="M1350 525 C1405 420 1430 335 1457 245" class="edge"/>
<path d="M1350 525 C1455 425 1565 330 1670 245" class="edge"/>
<path d="M1350 525 C1235 515 1135 515 1030 525" class="edge"/>
<path d="M1350 525 C1270 520 1235 520 1210 525" class="edge"/>
<path d="M1350 525 C1430 520 1465 520 1490 525" class="edge"/>
<path d="M1350 525 C1465 515 1565 515 1670 525" class="edge"/>
<path d="M1350 525 C1245 625 1135 720 1030 805" class="edge"/>
<path d="M1350 525 C1295 630 1270 715 1243 805" class="edge"/>
<path d="M1350 525 C1405 630 1430 715 1457 805" class="edge"/>
<path d="M1350 525 C1455 625 1565 720 1670 805" class="edge"/>
<g transform="translate(1350 525)">
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
<circle r="17" fill="#84cc16"/>
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
</g>
<g transform="translate(1030 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text></g>
<g transform="translate(1243 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text></g>
<g transform="translate(1457 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text></g>
<g transform="translate(1670 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text></g>
<g transform="translate(1030 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text></g>
<g transform="translate(1210 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text></g>
<g transform="translate(1490 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text></g>
<g transform="translate(1670 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text></g>
<g transform="translate(1030 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#a855f7"/><text y="17" text-anchor="middle" class="label">ivy</text></g>
<g transform="translate(1243 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f43f5e"/><text y="17" text-anchor="middle" class="label">rex</text></g>
<g transform="translate(1457 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#38bdf8"/><text y="17" text-anchor="middle" class="label">zoe</text></g>
<g transform="translate(1670 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#84cc16"/><text y="17" text-anchor="middle" class="label">sam</text></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -132,6 +132,7 @@ export default defineConfig({
} }
}, },
renderer: { renderer: {
cacheDir: resolve(__dirname, 'node_modules/.vite/electron-renderer'),
optimizeDeps: { optimizeDeps: {
include: ['@codemirror/language-data'], include: ['@codemirror/language-data'],
exclude: ['@claude-teams/agent-graph'] exclude: ['@claude-teams/agent-graph']

View file

@ -50,6 +50,8 @@ export interface SlotFrame {
taskColumnCount: number; taskColumnCount: number;
} }
type OwnerSlotLayoutKind = 'radial-sector' | 'row-orbit' | 'grid-under-lead';
export interface StableSlotLayoutSnapshot { export interface StableSlotLayoutSnapshot {
version: GraphLayoutPort['version']; version: GraphLayoutPort['version'];
teamName: string; teamName: string;
@ -61,6 +63,7 @@ export interface StableSlotLayoutSnapshot {
launchAnchor: { x: number; y: number } | null; launchAnchor: { x: number; y: number } | null;
leadCentralReservedBlock: StableRect; leadCentralReservedBlock: StableRect;
runtimeCentralExclusion: StableRect; runtimeCentralExclusion: StableRect;
ownerSlotLayoutKind: OwnerSlotLayoutKind;
centralCollisionRects: StableRect[]; centralCollisionRects: StableRect[];
memberSlotFrames: SlotFrame[]; memberSlotFrames: SlotFrame[];
memberSlotFrameByOwnerId: Map<string, SlotFrame>; memberSlotFrameByOwnerId: Map<string, SlotFrame>;
@ -104,6 +107,20 @@ interface RingLayoutState {
type RingLayoutStateMap = ReadonlyMap<string, RingLayoutState>; type RingLayoutStateMap = ReadonlyMap<string, RingLayoutState>;
interface PlannedMemberSlotLayout {
frames: SlotFrame[];
kind: OwnerSlotLayoutKind;
}
interface RowOrbitSlotConfig {
footprint: OwnerFootprint;
assignment: GraphOwnerSlotAssignment;
rowIndex: number;
columnIndex: number;
columnCount: number;
band: 'top' | 'middle' | 'bottom';
}
const SLOT_GEOMETRY = { const SLOT_GEOMETRY = {
...STABLE_SLOT_GEOMETRY, ...STABLE_SLOT_GEOMETRY,
activityColumnHeight: activityColumnHeight:
@ -129,11 +146,19 @@ const PROCESS_RAIL_NODE_GAP = 42;
const PROCESS_RAIL_NODE_FOOTPRINT = 28; const PROCESS_RAIL_NODE_FOOTPRINT = 28;
const GEOMETRY_EPSILON = 0.001; const GEOMETRY_EPSILON = 0.001;
const FEED_HEADER_BOTTOM_GAP = 4; const FEED_HEADER_BOTTOM_GAP = 4;
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24; const STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS = 96;
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7; const STRICT_SMALL_TEAM_RADIUS_EPSILON = 0.5;
const GRID_UNDER_LEAD_COLUMN_COUNT = 2; const STRICT_SMALL_TEAM_RADIUS_STEP = 24;
const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2;
const GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT = 3;
const GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT = 6;
const GRID_UNDER_LEAD_LEAD_GAP = 77.7; const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
const GRID_UNDER_LEAD_ROW_GAP = 77.7; const GRID_UNDER_LEAD_ROW_GAP = 77.7;
const ROW_ORBIT_MIN_OWNER_COUNT = 6;
const ROW_ORBIT_MAX_OWNER_COUNT = 12;
const ROW_ORBIT_HORIZONTAL_GAP = Math.max(112, STABLE_SLOT_GEOMETRY.slotHorizontalGap);
const ROW_ORBIT_VERTICAL_GAP = Math.max(144, GRID_UNDER_LEAD_ROW_GAP);
const ROW_ORBIT_CENTRAL_GAP = 160;
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray< const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
@ -159,14 +184,48 @@ const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } }, { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } },
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } }, { assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } },
], ],
[
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] },
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] },
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] },
{ assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] },
{ assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] },
],
[
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] },
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] },
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] },
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: SECTOR_VECTORS[3] },
{ assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] },
{ assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] },
],
]; ];
const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> =
SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment)); SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment));
const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map(
SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) => const ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT: Readonly<Record<number, readonly number[]>> = {
layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const) 6: [3, 2, 3],
) 7: [3, 2, 2],
8: [3, 2, 3],
9: [3, 2, 2, 2],
10: [3, 2, 2, 3],
11: [3, 3, 2, 3],
12: [3, 3, 3, 3],
};
const ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT: Readonly<
Record<number, readonly GraphOwnerSlotAssignment[]>
> = Object.fromEntries(
Object.entries(ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT).map(([ownerCount, rowCounts]) => [
Number(ownerCount),
rowCounts.flatMap((columnCount, rowIndex) =>
Array.from({ length: columnCount }, (_, columnIndex) => ({
ringIndex: rowIndex,
sectorIndex: columnIndex,
}))
),
])
); );
export function buildStableSlotLayoutSnapshot({ export function buildStableSlotLayoutSnapshot({
@ -201,10 +260,14 @@ export function buildStableSlotLayoutSnapshot({
SLOT_GEOMETRY.centralPadding SLOT_GEOMETRY.centralPadding
); );
const memberSlotFrames = const memberSlotLayout =
(layout?.mode ?? 'radial') === 'grid-under-lead' (layout?.mode ?? 'radial') === 'grid-under-lead'
? planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects) ? {
frames: planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects),
kind: 'grid-under-lead' as const,
}
: planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout); : planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout);
const memberSlotFrames = memberSlotLayout.frames;
const memberSlotFrameByOwnerId = new Map( const memberSlotFrameByOwnerId = new Map(
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const) memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
); );
@ -223,6 +286,7 @@ export function buildStableSlotLayoutSnapshot({
launchAnchor: null, launchAnchor: null,
leadCentralReservedBlock, leadCentralReservedBlock,
runtimeCentralExclusion, runtimeCentralExclusion,
ownerSlotLayoutKind: memberSlotLayout.kind,
centralCollisionRects, centralCollisionRects,
memberSlotFrames, memberSlotFrames,
memberSlotFrameByOwnerId, memberSlotFrameByOwnerId,
@ -457,6 +521,21 @@ export function resolveNearestSlotAssignment(args: {
return null; return null;
} }
if (args.snapshot.ownerSlotLayoutKind === 'row-orbit') {
const rowOrbitCandidate = resolveNearestRowOrbitSlotAssignment({
ownerId: args.ownerId,
ownerX: args.ownerX,
ownerY: args.ownerY,
currentFrame,
ownerFootprints: allFootprints,
snapshot: args.snapshot,
layout: args.layout,
});
if (rowOrbitCandidate) {
return rowOrbitCandidate;
}
}
const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({ const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({
ownerId: args.ownerId, ownerId: args.ownerId,
ownerX: args.ownerX, ownerX: args.ownerX,
@ -568,11 +647,119 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
return null; return null;
} }
return resolveNearestExistingFrameSlotAssignment({
ownerId: args.ownerId,
ownerX: args.ownerX,
ownerY: args.ownerY,
currentFrame: args.currentFrame,
frames: strictFrames,
});
}
function resolveNearestRowOrbitSlotAssignment(args: {
ownerId: string;
ownerX: number;
ownerY: number;
currentFrame: SlotFrame;
ownerFootprints: readonly OwnerFootprint[];
snapshot: StableSlotLayoutSnapshot;
layout?: GraphLayoutPort;
}): NearestSlotAssignmentResult | null {
const allowedAssignments = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[args.ownerFootprints.length];
if (!allowedAssignments || allowedAssignments.length < args.ownerFootprints.length) {
return null;
}
const baseAssignments = Object.fromEntries(
args.snapshot.memberSlotFrames.map((frame) => [
frame.ownerId,
{
ringIndex: frame.ringIndex,
sectorIndex: frame.sectorIndex,
},
])
);
let best: RankedNearestSlotAssignmentResult | null = null;
for (const assignment of allowedAssignments) {
const occupiedFrame = args.snapshot.memberSlotFrames.find(
(frame) =>
frame.ownerId !== args.ownerId &&
frame.ringIndex === assignment.ringIndex &&
frame.sectorIndex === assignment.sectorIndex
);
const simulatedAssignments: Record<string, GraphOwnerSlotAssignment> = {
...baseAssignments,
[args.ownerId]: assignment,
};
if (occupiedFrame) {
simulatedAssignments[occupiedFrame.ownerId] = {
ringIndex: args.currentFrame.ringIndex,
sectorIndex: args.currentFrame.sectorIndex,
};
}
const frames = planRowOrbitOwnerSlots(
args.ownerFootprints,
args.snapshot.centralCollisionRects,
args.snapshot.runtimeCentralExclusion,
{
version: args.layout?.version ?? 'stable-slots-v1',
mode: args.layout?.mode ?? 'radial',
ownerOrder:
args.layout?.ownerOrder ?? args.ownerFootprints.map((footprint) => footprint.ownerId),
slotAssignments: simulatedAssignments,
}
);
const previewFrame = frames?.find((frame) => frame.ownerId === args.ownerId);
if (!previewFrame) {
continue;
}
const dx = previewFrame.ownerX - args.ownerX;
const dy = previewFrame.ownerY - args.ownerY;
const candidate: RankedNearestSlotAssignmentResult = {
assignment,
displacedOwnerId: occupiedFrame?.ownerId,
displacedAssignment: occupiedFrame
? {
ringIndex: args.currentFrame.ringIndex,
sectorIndex: args.currentFrame.sectorIndex,
}
: undefined,
previewOwnerX: previewFrame.ownerX,
previewOwnerY: previewFrame.ownerY,
distanceSquared: dx * dx + dy * dy,
};
if (!best || candidate.distanceSquared < best.distanceSquared) {
best = candidate;
}
}
return best
? {
assignment: best.assignment,
displacedOwnerId: best.displacedOwnerId,
displacedAssignment: best.displacedAssignment,
previewOwnerX: best.previewOwnerX,
previewOwnerY: best.previewOwnerY,
}
: null;
}
function resolveNearestExistingFrameSlotAssignment(args: {
ownerId: string;
ownerX: number;
ownerY: number;
currentFrame: SlotFrame;
frames: readonly SlotFrame[];
}): NearestSlotAssignmentResult | null {
let best: { let best: {
frame: SlotFrame; frame: SlotFrame;
distanceSquared: number; distanceSquared: number;
} | null = null; } | null = null;
for (const frame of strictFrames) { for (const frame of args.frames) {
const dx = frame.ownerX - args.ownerX; const dx = frame.ownerX - args.ownerX;
const dy = frame.ownerY - args.ownerY; const dy = frame.ownerY - args.ownerY;
const distanceSquared = dx * dx + dy * dy; const distanceSquared = dx * dx + dy * dy;
@ -613,7 +800,7 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
} }
function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null { function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null {
if (frames.length === 0 || frames.length > 4) { if (frames.length === 0 || frames.length > 6) {
return null; return null;
} }
const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length]; const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length];
@ -968,7 +1155,22 @@ function planOwnerSlots(
centralCollisionRects: readonly StableRect[], centralCollisionRects: readonly StableRect[],
runtimeCentralExclusion: StableRect, runtimeCentralExclusion: StableRect,
layout?: GraphLayoutPort layout?: GraphLayoutPort
): SlotFrame[] { ): PlannedMemberSlotLayout {
const rowOrbitFrames = shouldUseRowOrbitLayout(ownerFootprints, layout)
? planRowOrbitOwnerSlots(
ownerFootprints,
centralCollisionRects,
runtimeCentralExclusion,
layout
)
: null;
if (rowOrbitFrames) {
return {
frames: rowOrbitFrames,
kind: 'row-orbit',
};
}
const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout) const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout)
? planStrictSmallTeamOwnerSlots( ? planStrictSmallTeamOwnerSlots(
ownerFootprints, ownerFootprints,
@ -978,7 +1180,10 @@ function planOwnerSlots(
) )
: null; : null;
if (strictSmallTeamFrames) { if (strictSmallTeamFrames) {
return strictSmallTeamFrames; return {
frames: strictSmallTeamFrames,
kind: 'radial-sector',
};
} }
const placedFrames: SlotFrame[] = []; const placedFrames: SlotFrame[] = [];
@ -1002,7 +1207,354 @@ function planOwnerSlots(
commitRingPlacement(ringStates, resolvedFrame, footprint); commitRingPlacement(ringStates, resolvedFrame, footprint);
} }
return placedFrames; return {
frames: placedFrames,
kind: 'radial-sector',
};
}
function shouldUseRowOrbitLayout(
ownerFootprints: readonly OwnerFootprint[],
layout?: GraphLayoutPort
): boolean {
if (
ownerFootprints.length < ROW_ORBIT_MIN_OWNER_COUNT ||
ownerFootprints.length > ROW_ORBIT_MAX_OWNER_COUNT
) {
return false;
}
const preset = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[ownerFootprints.length];
if (!preset || preset.length < ownerFootprints.length) {
return false;
}
const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length];
if (!rowCounts) {
return false;
}
const actualAssignments = ownerFootprints
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null);
const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments(
ownerFootprints.length,
actualAssignments
);
const actualAssignmentKeys = actualAssignments
.map((assignment) =>
normalizeRowOrbitAssignment(assignment, ownerFootprints.length, rowCounts, {
useLegacySixTwoRowAssignments,
})
)
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null)
.map((assignment) => buildAssignmentKey(assignment))
.sort();
const allowedAssignmentKeys = new Set(preset.map((assignment) => buildAssignmentKey(assignment)));
if (actualAssignmentKeys.length !== ownerFootprints.length) {
return false;
}
const uniqueAssignmentKeys = new Set(actualAssignmentKeys);
if (uniqueAssignmentKeys.size !== actualAssignmentKeys.length) {
return false;
}
for (const assignmentKey of actualAssignmentKeys) {
if (!allowedAssignmentKeys.has(assignmentKey)) {
return false;
}
}
return true;
}
function planRowOrbitOwnerSlots(
ownerFootprints: readonly OwnerFootprint[],
centralCollisionRects: readonly StableRect[],
runtimeCentralExclusion: StableRect,
layout?: GraphLayoutPort
): SlotFrame[] | null {
const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length];
if (!rowCounts) {
return null;
}
const slotConfigs = buildRowOrbitSlotConfigs(ownerFootprints, rowCounts, layout);
if (!slotConfigs) {
return null;
}
const frames = buildRowOrbitSlotFrames(slotConfigs, rowCounts, runtimeCentralExclusion);
const allValid = frames.every((frame, frameIndex) =>
isSlotFramePlacementValid(
frame,
frames.filter((_, index) => index !== frameIndex),
centralCollisionRects
)
);
return allValid ? frames : null;
}
function buildRowOrbitSlotConfigs(
ownerFootprints: readonly OwnerFootprint[],
rowCounts: readonly number[],
layout?: GraphLayoutPort
): RowOrbitSlotConfig[] | null {
const rowCount = rowCounts.length;
const middleRowIndex = rowCount === 3 ? 1 : -1;
const configs: RowOrbitSlotConfig[] = [];
const actualAssignments = ownerFootprints
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null);
const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments(
ownerFootprints.length,
actualAssignments
);
for (const footprint of ownerFootprints) {
const assignment = layout?.slotAssignments?.[footprint.ownerId];
if (!assignment) {
return null;
}
const rowOrbitAssignment = normalizeRowOrbitAssignment(
assignment,
ownerFootprints.length,
rowCounts,
{
useLegacySixTwoRowAssignments,
}
);
if (!rowOrbitAssignment) {
return null;
}
const columnCount = rowCounts[rowOrbitAssignment.ringIndex];
if (
columnCount == null ||
rowOrbitAssignment.sectorIndex < 0 ||
rowOrbitAssignment.sectorIndex >= columnCount
) {
return null;
}
configs.push({
footprint,
assignment: rowOrbitAssignment,
rowIndex: rowOrbitAssignment.ringIndex,
columnIndex: rowOrbitAssignment.sectorIndex,
columnCount,
band: resolveRowOrbitBand(rowOrbitAssignment.ringIndex, rowCount, middleRowIndex),
});
}
return configs;
}
function normalizeRowOrbitAssignment(
assignment: GraphOwnerSlotAssignment,
ownerCount: number,
rowCounts: readonly number[],
options: { useLegacySixTwoRowAssignments?: boolean } = {}
): GraphOwnerSlotAssignment | null {
if (
options.useLegacySixTwoRowAssignments === true &&
ownerCount === 6 &&
assignment.ringIndex === 1 &&
assignment.sectorIndex >= 0 &&
assignment.sectorIndex < 3
) {
return {
ringIndex: 2,
sectorIndex: assignment.sectorIndex,
};
}
const directColumnCount = rowCounts[assignment.ringIndex];
if (
directColumnCount != null &&
assignment.sectorIndex >= 0 &&
assignment.sectorIndex < directColumnCount
) {
return assignment;
}
if (
ownerCount === 6 &&
assignment.ringIndex === 0 &&
assignment.sectorIndex >= 0 &&
assignment.sectorIndex < 6
) {
return {
ringIndex: assignment.sectorIndex < 3 ? 0 : 2,
sectorIndex: assignment.sectorIndex % 3,
};
}
return null;
}
function shouldNormalizeLegacySixTwoRowAssignments(
ownerCount: number,
assignments: readonly GraphOwnerSlotAssignment[]
): boolean {
if (ownerCount !== 6 || assignments.length !== ownerCount) {
return false;
}
return assignments.some(
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
);
}
function resolveRowOrbitBand(
rowIndex: number,
rowCount: number,
middleRowIndex: number
): RowOrbitSlotConfig['band'] {
if (middleRowIndex >= 0) {
if (rowIndex < middleRowIndex) {
return 'top';
}
return rowIndex === middleRowIndex ? 'middle' : 'bottom';
}
return rowIndex < rowCount / 2 ? 'top' : 'bottom';
}
function buildRowOrbitSlotFrames(
slotConfigs: readonly RowOrbitSlotConfig[],
rowCounts: readonly number[],
runtimeCentralExclusion: StableRect
): SlotFrame[] {
const rowConfigs = groupRowOrbitSlotConfigs(slotConfigs, rowCounts.length);
const middleRowIndex = rowCounts.length === 3 ? 1 : -1;
const rowTopByIndex = resolveRowOrbitRowTops(rowConfigs, middleRowIndex, runtimeCentralExclusion);
const framesByOwnerId = new Map<string, SlotFrame>();
const fallbackColumnWidth = Math.max(...slotConfigs.map((config) => config.footprint.slotWidth));
for (const row of rowConfigs) {
if (row.length === 0) {
continue;
}
if (row[0]?.band === 'middle') {
for (const config of row) {
const ownerX =
config.columnIndex === 0
? runtimeCentralExclusion.left - ROW_ORBIT_CENTRAL_GAP - config.footprint.slotWidth / 2
: runtimeCentralExclusion.right +
ROW_ORBIT_CENTRAL_GAP +
config.footprint.slotWidth / 2;
framesByOwnerId.set(
config.footprint.ownerId,
buildSlotFrameAtOwnerAnchor(config.footprint, config.assignment, ownerX, 0)
);
}
continue;
}
const rowTop = rowTopByIndex.get(row[0]!.rowIndex) ?? 0;
const columnCount = rowCounts[row[0]!.rowIndex] ?? row.length;
const columnWidths = resolveRowOrbitColumnWidths(row, columnCount, fallbackColumnWidth);
let nextLeft = -getRowOrbitRowWidth(columnWidths) / 2;
for (const config of row) {
const ownerX =
nextLeft +
columnWidths.slice(0, config.columnIndex).reduce((sum, width) => sum + width, 0) +
config.columnIndex * ROW_ORBIT_HORIZONTAL_GAP +
columnWidths[config.columnIndex]! / 2;
const ownerY = rowTop + getOwnerAnchorTopOffset();
framesByOwnerId.set(
config.footprint.ownerId,
buildSlotFrameAtOwnerAnchor(config.footprint, config.assignment, ownerX, ownerY)
);
}
}
return slotConfigs.flatMap((config) => {
const frame = framesByOwnerId.get(config.footprint.ownerId);
return frame ? [frame] : [];
});
}
function groupRowOrbitSlotConfigs(
slotConfigs: readonly RowOrbitSlotConfig[],
rowCount: number
): RowOrbitSlotConfig[][] {
const rows: RowOrbitSlotConfig[][] = Array.from({ length: rowCount }, () => []);
for (const config of slotConfigs) {
rows[config.rowIndex]!.push(config);
}
for (const row of rows) {
row.sort((left, right) => left.columnIndex - right.columnIndex);
}
return rows;
}
function resolveRowOrbitRowTops(
rowConfigs: readonly (readonly RowOrbitSlotConfig[])[],
middleRowIndex: number,
runtimeCentralExclusion: StableRect
): Map<number, number> {
const topByRowIndex = new Map<number, number>();
let nextTopRowBottom = runtimeCentralExclusion.top - ROW_ORBIT_CENTRAL_GAP;
for (
let rowIndex = middleRowIndex >= 0 ? middleRowIndex - 1 : rowConfigs.length / 2 - 1;
rowIndex >= 0;
rowIndex -= 1
) {
const row = rowConfigs[rowIndex] ?? [];
if (row.length === 0) {
continue;
}
const rowHeight = getRowOrbitRowHeight(row);
const rowTop = nextTopRowBottom - rowHeight;
topByRowIndex.set(rowIndex, rowTop);
nextTopRowBottom = rowTop - ROW_ORBIT_VERTICAL_GAP;
}
let nextBottomRowTop = runtimeCentralExclusion.bottom + ROW_ORBIT_CENTRAL_GAP;
for (
let rowIndex = middleRowIndex >= 0 ? middleRowIndex + 1 : Math.ceil(rowConfigs.length / 2);
rowIndex < rowConfigs.length;
rowIndex += 1
) {
const row = rowConfigs[rowIndex] ?? [];
if (row.length === 0) {
continue;
}
topByRowIndex.set(rowIndex, nextBottomRowTop);
nextBottomRowTop += getRowOrbitRowHeight(row) + ROW_ORBIT_VERTICAL_GAP;
}
return topByRowIndex;
}
function resolveRowOrbitColumnWidths(
row: readonly RowOrbitSlotConfig[],
columnCount: number,
fallbackColumnWidth: number
): number[] {
const columnWidths = Array.from({ length: columnCount }, () => fallbackColumnWidth);
for (const config of row) {
columnWidths[config.columnIndex] = Math.max(
columnWidths[config.columnIndex] ?? fallbackColumnWidth,
config.footprint.slotWidth
);
}
return columnWidths;
}
function getRowOrbitRowWidth(columnWidths: readonly number[]): number {
return (
columnWidths.reduce((sum, width) => sum + width, 0) +
Math.max(0, columnWidths.length - 1) * ROW_ORBIT_HORIZONTAL_GAP
);
}
function getRowOrbitRowHeight(row: readonly RowOrbitSlotConfig[]): number {
return Math.max(...row.map((config) => config.footprint.slotHeight));
} }
function planGridUnderLeadOwnerSlots( function planGridUnderLeadOwnerSlots(
@ -1012,16 +1564,14 @@ function planGridUnderLeadOwnerSlots(
const frames: SlotFrame[] = []; const frames: SlotFrame[] = [];
const centralBlock = unionRects([...centralCollisionRects]); const centralBlock = unionRects([...centralCollisionRects]);
let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP; let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP;
const columnCount = getGridUnderLeadColumnCount(ownerFootprints.length);
for ( for (
let rowStartIndex = 0; let rowStartIndex = 0;
rowStartIndex < ownerFootprints.length; rowStartIndex < ownerFootprints.length;
rowStartIndex += GRID_UNDER_LEAD_COLUMN_COUNT rowStartIndex += columnCount
) { ) {
const rowFootprints = ownerFootprints.slice( const rowFootprints = ownerFootprints.slice(rowStartIndex, rowStartIndex + columnCount);
rowStartIndex,
rowStartIndex + GRID_UNDER_LEAD_COLUMN_COUNT
);
const rowWidth = const rowWidth =
rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) + rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) +
Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap; Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap;
@ -1035,7 +1585,7 @@ function planGridUnderLeadOwnerSlots(
buildSlotFrameAtOwnerAnchor( buildSlotFrameAtOwnerAnchor(
footprint, footprint,
{ {
ringIndex: Math.floor(rowStartIndex / GRID_UNDER_LEAD_COLUMN_COUNT), ringIndex: Math.floor(rowStartIndex / columnCount),
sectorIndex: columnIndex, sectorIndex: columnIndex,
}, },
ownerX, ownerX,
@ -1051,11 +1601,17 @@ function planGridUnderLeadOwnerSlots(
return frames; return frames;
} }
function getGridUnderLeadColumnCount(ownerCount: number): number {
return ownerCount === GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT
? GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT
: GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT;
}
function shouldUseStrictSmallTeamCardinalLayout( function shouldUseStrictSmallTeamCardinalLayout(
ownerFootprints: readonly OwnerFootprint[], ownerFootprints: readonly OwnerFootprint[],
layout?: GraphLayoutPort layout?: GraphLayoutPort
): boolean { ): boolean {
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { if (ownerFootprints.length === 0 || ownerFootprints.length > 6) {
return false; return false;
} }
@ -1090,7 +1646,7 @@ function planStrictSmallTeamOwnerSlots(
runtimeCentralExclusion: StableRect, runtimeCentralExclusion: StableRect,
layout?: GraphLayoutPort layout?: GraphLayoutPort
): SlotFrame[] | null { ): SlotFrame[] | null {
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { if (ownerFootprints.length === 0 || ownerFootprints.length > 6) {
return null; return null;
} }
@ -1098,13 +1654,16 @@ function planStrictSmallTeamOwnerSlots(
if (!preset || preset.length !== ownerFootprints.length) { if (!preset || preset.length !== ownerFootprints.length) {
return null; return null;
} }
const vectorByAssignmentKey = new Map(
preset.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
);
const slotConfigs = ownerFootprints.map((footprint) => { const slotConfigs = ownerFootprints.map((footprint) => {
const assignment = layout?.slotAssignments?.[footprint.ownerId]; const assignment = layout?.slotAssignments?.[footprint.ownerId];
if (!assignment) { if (!assignment) {
return null; return null;
} }
const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment)); const vector = vectorByAssignmentKey.get(buildAssignmentKey(assignment));
if (!vector) { if (!vector) {
return null; return null;
} }
@ -1119,72 +1678,164 @@ function planStrictSmallTeamOwnerSlots(
return null; return null;
} }
const baseRadiusByAxis = resolveStrictSmallTeamRadiusByAxis( return packStrictSmallTeamOwnerSlots(
slotConfigs.map((slot) => slot!), slotConfigs.map((slot) => slot!),
centralCollisionRects, centralCollisionRects,
runtimeCentralExclusion runtimeCentralExclusion
); );
}
for (let iteration = 0; iteration < 48; iteration += 1) { function packStrictSmallTeamOwnerSlots(
const radiusBump = iteration * SMALL_TEAM_CARDINAL_RADIUS_STEP; slotConfigs: readonly {
const frames = slotConfigs.map((slot) => { footprint: OwnerFootprint;
const axis = resolveStrictSmallTeamVectorAxis(slot!.vector); assignment: GraphOwnerSlotAssignment;
return buildSlotFrameAtRadiusWithVector( vector: { x: number; y: number };
slot!.footprint, }[],
slot!.assignment, centralCollisionRects: readonly StableRect[],
baseRadiusByAxis[axis] + runtimeCentralExclusion: StableRect
(axis === 'vertical' ? SMALL_TEAM_CARDINAL_VERTICAL_PADDING : 0) + ): SlotFrame[] | null {
radiusBump, const radii = slotConfigs.map((slot) =>
slot!.vector resolveMinimumDirectionalRadiusForVector({
); vector: slot.vector,
}); footprint: slot.footprint,
const allValid = frames.every((frame, frameIndex) => centralCollisionRects,
isSlotFramePlacementValid( runtimeCentralExclusion,
frame, })
frames.filter((_, index) => index !== frameIndex), );
centralCollisionRects
) for (let iteration = 0; iteration < STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS; iteration += 1) {
const frames = buildStrictSmallTeamFrames(slotConfigs, radii);
const invalidCentralIndex = frames.findIndex((frame) =>
rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)
); );
if (allValid) { if (invalidCentralIndex >= 0) {
radii[invalidCentralIndex] += STRICT_SMALL_TEAM_RADIUS_STEP;
continue;
}
const conflict = findFirstOwnerSlotFrameConflict(frames);
if (!conflict) {
return frames; return frames;
} }
const nextLeftRadius = resolveMinimumRadiusAvoidingFrame({
slotConfig: slotConfigs[conflict.leftIndex]!,
currentRadius: radii[conflict.leftIndex]!,
otherFrame: frames[conflict.rightIndex]!,
centralCollisionRects,
});
const nextRightRadius = resolveMinimumRadiusAvoidingFrame({
slotConfig: slotConfigs[conflict.rightIndex]!,
currentRadius: radii[conflict.rightIndex]!,
otherFrame: frames[conflict.leftIndex]!,
centralCollisionRects,
});
if (nextLeftRadius == null && nextRightRadius == null) {
return null;
}
const leftIncrease =
nextLeftRadius == null
? Number.POSITIVE_INFINITY
: nextLeftRadius - radii[conflict.leftIndex]!;
const rightIncrease =
nextRightRadius == null
? Number.POSITIVE_INFINITY
: nextRightRadius - radii[conflict.rightIndex]!;
if (leftIncrease <= rightIncrease) {
radii[conflict.leftIndex] = nextLeftRadius!;
} else {
radii[conflict.rightIndex] = nextRightRadius!;
}
} }
return null; return null;
} }
function resolveStrictSmallTeamRadiusByAxis( function buildStrictSmallTeamFrames(
slotConfigs: readonly { slotConfigs: readonly {
footprint: OwnerFootprint; footprint: OwnerFootprint;
assignment: GraphOwnerSlotAssignment;
vector: { x: number; y: number }; vector: { x: number; y: number };
}[], }[],
centralCollisionRects: readonly StableRect[], radii: readonly number[]
runtimeCentralExclusion: StableRect ): SlotFrame[] {
): Record<'horizontal' | 'vertical', number> { return slotConfigs.map((slot, index) =>
const radiusByAxis = { buildSlotFrameAtRadiusWithVector(
horizontal: 0, slot.footprint,
vertical: 0, slot.assignment,
}; radii[index] ?? 0,
slot.vector
for (const slot of slotConfigs) { )
const axis = resolveStrictSmallTeamVectorAxis(slot.vector); );
const radius = resolveMinimumDirectionalRadiusForVector({
vector: slot.vector,
footprint: slot.footprint,
centralCollisionRects,
runtimeCentralExclusion,
});
radiusByAxis[axis] = Math.max(radiusByAxis[axis], radius);
}
return radiusByAxis;
} }
function resolveStrictSmallTeamVectorAxis(vector: { function findFirstOwnerSlotFrameConflict(
x: number; frames: readonly SlotFrame[]
y: number; ): { leftIndex: number; rightIndex: number } | null {
}): 'horizontal' | 'vertical' { for (const [leftIndex, left] of frames.entries()) {
return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical'; for (let rightIndex = leftIndex + 1; rightIndex < frames.length; rightIndex += 1) {
const right = frames[rightIndex]!;
if (ownerSlotFramesOverlap(left.bounds, right.bounds)) {
return { leftIndex, rightIndex };
}
}
}
return null;
}
function resolveMinimumRadiusAvoidingFrame(args: {
slotConfig: {
footprint: OwnerFootprint;
assignment: GraphOwnerSlotAssignment;
vector: { x: number; y: number };
};
currentRadius: number;
otherFrame: SlotFrame;
centralCollisionRects: readonly StableRect[];
}): number | null {
const canPlaceAtRadius = (radius: number): boolean => {
const frame = buildSlotFrameAtRadiusWithVector(
args.slotConfig.footprint,
args.slotConfig.assignment,
radius,
args.slotConfig.vector
);
return (
!rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects) &&
!ownerSlotFramesOverlap(frame.bounds, args.otherFrame.bounds)
);
};
if (canPlaceAtRadius(args.currentRadius)) {
return args.currentRadius;
}
let low = args.currentRadius;
let high = Math.max(args.currentRadius + STRICT_SMALL_TEAM_RADIUS_STEP, args.currentRadius * 1.1);
let expansionCount = 0;
while (!canPlaceAtRadius(high) && expansionCount < 24) {
low = high;
high = Math.max(high + STRICT_SMALL_TEAM_RADIUS_STEP, high * 1.25);
expansionCount += 1;
}
if (!canPlaceAtRadius(high)) {
return null;
}
for (let iteration = 0; iteration < 24; iteration += 1) {
const mid = (low + high) / 2;
if (canPlaceAtRadius(mid)) {
high = mid;
} else {
low = mid;
}
}
return Math.ceil(high + STRICT_SMALL_TEAM_RADIUS_EPSILON);
} }
function buildPreferredAssignmentsMap( function buildPreferredAssignmentsMap(

View file

@ -80,7 +80,16 @@ export interface TeamGraphData extends TeamViewSnapshot {
function toGraphLaunchVisualState( function toGraphLaunchVisualState(
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
): GraphNode['launchVisualState'] { ): GraphNode['launchVisualState'] {
return visualState === 'bootstrap_stalled' ? 'runtime_pending' : (visualState ?? undefined); if (!visualState) {
return undefined;
}
if (visualState === 'bootstrap_stalled') {
return 'runtime_pending';
}
if (visualState === 'starting_stale') {
return 'spawning';
}
return visualState;
} }
export class TeamGraphAdapter { export class TeamGraphAdapter {

View file

@ -26,7 +26,7 @@ export const RecentProjectCard = ({
onClick={onClick} onClick={onClick}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised" className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised"
style={{ style={{
borderLeftColor: color.border, borderLeftColor: color.border,
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined, boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,

View file

@ -18,6 +18,7 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
// Keep userData stable before any integration can initialize Electron storage. // Keep userData stable before any integration can initialize Electron storage.
// Sentry must stay near the top to capture early errors after storage migration. // Sentry must stay near the top to capture early errors after storage migration.
// eslint-disable-next-line simple-import-sort/imports -- userData migration must run before Sentry initializes Electron storage.
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration'; import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
import './sentry'; import './sentry';
@ -76,8 +77,9 @@ import {
} from '@main/services/team/TeamMcpConfigBuilder'; } from '@main/services/team/TeamMcpConfigBuilder';
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver'; import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
import { killTrackedCliProcesses } from '@main/utils/childProcess'; import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { import {
APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS,
CONTEXT_CHANGED, CONTEXT_CHANGED,
SCHEDULE_CHANGE, SCHEDULE_CHANGE,
SKILLS_CHANGED, SKILLS_CHANGED,
@ -105,6 +107,7 @@ import { join } from 'path';
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { registerRendererLogHandlers } from './ipc/rendererLogs';
import { setReviewMainWindow } from './ipc/review'; import { setReviewMainWindow } from './ipc/review';
import { setTmuxMainWindow } from './ipc/tmux'; import { setTmuxMainWindow } from './ipc/tmux';
import { import {
@ -209,7 +212,7 @@ import {
} from './services'; } from './services';
import type { FileChangeEvent } from '@main/types'; import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types'; import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types';
const logger = createLogger('App'); const logger = createLogger('App');
let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null; let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null;
@ -256,17 +259,27 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500;
/** Messages sent from our UI (user_sent) — suppress notifications for these. */ /** Messages sent from our UI (user_sent) — suppress notifications for these. */
const suppressedSources = new Set(['user_sent']); const suppressedSources = new Set(['user_sent']);
async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapterRegistry> { async function createOpenCodeRuntimeAdapterRegistry(
const binaryPath = await ClaudeBinaryResolver.resolve(); reportProgress: (phase: string, message: string) => void = () => undefined
): Promise<TeamRuntimeAdapterRegistry> {
const binaryPath = await ClaudeBinaryResolver.resolve({
onProgress: ({ phase, message }) => reportProgress(`runtime-${phase}`, message),
});
if (!binaryPath) { if (!binaryPath) {
logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved'); logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved');
reportProgress(
'runtime-unavailable',
'Runtime not found. Continuing with limited launch support...'
);
openCodeLifecycleBridge = null; openCodeLifecycleBridge = null;
return new TeamRuntimeAdapterRegistry(); return new TeamRuntimeAdapterRegistry();
} }
reportProgress('runtime-environment', 'Preparing runtime environment...');
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env }); const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath(); bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
try { try {
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
teamsBasePath: getTeamsBasePath(), teamsBasePath: getTeamsBasePath(),
provider: 'opencode', provider: 'opencode',
@ -282,7 +295,10 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
); );
} }
try { try {
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...');
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({
onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message),
});
const mcpEntry = mcpLaunchSpec.args[0]; const mcpEntry = mcpLaunchSpec.args[0];
if (mcpEntry) { if (mcpEntry) {
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
@ -297,6 +313,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
); );
} }
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
const bridgeClient = new OpenCodeBridgeCommandClient({ const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath, binaryPath,
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'), tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
@ -624,6 +641,11 @@ let teamBackupService: TeamBackupService | null = null;
let branchStatusService: BranchStatusService | null = null; let branchStatusService: BranchStatusService | null = null;
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null; let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
let rendererRecoveryAttempts = 0; let rendererRecoveryAttempts = 0;
let servicesReady = false;
let rendererDidFinishLoad = false;
let fileWatcherStartupStarted = false;
let backgroundStartupTasksStarted = false;
let appStartupHandlersRegistered = false;
// File watcher event cleanup functions // File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null; let fileChangeCleanup: (() => void) | null = null;
@ -636,6 +658,24 @@ const startupTimers = new Set<ReturnType<typeof setTimeout>>();
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
const STARTUP_RECOVERY_DELAY_MS = 10_000; const STARTUP_RECOVERY_DELAY_MS = 10_000;
const STARTUP_RECOVERY_CONCURRENCY = 1; const STARTUP_RECOVERY_CONCURRENCY = 1;
const appStartupStartedAt = Date.now();
let appStartupSteps: AppStartupStep[] = [
{
phase: 'boot',
message: 'Starting Agent Teams AI...',
startedAt: appStartupStartedAt,
updatedAt: appStartupStartedAt,
},
];
let appStartupStatus: AppStartupStatus = {
phase: 'boot',
message: 'Starting Agent Teams AI...',
ready: false,
error: null,
startedAt: appStartupStartedAt,
updatedAt: appStartupStartedAt,
steps: appStartupSteps,
};
function isShutdownStarted(): boolean { function isShutdownStarted(): boolean {
return shutdownComplete || shutdownPromise !== null; return shutdownComplete || shutdownPromise !== null;
@ -653,6 +693,74 @@ function scheduleStartupTask(action: () => void, delayMs: number): void {
startupTimers.add(timer); startupTimers.add(timer);
} }
function registerAppStartupHandlers(): void {
if (appStartupHandlersRegistered) {
return;
}
appStartupHandlersRegistered = true;
registerRendererLogHandlers(ipcMain);
ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus);
}
function cloneStartupSteps(): AppStartupStep[] {
return appStartupSteps.map((step) => ({ ...step }));
}
function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number): void {
if (!update.phase && !update.message) {
return;
}
const phase = update.phase ?? appStartupStatus.phase;
const message = update.message ?? appStartupStatus.message;
const current = appStartupSteps[appStartupSteps.length - 1];
if (current?.phase !== phase) {
if (current && !current.finishedAt) {
current.finishedAt = now;
current.durationMs = now - current.startedAt;
current.updatedAt = now;
}
appStartupSteps.push({
phase,
message,
startedAt: now,
updatedAt: now,
});
if (appStartupSteps.length > 32) {
appStartupSteps = appStartupSteps.slice(-32);
}
} else {
current.message = message;
current.updatedAt = now;
}
}
function finishCurrentStartupStep(now: number): void {
const current = appStartupSteps[appStartupSteps.length - 1];
if (!current || current.finishedAt) {
return;
}
current.finishedAt = now;
current.durationMs = now - current.startedAt;
current.updatedAt = now;
}
function publishStartupStatus(update: Partial<AppStartupStatus>): void {
const now = Date.now();
updateStartupTimeline(update, now);
if (update.ready === true || update.error) {
finishCurrentStartupStep(now);
}
appStartupStatus = {
...appStartupStatus,
...update,
updatedAt: now,
steps: cloneStartupSteps(),
};
safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus);
}
async function runStartupJobsBounded<T>( async function runStartupJobsBounded<T>(
items: readonly T[], items: readonly T[],
concurrency: number, concurrency: number,
@ -1063,6 +1171,12 @@ function reconfigureLocalContextForClaudeRoot(): void {
*/ */
async function initializeServices(): Promise<void> { async function initializeServices(): Promise<void> {
logger.info('Initializing services...'); logger.info('Initializing services...');
publishStartupStatus({
phase: 'services',
message: 'Preparing app services...',
ready: false,
error: null,
});
// Initialize SSH connection manager // Initialize SSH connection manager
sshConnectionManager = new SshConnectionManager(); sshConnectionManager = new SshConnectionManager();
@ -1167,10 +1281,20 @@ async function initializeServices(): Promise<void> {
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => { teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName); teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
}); });
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry()); publishStartupStatus({
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) => phase: 'runtime',
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`) message: 'Resolving local runtime...',
});
teamProvisioningService.setRuntimeAdapterRegistry(
await createOpenCodeRuntimeAdapterRegistry((phase, message) =>
publishStartupStatus({ phase, message })
)
); );
scheduleStartupTask(() => {
void cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
);
}, STARTUP_RECOVERY_DELAY_MS);
// Startup GC: remove stale MCP config files from previous sessions (best-effort) // Startup GC: remove stale MCP config files from previous sessions (best-effort)
void new TeamMcpConfigBuilder().gcStaleConfigs(); void new TeamMcpConfigBuilder().gcStaleConfigs();
void teamDataService void teamDataService
@ -1267,6 +1391,10 @@ async function initializeServices(): Promise<void> {
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter); const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
const apiKeyService = new ApiKeyService(); const apiKeyService = new ApiKeyService();
providerConnectionService.setApiKeyService(apiKeyService); providerConnectionService.setApiKeyService(apiKeyService);
publishStartupStatus({
phase: 'settings',
message: 'Loading secure settings...',
});
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS); await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
// warmup() and ensureInstalled() are deferred to after window creation // warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup. // (did-finish-load handler) to avoid thread pool contention at startup.
@ -1448,6 +1576,11 @@ async function initializeServices(): Promise<void> {
// startProcessHealthPolling() is deferred to after window creation // startProcessHealthPolling() is deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup. // (did-finish-load handler) to avoid thread pool contention at startup.
publishStartupStatus({
phase: 'ipc',
message: 'Wiring app actions...',
});
// Initialize IPC handlers with registry // Initialize IPC handlers with registry
initializeIpcHandlers( initializeIpcHandlers(
contextRegistry, contextRegistry,
@ -1529,6 +1662,10 @@ async function initializeServices(): Promise<void> {
} }
logger.info('Services initialized successfully'); logger.info('Services initialized successfully');
publishStartupStatus({
phase: 'readying',
message: 'Finishing startup...',
});
} }
/** /**
@ -1717,6 +1854,85 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor);
} }
function attachMainWindowToServices(): void {
const win = mainWindow;
if (!win || win.isDestroyed()) {
return;
}
notificationManager?.setMainWindow(win);
updaterService?.setMainWindow(win);
cliInstallerService?.setMainWindow(win);
setTmuxMainWindow(win);
ptyTerminalService?.setMainWindow(win);
teamProvisioningService?.setMainWindow(win);
codexAccountFeature?.setMainWindow(win);
setEditorMainWindow(win);
setReviewMainWindow(win);
}
function runPostRendererStartupTasks(): void {
if (!servicesReady || !rendererDidFinishLoad || !mainWindow || mainWindow.isDestroyed()) {
return;
}
if (!fileWatcherStartupStarted) {
fileWatcherStartupStarted = true;
// Start file watchers after both the visible window and main services are ready.
const activeContext = contextRegistry.getActive();
if (process.platform === 'win32') {
scheduleStartupTask(() => {
if (!fileWatcherStartupStarted || !servicesReady || !rendererDidFinishLoad) {
return;
}
activeContext.startFileWatcher();
}, 1500);
} else if (!isShutdownStarted()) {
activeContext.startFileWatcher();
}
}
if (backgroundStartupTasksStarted) {
return;
}
backgroundStartupTasksStarted = true;
if (!isShutdownStarted()) {
scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000);
updaterService.startPeriodicCheck(60 * 60 * 1000);
}
scheduleStartupTask(
() => {
void getTeamFsWorkerClient()
.prewarm()
.catch((error: unknown) =>
logger.debug(
`[startup] team-fs-worker prewarm skipped: ${
error instanceof Error ? error.message : String(error)
}`
)
);
void getTeamDataWorkerClient()
.prewarm()
.catch((error: unknown) =>
logger.debug(
`[startup] team-data-worker prewarm skipped: ${
error instanceof Error ? error.message : String(error)
}`
)
);
},
process.platform === 'win32' ? 2500 : 1000
);
scheduleStartupTask(() => {
void teamProvisioningService.warmup();
teamDataService.startProcessHealthPolling();
void schedulerService?.start();
}, 5000);
}
function scheduleRendererRecovery(win: BrowserWindow): void { function scheduleRendererRecovery(win: BrowserWindow): void {
if (isShutdownStarted()) { if (isShutdownStarted()) {
return; return;
@ -1759,6 +1975,7 @@ function createWindow(): void {
if (isShutdownStarted()) { if (isShutdownStarted()) {
return; return;
} }
rendererDidFinishLoad = false;
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
@ -1780,7 +1997,7 @@ function createWindow(): void {
backgroundColor: '#1a1a1a', backgroundColor: '#1a1a1a',
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
title: 'Agent Teams UI', title: 'Agent Teams AI',
}); });
markRendererUnavailable(mainWindow); markRendererUnavailable(mainWindow);
@ -1850,6 +2067,7 @@ function createWindow(): void {
if (isShutdownStarted()) { if (isShutdownStarted()) {
return; return;
} }
rendererDidFinishLoad = false;
markRendererUnavailable(mainWindow); markRendererUnavailable(mainWindow);
branchStatusService?.resetAllTracking(); branchStatusService?.resetAllTracking();
}); });
@ -1874,57 +2092,8 @@ function createWindow(): void {
} }
}, 0); }, 0);
fullscreenSyncTimer.unref?.(); fullscreenSyncTimer.unref?.();
// Start file watchers now that the window is visible and responsive. rendererDidFinishLoad = true;
// Deferred from initializeServices() to avoid blocking window creation runPostRendererStartupTasks();
// with fs.watch() setup (especially slow on Windows with recursive watchers).
const activeContext = contextRegistry.getActive();
if (process.platform === 'win32') {
// On Windows, delay FileWatcher startup to let the renderer complete
// its initial IPC calls without UV thread pool contention. Recursive
// fs.watch() on NTFS saturates all 4 default UV threads.
scheduleStartupTask(() => activeContext.startFileWatcher(), 1500);
} else {
if (!isShutdownStarted()) {
activeContext.startFileWatcher();
}
}
if (!isShutdownStarted()) {
scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000);
updaterService.startPeriodicCheck(60 * 60 * 1000);
}
scheduleStartupTask(
() => {
void getTeamFsWorkerClient()
.prewarm()
.catch((error: unknown) =>
logger.debug(
`[startup] team-fs-worker prewarm skipped: ${
error instanceof Error ? error.message : String(error)
}`
)
);
void getTeamDataWorkerClient()
.prewarm()
.catch((error: unknown) =>
logger.debug(
`[startup] team-data-worker prewarm skipped: ${
error instanceof Error ? error.message : String(error)
}`
)
);
},
process.platform === 'win32' ? 2500 : 1000
);
// Defer non-critical startup work to avoid thread pool contention.
// The window is now visible and responsive; these run in the background.
scheduleStartupTask(() => {
void teamProvisioningService.warmup();
teamDataService.startProcessHealthPolling();
void schedulerService?.start();
}, 5000);
} }
}); });
@ -2037,34 +2206,16 @@ function createWindow(): void {
return; return;
} }
markRendererUnavailable(mainWindow); markRendererUnavailable(mainWindow);
rendererDidFinishLoad = false;
fileWatcherStartupStarted = false;
branchStatusService?.resetAllTracking(); branchStatusService?.resetAllTracking();
const activeContext = contextRegistry.getActive(); contextRegistry?.getActive()?.stopFileWatcher();
activeContext?.stopFileWatcher();
if (mainWindow) { if (mainWindow) {
scheduleRendererRecovery(mainWindow); scheduleRendererRecovery(mainWindow);
} }
}); });
// Set main window reference for notification manager and updater attachMainWindowToServices();
if (notificationManager) {
notificationManager.setMainWindow(mainWindow);
}
if (updaterService) {
updaterService.setMainWindow(mainWindow);
}
if (cliInstallerService) {
cliInstallerService.setMainWindow(mainWindow);
}
setTmuxMainWindow(mainWindow);
if (ptyTerminalService) {
ptyTerminalService.setMainWindow(mainWindow);
}
if (teamProvisioningService) {
teamProvisioningService.setMainWindow(mainWindow);
}
codexAccountFeature?.setMainWindow(mainWindow);
setEditorMainWindow(mainWindow);
setReviewMainWindow(mainWindow);
logger.info('Main window created'); logger.info('Main window created');
} }
@ -2074,18 +2225,14 @@ function createWindow(): void {
*/ */
void app.whenReady().then(async () => { void app.whenReady().then(async () => {
logger.info('App ready, initializing...'); logger.info('App ready, initializing...');
registerAppStartupHandlers();
// Pre-warm interactive shell env cache (non-blocking).
// On macOS, Finder-launched apps get a minimal PATH. This resolves the user's
// full shell PATH (nvm, homebrew, .local/bin, etc.) in the background so that
// CliInstallerService.getStatus() and other services get cached results instantly.
void resolveInteractiveShellEnv();
try { try {
// Initialize services first publishStartupStatus({
await initializeServices(); phase: 'electron-ready',
message: 'Opening window...',
});
// Apply configuration settings
const config = configManager.getConfig(); const config = configManager.getConfig();
// Sync Sentry telemetry opt-in flag from persisted config // Sync Sentry telemetry opt-in flag from persisted config
@ -2109,9 +2256,19 @@ void app.whenReady().then(async () => {
// so we avoid runtime setIcon calls that can fail and block startup. // so we avoid runtime setIcon calls that can fail and block startup.
} }
// Then create window
createWindow(); createWindow();
await initializeServices();
servicesReady = true;
attachMainWindowToServices();
publishStartupStatus({
phase: 'ready',
message: 'Ready',
ready: true,
error: null,
});
runPostRendererStartupTasks();
// Listen for notification click events // Listen for notification click events
notificationManager.on('notification-clicked', (_error) => { notificationManager.on('notification-clicked', (_error) => {
if (isShutdownStarted()) { if (isShutdownStarted()) {
@ -2124,6 +2281,12 @@ void app.whenReady().then(async () => {
}); });
} catch (error) { } catch (error) {
logger.error('Startup initialization failed:', error); logger.error('Startup initialization failed:', error);
publishStartupStatus({
phase: 'failed',
message: 'Startup failed',
ready: false,
error: error instanceof Error ? error.message : String(error),
});
if (!mainWindow) { if (!mainWindow) {
createWindow(); createWindow();
} }

View file

@ -10,6 +10,7 @@ const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
const hasReceivedHeartbeatByWebContentsId = new Set<number>(); const hasReceivedHeartbeatByWebContentsId = new Set<number>();
let heartbeatMonitorStarted = false; let heartbeatMonitorStarted = false;
let heartbeatMonitorInterval: ReturnType<typeof setInterval> | null = null; let heartbeatMonitorInterval: ReturnType<typeof setInterval> | null = null;
let rendererLogHandlersRegistered = false;
function startHeartbeatMonitor(): void { function startHeartbeatMonitor(): void {
if (heartbeatMonitorStarted) return; if (heartbeatMonitorStarted) return;
@ -40,6 +41,10 @@ function startHeartbeatMonitor(): void {
} }
export function registerRendererLogHandlers(ipcMain: IpcMain): void { export function registerRendererLogHandlers(ipcMain: IpcMain): void {
if (rendererLogHandlersRegistered) {
return;
}
rendererLogHandlersRegistered = true;
startHeartbeatMonitor(); startHeartbeatMonitor();
ipcMain.on(RENDERER_LOG, () => { ipcMain.on(RENDERER_LOG, () => {
@ -69,6 +74,7 @@ export function removeRendererLogHandlers(ipcMain: IpcMain): void {
ipcMain.removeAllListeners(RENDERER_LOG); ipcMain.removeAllListeners(RENDERER_LOG);
ipcMain.removeAllListeners(RENDERER_BOOT); ipcMain.removeAllListeners(RENDERER_BOOT);
ipcMain.removeAllListeners(RENDERER_HEARTBEAT); ipcMain.removeAllListeners(RENDERER_HEARTBEAT);
rendererLogHandlersRegistered = false;
if (heartbeatMonitorInterval) { if (heartbeatMonitorInterval) {
clearInterval(heartbeatMonitorInterval); clearInterval(heartbeatMonitorInterval);

View file

@ -7,6 +7,23 @@ import * as path from 'path';
import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe'; import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe';
import { getConfiguredCliFlavor } from './cliFlavor'; import { getConfiguredCliFlavor } from './cliFlavor';
export interface ClaudeBinaryResolveProgress {
phase: string;
message: string;
}
export interface ClaudeBinaryResolveOptions {
onProgress?: (progress: ClaudeBinaryResolveProgress) => void;
}
function emitProgress(
options: ClaudeBinaryResolveOptions | undefined,
phase: string,
message: string
): void {
options?.onProgress?.({ phase, message });
}
async function isExecutable(filePath: string): Promise<boolean> { async function isExecutable(filePath: string): Promise<boolean> {
if (process.platform === 'win32') { if (process.platform === 'win32') {
try { try {
@ -218,34 +235,46 @@ export class ClaudeBinaryResolver {
cacheVerifiedAt = 0; cacheVerifiedAt = 0;
} }
static async resolve(): Promise<string | null> { static async resolve(options: ClaudeBinaryResolveOptions = {}): Promise<string | null> {
if (cachedPath !== undefined) { if (cachedPath !== undefined) {
const now = Date.now(); const now = Date.now();
// Re-verify the cached binary still exists, but at most once per TTL // Re-verify the cached binary still exists, but at most once per TTL
if (cachedPath !== null && now - cacheVerifiedAt > CACHE_VERIFY_TTL_MS) { if (cachedPath !== null && now - cacheVerifiedAt > CACHE_VERIFY_TTL_MS) {
emitProgress(options, 'cache-verify', 'Verifying cached runtime...');
if (await isExecutable(cachedPath)) { if (await isExecutable(cachedPath)) {
cacheVerifiedAt = now; cacheVerifiedAt = now;
emitProgress(options, 'cache-hit', 'Using cached runtime...');
return cachedPath; return cachedPath;
} }
cachedPath = undefined; cachedPath = undefined;
cacheVerifiedAt = 0; cacheVerifiedAt = 0;
// Fall through to full resolution below // Fall through to full resolution below
} else { } else {
emitProgress(
options,
cachedPath ? 'cache-hit' : 'cache-miss',
'Using cached runtime status...'
);
return cachedPath; return cachedPath;
} }
} }
if (!resolveInFlight) { if (!resolveInFlight) {
resolveInFlight = ClaudeBinaryResolver.runResolve().finally(() => { resolveInFlight = ClaudeBinaryResolver.runResolve(options).finally(() => {
resolveInFlight = null; resolveInFlight = null;
}); });
} else {
emitProgress(options, 'in-flight', 'Waiting for runtime lookup...');
} }
return resolveInFlight; return resolveInFlight;
} }
private static async runResolve(): Promise<string | null> { private static async runResolve(options: ClaudeBinaryResolveOptions): Promise<string | null> {
await resolveInteractiveShellEnv(); await resolveInteractiveShellEnv({
onProgress: (progress) => emitProgress(options, progress.phase, progress.message),
});
const enrichedPath = buildMergedCliPath(null); const enrichedPath = buildMergedCliPath(null);
const flavor = getConfiguredCliFlavor(); const flavor = getConfiguredCliFlavor();
emitProgress(options, 'flavor', `Using ${flavor} runtime mode...`);
const overrideRaw = const overrideRaw =
flavor === 'agent_teams_orchestrator' flavor === 'agent_teams_orchestrator'
@ -253,6 +282,7 @@ export class ClaudeBinaryResolver {
process.env.CLAUDE_CLI_PATH?.trim()) process.env.CLAUDE_CLI_PATH?.trim())
: process.env.CLAUDE_CLI_PATH?.trim(); : process.env.CLAUDE_CLI_PATH?.trim();
if (overrideRaw) { if (overrideRaw) {
emitProgress(options, 'configured-path', 'Checking configured runtime path...');
const looksLikePath = const looksLikePath =
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/'); path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
const resolvedOverride = looksLikePath const resolvedOverride = looksLikePath
@ -262,15 +292,18 @@ export class ClaudeBinaryResolver {
if (resolvedOverride) { if (resolvedOverride) {
cachedPath = resolvedOverride; cachedPath = resolvedOverride;
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
emitProgress(options, 'configured-path-found', 'Using configured runtime path...');
return cachedPath; return cachedPath;
} }
} }
if (flavor === 'agent_teams_orchestrator') { if (flavor === 'agent_teams_orchestrator') {
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
const bundledBinary = await resolveBundledOrchestratorBinary(); const bundledBinary = await resolveBundledOrchestratorBinary();
if (bundledBinary) { if (bundledBinary) {
cachedPath = bundledBinary; cachedPath = bundledBinary;
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
return cachedPath; return cachedPath;
} }
@ -279,17 +312,21 @@ export class ClaudeBinaryResolver {
// claude-multimodel on PATH without making this resolver guess a sibling // claude-multimodel on PATH without making this resolver guess a sibling
// repo name or folder. // repo name or folder.
const orchestratorBinaryName = 'claude-multimodel'; const orchestratorBinaryName = 'claude-multimodel';
emitProgress(options, 'path-runtime', 'Searching PATH for Agent Teams runtime...');
const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath); const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath);
if (fromPath) { if (fromPath) {
cachedPath = fromPath; cachedPath = fromPath;
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
emitProgress(options, 'path-runtime-found', 'Using Agent Teams runtime from PATH...');
return cachedPath; return cachedPath;
} }
emitProgress(options, 'doctor-runtime', 'Checking runtime diagnostics fallback...');
const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName); const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName);
if (fromDoctor) { if (fromDoctor) {
cachedPath = fromDoctor; cachedPath = fromDoctor;
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
emitProgress(options, 'doctor-runtime-found', 'Using runtime from diagnostics fallback...');
return cachedPath; return cachedPath;
} }
@ -300,10 +337,12 @@ export class ClaudeBinaryResolver {
} }
const baseBinaryName = 'claude'; const baseBinaryName = 'claude';
emitProgress(options, 'path-claude', 'Searching PATH for Claude CLI...');
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath); const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
if (fromPath) { if (fromPath) {
cachedPath = fromPath; cachedPath = fromPath;
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
emitProgress(options, 'path-claude-found', 'Using Claude CLI from PATH...');
return cachedPath; return cachedPath;
} }
@ -343,7 +382,11 @@ export class ClaudeBinaryResolver {
platformBinaryNames.map((name) => path.join(dir, name)) platformBinaryNames.map((name) => path.join(dir, name))
); );
emitProgress(options, 'standard-locations', 'Checking standard Claude install locations...');
const nvmCandidates = await collectNvmCandidates(); const nvmCandidates = await collectNvmCandidates();
if (nvmCandidates.length > 0) {
emitProgress(options, 'nvm-locations', 'Checking nvm-managed Claude installs...');
}
const allCandidates = [...candidates, ...nvmCandidates]; const allCandidates = [...candidates, ...nvmCandidates];
// Check all fallback candidates in parallel for speed // Check all fallback candidates in parallel for speed
@ -358,17 +401,29 @@ export class ClaudeBinaryResolver {
if (found) { if (found) {
cachedPath = found.path; cachedPath = found.path;
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
emitProgress(
options,
'fallback-location-found',
'Using Claude CLI from install locations...'
);
return cachedPath; return cachedPath;
} }
emitProgress(options, 'doctor-claude', 'Checking Claude diagnostics fallback...');
const fromDoctor = await resolveFromDoctorFallback(baseBinaryName); const fromDoctor = await resolveFromDoctorFallback(baseBinaryName);
if (fromDoctor) { if (fromDoctor) {
cachedPath = fromDoctor; cachedPath = fromDoctor;
cacheVerifiedAt = Date.now(); cacheVerifiedAt = Date.now();
emitProgress(options, 'doctor-claude-found', 'Using Claude CLI from diagnostics fallback...');
return cachedPath; return cachedPath;
} }
// Don't cache null — CLI may be installed later without app restart // Don't cache null — CLI may be installed later without app restart
emitProgress(
options,
'not-found',
'Runtime not found. Continuing with limited launch support...'
);
return null; return null;
} }
} }

View file

@ -203,15 +203,27 @@ export function summarizeProcessBootstrapTransportEvents(
export function buildProcessBootstrapPendingDiagnostic( export function buildProcessBootstrapPendingDiagnostic(
summary: ProcessBootstrapTransportSummary summary: ProcessBootstrapTransportSummary
): string { ): string {
if (summary.submitted) {
return summary.lastStage
? `Bootstrap prompt was submitted; waiting for bootstrap confirmation. Last transport stage: ${summary.lastStage}.`
: 'Bootstrap prompt was submitted; waiting for bootstrap confirmation.';
}
return summary.lastStage return summary.lastStage
? `Bootstrap transport reached ${summary.lastStage}; waiting for bootstrap confirmation.` ? `Bootstrap prompt has not been submitted yet. Last transport stage: ${summary.lastStage}.`
: 'Bootstrap transport is waiting for bootstrap confirmation.'; : 'Bootstrap prompt has not been submitted yet.';
} }
export function buildProcessBootstrapTimeoutDiagnostic( export function buildProcessBootstrapTimeoutDiagnostic(
summary: ProcessBootstrapTransportSummary summary: ProcessBootstrapTransportSummary
): string { ): string {
if (summary.submitted) {
return summary.lastStage
? `Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}`
: 'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout.';
}
return summary.lastStage return summary.lastStage
? `Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}` ? `Bootstrap prompt was not submitted before timeout. Last transport stage: ${summary.lastStage}`
: 'Teammate was registered but did not bootstrap-confirm before timeout.'; : 'Bootstrap prompt was not submitted before timeout.';
} }

View file

@ -16,6 +16,15 @@ export interface McpLaunchSpec {
args: string[]; args: string[];
} }
export interface McpLaunchSpecResolveProgress {
phase: string;
message: string;
}
export interface McpLaunchSpecResolveOptions {
onProgress?: (progress: McpLaunchSpecResolveProgress) => void;
}
const MCP_SERVER_NAME = 'agent-teams'; const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const logger = createLogger('Service:TeamMcpConfigBuilder'); const logger = createLogger('Service:TeamMcpConfigBuilder');
@ -158,15 +167,24 @@ export function clearResolvedNodePathForTests(): void {
_resolvedNodePath = undefined; _resolvedNodePath = undefined;
} }
function emitProgress(
options: McpLaunchSpecResolveOptions | undefined,
phase: string,
message: string
): void {
options?.onProgress?.({ phase, message });
}
/** /**
* Find the real `node` binary path. In Electron, process.execPath is the * Find the real `node` binary path. In Electron, process.execPath is the
* Electron binary NOT node so we must resolve node separately. * Electron binary NOT node so we must resolve node separately.
* Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency). * Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency).
*/ */
async function resolveNodePath(): Promise<string> { async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<string> {
if (_resolvedNodePath) return _resolvedNodePath; if (_resolvedNodePath) return _resolvedNodePath;
try { try {
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
const resolved = await new Promise<string>((resolve, reject) => { const resolved = await new Promise<string>((resolve, reject) => {
execFile( execFile(
'node', 'node',
@ -180,12 +198,14 @@ async function resolveNodePath(): Promise<string> {
}); });
if (resolved) { if (resolved) {
_resolvedNodePath = resolved; _resolvedNodePath = resolved;
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
return _resolvedNodePath; return _resolvedNodePath;
} }
} catch { } catch {
// node not found or timed out — use bare 'node' and let the OS resolve it // node not found or timed out — use bare 'node' and let the OS resolve it
} }
_resolvedNodePath = 'node'; _resolvedNodePath = 'node';
emitProgress(options, 'node-runtime-fallback', 'Using system Node.js command...');
return _resolvedNodePath; return _resolvedNodePath;
} }
@ -199,10 +219,11 @@ async function resolveNodePath(): Promise<string> {
* *
* Returns the resolved index.js path (stable copy or resourcesPath fallback). * Returns the resolved index.js path (stable copy or resourcesPath fallback).
*/ */
async function resolvePackagedServerEntry(): Promise<string> { async function resolvePackagedServerEntry(options?: McpLaunchSpecResolveOptions): Promise<string> {
const fallbackEntry = getPackagedServerEntry(); const fallbackEntry = getPackagedServerEntry();
if (!isPackagedApp()) return fallbackEntry; if (!isPackagedApp()) return fallbackEntry;
emitProgress(options, 'packaged-server', 'Checking packaged MCP server...');
const appVersion = getAppVersion(); const appVersion = getAppVersion();
const baseDir = getMcpServerBasePath(); const baseDir = getMcpServerBasePath();
const finalDir = path.join(baseDir, appVersion); const finalDir = path.join(baseDir, appVersion);
@ -210,6 +231,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
// Reuse existing valid copy // Reuse existing valid copy
if (await hasValidServerCopy(finalDir)) { if (await hasValidServerCopy(finalDir)) {
emitProgress(options, 'packaged-server-reuse', 'Using cached MCP server copy...');
return finalEntry; return finalEntry;
} }
@ -230,6 +252,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
return fallbackEntry; return fallbackEntry;
} }
emitProgress(options, 'packaged-server-copy', 'Copying MCP server to app data...');
// Atomic: copy to temp dir, then rename to final // Atomic: copy to temp dir, then rename to final
const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`); const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`);
await fs.promises.mkdir(tmpDir, { recursive: true }); await fs.promises.mkdir(tmpDir, { recursive: true });
@ -254,6 +277,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
} }
logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`); logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`);
emitProgress(options, 'packaged-server-ready', 'MCP server copy is ready...');
return finalEntry; return finalEntry;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@ -265,16 +289,18 @@ async function resolvePackagedServerEntry(): Promise<string> {
} }
} }
export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> { export async function resolveAgentTeamsMcpLaunchSpec(
options: McpLaunchSpecResolveOptions = {}
): Promise<McpLaunchSpec> {
const checked: string[] = []; const checked: string[] = [];
// 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath // 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath
if (isPackagedApp()) { if (isPackagedApp()) {
const packagedEntry = await resolvePackagedServerEntry(); const packagedEntry = await resolvePackagedServerEntry(options);
checked.push(packagedEntry); checked.push(packagedEntry);
if (await pathExists(packagedEntry)) { if (await pathExists(packagedEntry)) {
return { return {
command: await resolveNodePath(), command: await resolveNodePath(options),
args: [packagedEntry], args: [packagedEntry],
}; };
} }
@ -283,12 +309,14 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools // 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
const sourceEntry = getSourceServerEntry(); const sourceEntry = getSourceServerEntry();
emitProgress(options, 'source-entry', 'Checking MCP source entry...');
checked.push(sourceEntry); checked.push(sourceEntry);
if (await pathExists(sourceEntry)) { if (await pathExists(sourceEntry)) {
emitProgress(options, 'tsx-runner', 'Resolving MCP TypeScript runner...');
const tsxCli = await resolveWorkspaceTsxCli(checked); const tsxCli = await resolveWorkspaceTsxCli(checked);
if (tsxCli) { if (tsxCli) {
return { return {
command: await resolveNodePath(), command: await resolveNodePath(options),
args: [tsxCli, sourceEntry], args: [tsxCli, sourceEntry],
}; };
} }
@ -296,10 +324,11 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
// 3. Dev mode fallback — use built dist when source execution is unavailable // 3. Dev mode fallback — use built dist when source execution is unavailable
const builtEntry = getBuiltServerEntry(); const builtEntry = getBuiltServerEntry();
emitProgress(options, 'built-entry', 'Checking built MCP server entry...');
checked.push(builtEntry); checked.push(builtEntry);
if (await pathExists(builtEntry)) { if (await pathExists(builtEntry)) {
return { return {
command: await resolveNodePath(), command: await resolveNodePath(options),
args: [builtEntry], args: [builtEntry],
}; };
} }

View file

@ -42,9 +42,9 @@ import {
getTasksBasePath, getTasksBasePath,
getTeamsBasePath, getTeamsBasePath,
} from '@main/utils/pathDecoder'; } from '@main/utils/pathDecoder';
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { isProcessAlive } from '@main/utils/processHealth'; import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill'; import { killProcessByPid } from '@main/utils/processKill';
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import { import {
@ -157,15 +157,6 @@ import {
parseBootstrapRuntimeProofDetail, parseBootstrapRuntimeProofDetail,
validateBootstrapRuntimeProofEnvelope, validateBootstrapRuntimeProofEnvelope,
} from './bootstrap/BootstrapProofValidation'; } from './bootstrap/BootstrapProofValidation';
import {
buildProcessBootstrapPendingDiagnostic,
buildProcessBootstrapTimeoutDiagnostic,
deriveProcessTransportProjectionPhase,
sanitizeProcessRuntimeEventFilePrefix,
summarizeProcessBootstrapTransportEvents,
type ProcessBootstrapTransportEvent,
type ProcessBootstrapTransportSummary,
} from './ProcessBootstrapTransportEvidence';
import { import {
buildNativeAppManagedBootstrapSpecs, buildNativeAppManagedBootstrapSpecs,
type NativeAppManagedBootstrapSpec, type NativeAppManagedBootstrapSpec,
@ -247,6 +238,15 @@ import {
} from './idleNotificationMainProcessSemantics'; } from './idleNotificationMainProcessSemantics';
import { withInboxLock } from './inboxLock'; import { withInboxLock } from './inboxLock';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import {
buildProcessBootstrapPendingDiagnostic,
buildProcessBootstrapTimeoutDiagnostic,
deriveProcessTransportProjectionPhase,
type ProcessBootstrapTransportEvent,
type ProcessBootstrapTransportSummary,
sanitizeProcessRuntimeEventFilePrefix,
summarizeProcessBootstrapTransportEvents,
} from './ProcessBootstrapTransportEvidence';
import { import {
boundLaunchDiagnostics, boundLaunchDiagnostics,
buildProgressLiveOutput, buildProgressLiveOutput,
@ -289,6 +289,7 @@ import {
sanitizeProcessCommandForDiagnostics, sanitizeProcessCommandForDiagnostics,
} from './TeamRuntimeLivenessResolver'; } from './TeamRuntimeLivenessResolver';
import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskActivityIntervalService } from './TeamTaskActivityIntervalService';
import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskReader } from './TeamTaskReader';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
@ -5451,6 +5452,8 @@ export class TeamProvisioningService {
| null = null; | null = null;
private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly memberLogsFinder: TeamMemberLogsFinder;
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService();
private readonly crashRepairedActivityIntervalsByTeam = new Set<string>();
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
private helpOutputCache: string | null = null; private helpOutputCache: string | null = null;
private helpOutputCacheTime = 0; private helpOutputCacheTime = 0;
@ -5504,6 +5507,15 @@ export class TeamProvisioningService {
this.scheduleStaleAnthropicTeamApiKeyHelperCleanup(); this.scheduleStaleAnthropicTeamApiKeyHelperCleanup();
} }
private repairStaleTaskActivityIntervalsOnce(
teamName: string,
launchSnapshot?: PersistedTeamLaunchSnapshot | null
): void {
if (this.crashRepairedActivityIntervalsByTeam.has(teamName)) return;
this.taskActivityIntervalService.repairStaleIntervalsAfterCrash(teamName, launchSnapshot);
this.crashRepairedActivityIntervalsByTeam.add(teamName);
}
private scheduleStaleAnthropicTeamApiKeyHelperCleanup(): void { private scheduleStaleAnthropicTeamApiKeyHelperCleanup(): void {
void cleanupStaleAnthropicTeamApiKeyHelpers({ void cleanupStaleAnthropicTeamApiKeyHelpers({
baseClaudeDir: getClaudeBasePath(), baseClaudeDir: getClaudeBasePath(),
@ -12255,6 +12267,20 @@ export class TeamProvisioningService {
return; return;
} }
if (prev.runtimeAlive === true && next.runtimeAlive !== true) {
this.taskActivityIntervalService.pauseActiveIntervalsForMember(
run.teamName,
memberName,
updatedAt
);
} else if (prev.runtimeAlive !== true && next.runtimeAlive === true) {
this.taskActivityIntervalService.resumeActiveIntervalsForMember(
run.teamName,
memberName,
updatedAt
);
}
run.memberSpawnStatuses.set(memberName, next); run.memberSpawnStatuses.set(memberName, next);
if ( if (
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) || (status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
@ -12394,10 +12420,21 @@ export class TeamProvisioningService {
}> { }> {
const readPersistedStatuses = async (resolvedRunId: string | null) => { const readPersistedStatuses = async (resolvedRunId: string | null) => {
const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName); const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName);
this.repairStaleTaskActivityIntervalsOnce(teamName, snapshot);
const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, { const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, {
openCodeSecondaryBootstrapPendingMembers: openCodeSecondaryBootstrapPendingMembers:
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot), this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
}); });
const runtimeObservedAt = nowIso();
for (const [memberName, entry] of Object.entries(nextStatuses)) {
if (entry.runtimeAlive === true) {
this.taskActivityIntervalService.resumeActiveIntervalsForMember(
teamName,
memberName,
runtimeObservedAt
);
}
}
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined; const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
const summary = expectedMembers const summary = expectedMembers
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses) ? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
@ -16886,6 +16923,10 @@ export class TeamProvisioningService {
if (existingProvisioningRunId) { if (existingProvisioningRunId) {
return { runId: existingProvisioningRunId }; return { runId: existingProvisioningRunId };
} }
const previousLaunchSnapshot = await this.launchStateStore
.read(request.teamName)
.catch(() => null);
this.repairStaleTaskActivityIntervalsOnce(request.teamName, previousLaunchSnapshot);
const stopAllGenerationAtStart = this.stopAllTeamsGeneration; const stopAllGenerationAtStart = this.stopAllTeamsGeneration;
assertAppDeterministicBootstrapEnabled(); assertAppDeterministicBootstrapEnabled();
if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) { if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) {
@ -25806,6 +25847,7 @@ export class TeamProvisioningService {
*/ */
async stopTeam(teamName: string): Promise<void> { async stopTeam(teamName: string): Promise<void> {
this.invalidateRuntimeSnapshotCaches(teamName); this.invalidateRuntimeSnapshotCaches(teamName);
this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName);
this.stopPersistentTeamMembers(teamName); this.stopPersistentTeamMembers(teamName);
const runId = this.getTrackedRunId(teamName); const runId = this.getTrackedRunId(teamName);
@ -26301,6 +26343,7 @@ export class TeamProvisioningService {
if (orphanOnly.length > 0) { if (orphanOnly.length > 0) {
logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`); logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`);
for (const teamName of orphanOnly) { for (const teamName of orphanOnly) {
this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName);
this.stopPersistentTeamMembers(teamName); this.stopPersistentTeamMembers(teamName);
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
} }
@ -30828,7 +30871,14 @@ export class TeamProvisioningService {
return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode'; return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode';
}); });
if (configHasOpenCodeMember) { if (configHasOpenCodeMember) {
return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId); return this.buildConfigLaunchCompatibilityReport(
teamName,
configMembers,
leadProviderId,
{
ignoredInboxNames: true,
}
);
} }
const configMembersByName = new Map( const configMembersByName = new Map(
configMembers.map((member) => [member.name.toLowerCase(), member] as const) configMembers.map((member) => [member.name.toLowerCase(), member] as const)
@ -30915,7 +30965,8 @@ export class TeamProvisioningService {
private buildConfigLaunchCompatibilityReport( private buildConfigLaunchCompatibilityReport(
teamName: string, teamName: string,
configMembers: TeamCreateRequest['members'], configMembers: TeamCreateRequest['members'],
leadProviderId?: TeamProviderId leadProviderId?: TeamProviderId,
options: { ignoredInboxNames?: boolean } = {}
): TeamLaunchCompatibilityReport { ): TeamLaunchCompatibilityReport {
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) { if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
return { return {
@ -30957,8 +31008,10 @@ export class TeamProvisioningService {
rosterSource: 'config', rosterSource: 'config',
members: configMembers, members: configMembers,
warnings: [ warnings: [
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' + options.ignoredInboxNames
'Run a fresh team bootstrap to persist stable member metadata.', ? 'members.meta.json is missing; launch used complete config.json member metadata instead of inbox fallback to preserve mixed provider/model layout.'
: 'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
'Run a fresh team bootstrap to persist stable member metadata.',
], ],
blockers: [], blockers: [],
repairAction: 'materialize-members-meta', repairAction: 'materialize-members-meta',

View file

@ -0,0 +1,269 @@
import { getTasksBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { TeamTaskReader } from './TeamTaskReader';
import type {
PersistedTeamLaunchMemberState,
PersistedTeamLaunchSnapshot,
TaskReviewInterval,
TeamTask,
} from '@shared/types';
interface ActivityIntervalResult {
changedTasks: number;
}
type MutableTeamTask = TeamTask & {
reviewIntervals?: TaskReviewInterval[];
};
const CRASH_REPAIR_GRACE_MS = 5_000;
function normalizeMemberName(value: string | null | undefined): string {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
}
function parseIsoMs(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function toIso(ms: number): string {
return new Date(ms).toISOString();
}
function ensureCloseIso(startedAt: string, at: string): string {
const startedAtMs = parseIsoMs(startedAt);
const atMs = parseIsoMs(at);
if (startedAtMs <= 0) return at;
if (atMs <= startedAtMs) return toIso(startedAtMs);
return toIso(atMs);
}
function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemberState): string {
const startedAtMs = parseIsoMs(startedAt);
const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now();
const evidenceMs = Math.max(
parseIsoMs(member?.lastHeartbeatAt),
parseIsoMs(member?.runtimeLastSeenAt),
parseIsoMs(member?.lastRuntimeAliveAt)
);
const closeMs =
evidenceMs > 0
? Math.max(safeStartedAtMs, evidenceMs + CRASH_REPAIR_GRACE_MS)
: safeStartedAtMs + CRASH_REPAIR_GRACE_MS;
const boundedCloseMs = Math.max(safeStartedAtMs, Math.min(Date.now(), closeMs));
return toIso(boundedCloseMs);
}
function hasOpenWorkInterval(task: MutableTeamTask): boolean {
return (
Array.isArray(task.workIntervals) &&
task.workIntervals.some((interval) => !interval.completedAt)
);
}
function hasOpenReviewInterval(task: MutableTeamTask, reviewer: string): boolean {
const reviewerKey = normalizeMemberName(reviewer);
return (
Array.isArray(task.reviewIntervals) &&
task.reviewIntervals.some(
(interval) => !interval.completedAt && normalizeMemberName(interval.reviewer) === reviewerKey
)
);
}
function closeOpenWorkIntervals(task: MutableTeamTask, at: string, owner?: string): boolean {
if (!Array.isArray(task.workIntervals)) return false;
if (owner && normalizeMemberName(task.owner) !== normalizeMemberName(owner)) return false;
let changed = false;
task.workIntervals = task.workIntervals.map((interval) => {
if (interval.completedAt) return interval;
changed = true;
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
});
return changed;
}
function closeOpenReviewIntervals(task: MutableTeamTask, at: string, reviewer?: string): boolean {
if (!Array.isArray(task.reviewIntervals)) return false;
const reviewerKey = normalizeMemberName(reviewer);
let changed = false;
task.reviewIntervals = task.reviewIntervals.map((interval) => {
if (interval.completedAt) return interval;
if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval;
changed = true;
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
});
return changed;
}
function getActiveReviewActor(task: MutableTeamTask): string | null {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'review_started') {
return typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : null;
}
if (
event.type === 'review_approved' ||
event.type === 'review_changes_requested' ||
(event.type === 'status_changed' &&
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')) ||
event.type === 'task_created'
) {
return null;
}
}
return null;
}
function readTaskFile(filePath: string): MutableTeamTask | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null;
} catch {
return null;
}
}
function writeTaskFile(filePath: string, task: MutableTeamTask): void {
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(task, null, 2));
fs.renameSync(tempPath, filePath);
}
export class TeamTaskActivityIntervalService {
private mutateTeamTasks(
teamName: string,
mutate: (task: MutableTeamTask) => boolean
): ActivityIntervalResult {
const tasksDir = path.join(getTasksBasePath(), teamName);
let entries: string[];
try {
entries = fs.readdirSync(tasksDir);
} catch {
return { changedTasks: 0 };
}
let changedTasks = 0;
for (const fileName of entries) {
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
const filePath = path.join(tasksDir, fileName);
const task = readTaskFile(filePath);
if (!task) continue;
if (!mutate(task)) continue;
writeTaskFile(filePath, task);
changedTasks += 1;
}
if (changedTasks > 0) {
TeamTaskReader.invalidateAllTasksCache();
}
return { changedTasks };
}
pauseActiveIntervalsForTeam(
teamName: string,
at = new Date().toISOString()
): ActivityIntervalResult {
return this.mutateTeamTasks(teamName, (task) => {
const changedWork = closeOpenWorkIntervals(task, at);
const changedReview = closeOpenReviewIntervals(task, at);
return changedWork || changedReview;
});
}
pauseActiveIntervalsForMember(
teamName: string,
memberName: string,
at = new Date().toISOString()
): ActivityIntervalResult {
return this.mutateTeamTasks(teamName, (task) => {
const changedWork = closeOpenWorkIntervals(task, at, memberName);
const changedReview = closeOpenReviewIntervals(task, at, memberName);
return changedWork || changedReview;
});
}
resumeActiveIntervalsForMember(
teamName: string,
memberName: string,
at = new Date().toISOString()
): ActivityIntervalResult {
const memberKey = normalizeMemberName(memberName);
if (!memberKey) return { changedTasks: 0 };
return this.mutateTeamTasks(teamName, (task) => {
let changed = false;
if (
task.status === 'in_progress' &&
normalizeMemberName(task.owner) === memberKey &&
!hasOpenWorkInterval(task)
) {
task.workIntervals = [
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
{ startedAt: at },
];
changed = true;
}
const activeReviewer = getActiveReviewActor(task);
if (
activeReviewer &&
normalizeMemberName(activeReviewer) === memberKey &&
!hasOpenReviewInterval(task, activeReviewer)
) {
task.reviewIntervals = [
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
{ reviewer: activeReviewer, startedAt: at },
];
changed = true;
}
return changed;
});
}
repairStaleIntervalsAfterCrash(
teamName: string,
launchSnapshot?: PersistedTeamLaunchSnapshot | null
): ActivityIntervalResult {
const memberByName = new Map<string, PersistedTeamLaunchMemberState>();
for (const member of Object.values(launchSnapshot?.members ?? {})) {
memberByName.set(normalizeMemberName(member.name), member);
}
return this.mutateTeamTasks(teamName, (task) => {
let changed = false;
if (Array.isArray(task.workIntervals)) {
const ownerMember = memberByName.get(normalizeMemberName(task.owner));
task.workIntervals = task.workIntervals.map((interval) => {
if (interval.completedAt) return interval;
changed = true;
return { ...interval, completedAt: crashRepairCloseIso(interval.startedAt, ownerMember) };
});
}
if (Array.isArray(task.reviewIntervals)) {
task.reviewIntervals = task.reviewIntervals.map((interval) => {
if (interval.completedAt) return interval;
const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer));
changed = true;
return {
...interval,
completedAt: crashRepairCloseIso(interval.startedAt, reviewerMember),
};
});
}
return changed;
});
}
}

View file

@ -15,6 +15,7 @@ import type {
TaskComment, TaskComment,
TaskHistoryEvent, TaskHistoryEvent,
TaskRef, TaskRef,
TaskReviewInterval,
TaskWorkInterval, TaskWorkInterval,
TeamTask, TeamTask,
} from '@shared/types'; } from '@shared/types';
@ -194,6 +195,25 @@ export class TeamTaskReader {
completedAt: i.completedAt, completedAt: i.completedAt,
})) }))
: undefined; : undefined;
const reviewIntervals: TaskReviewInterval[] | undefined = Array.isArray(
parsed.reviewIntervals
)
? (parsed.reviewIntervals as unknown[])
.filter(
(i): i is { reviewer: string; startedAt: string; completedAt?: string } =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as Record<string, unknown>).reviewer === 'string' &&
typeof (i as Record<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
)
.map((i) => ({
reviewer: i.reviewer,
startedAt: i.startedAt,
completedAt: i.completedAt,
}))
: undefined;
const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
parsed.status as TeamTask['status'] parsed.status as TeamTask['status']
) )
@ -223,6 +243,7 @@ export class TeamTaskReader {
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status, status,
workIntervals, workIntervals,
reviewIntervals,
historyEvents, historyEvents,
blocks: Array.isArray(parsed.blocks) blocks: Array.isArray(parsed.blocks)
? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string') ? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')

View file

@ -2,10 +2,10 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
const LEGACY_USER_DATA_DIR_NAMES = [ const LEGACY_USER_DATA_DIR_NAMES = [
'Claude Agent Teams UI',
'claude-agent-teams-ui',
'agent-teams-ai', 'agent-teams-ai',
'Agent Teams UI', 'Agent Teams UI',
'Claude Agent Teams UI',
'claude-agent-teams-ui',
'claude-devtools', 'claude-devtools',
'claude-code-context', 'claude-code-context',
] as const; ] as const;
@ -72,6 +72,9 @@ const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([
'Trust Tokens-journal', 'Trust Tokens-journal',
]); ]);
const DURABLE_USER_DATA_ROOT_NAMES = new Set(['data', 'backups']);
const PREFERRED_USER_DATA_DIR_NAME = 'agent-teams-ai';
const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000; const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000;
export function getLegacyElectronUserDataCandidates(currentPath: string): string[] { export function getLegacyElectronUserDataCandidates(currentPath: string): string[] {
@ -104,6 +107,30 @@ export function migrateElectronUserDataDirectory(
}; };
} }
const preferredExistingPath = selectPreferredElectronUserDataPath(currentPath);
if (preferredExistingPath) {
try {
setLegacyElectronPaths(app, preferredExistingPath, logger);
logger?.info(`Reusing preferred Electron userData at ${preferredExistingPath}`);
return {
currentPath,
legacyPath: preferredExistingPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
};
} catch (error) {
logger?.warn(`Electron userData preferred reuse failed: ${stringifyError(error)}`);
return {
currentPath,
legacyPath: preferredExistingPath,
migrated: false,
fallbackToLegacy: false,
reason: 'error',
};
}
}
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) { if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
return { return {
currentPath, currentPath,
@ -209,15 +236,23 @@ export function migrateElectronUserDataDirectory(
} }
function selectLegacyElectronUserDataPath(currentPath: string): string | null { function selectLegacyElectronUserDataPath(currentPath: string): string | null {
const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists);
return ( return (
candidates.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? getLegacyElectronUserDataCandidates(currentPath)
candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ?? .filter(directoryExists)
candidates[0] ?? .find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? null
null
); );
} }
function selectPreferredElectronUserDataPath(currentPath: string): string | null {
const preferredPath = path.join(path.dirname(currentPath), PREFERRED_USER_DATA_DIR_NAME);
if (path.resolve(preferredPath) === path.resolve(currentPath)) {
return null;
}
return directoryExists(preferredPath) && directoryHasDurableUserDataEntries(preferredPath)
? preferredPath
: null;
}
function setLegacyElectronPaths( function setLegacyElectronPaths(
app: ElectronUserDataMigrationApp, app: ElectronUserDataMigrationApp,
legacyPath: string, legacyPath: string,
@ -252,7 +287,7 @@ function copyLegacyUserDataDirectory(
copyDirectory(legacyPath, tempPath); copyDirectory(legacyPath, tempPath);
if (directoryExists(currentPath) && !directoryHasEntries(currentPath)) { if (directoryExists(currentPath) && directoryIsEmpty(currentPath)) {
fs.rmdirSync(currentPath); fs.rmdirSync(currentPath);
} }
@ -360,9 +395,9 @@ function directoryExists(targetPath: string): boolean {
} }
} }
function directoryHasEntries(targetPath: string): boolean { function directoryIsEmpty(targetPath: string): boolean {
try { try {
return fs.readdirSync(targetPath).length > 0; return fs.readdirSync(targetPath).length === 0;
} catch { } catch {
return false; return false;
} }
@ -381,6 +416,12 @@ function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath:
for (const entry of entries) { for (const entry of entries) {
const entryPath = path.join(targetPath, entry.name); const entryPath = path.join(targetPath, entry.name);
const relativePath = path.relative(rootPath, entryPath);
const rootSegment = relativePath.split(path.sep).find(Boolean);
if (!rootSegment || !DURABLE_USER_DATA_ROOT_NAMES.has(rootSegment)) {
continue;
}
if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) { if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) {
continue; continue;
} }

View file

@ -20,6 +20,23 @@ const SHELL_ENV_TIMEOUT_MS = 12_000;
let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null; let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null;
let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null; let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
export interface ShellEnvResolveProgress {
phase: string;
message: string;
}
export interface ShellEnvResolveOptions {
onProgress?: (progress: ShellEnvResolveProgress) => void;
}
function emitProgress(
options: ShellEnvResolveOptions | undefined,
phase: string,
message: string
): void {
options?.onProgress?.({ phase, message });
}
function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv { function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv {
const parsed: NodeJS.ProcessEnv = {}; const parsed: NodeJS.ProcessEnv = {};
const lines = content.split('\0'); const lines = content.split('\0');
@ -95,14 +112,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
* Tries login shell first (`-lic`), falls back to interactive (`-ic`). * Tries login shell first (`-lic`), falls back to interactive (`-ic`).
* On Windows returns empty object. Result is cached after first success. * On Windows returns empty object. Result is cached after first success.
*/ */
export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> { export async function resolveInteractiveShellEnv(
options: ShellEnvResolveOptions = {}
): Promise<NodeJS.ProcessEnv> {
if (cachedInteractiveShellEnv) { if (cachedInteractiveShellEnv) {
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
return cachedInteractiveShellEnv; return cachedInteractiveShellEnv;
} }
if (shellEnvResolvePromise) { if (shellEnvResolvePromise) {
emitProgress(options, 'shell-env-waiting', 'Waiting for shell environment...');
return shellEnvResolvePromise; return shellEnvResolvePromise;
} }
if (process.platform === 'win32') { if (process.platform === 'win32') {
emitProgress(options, 'shell-env-skipped', 'Skipping shell environment on Windows...');
cachedInteractiveShellEnv = {}; cachedInteractiveShellEnv = {};
return cachedInteractiveShellEnv; return cachedInteractiveShellEnv;
} }
@ -110,6 +132,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
shellEnvResolvePromise = (async () => { shellEnvResolvePromise = (async () => {
const shellPath = process.env.SHELL || '/bin/zsh'; const shellPath = process.env.SHELL || '/bin/zsh';
try { try {
emitProgress(options, 'shell-env-login', 'Reading login shell environment...');
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']); const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
cachedInteractiveShellEnv = loginEnv; cachedInteractiveShellEnv = loginEnv;
return loginEnv; return loginEnv;
@ -117,6 +140,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError); const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
logger.warn(`Failed to resolve login shell env: ${loginMessage}`); logger.warn(`Failed to resolve login shell env: ${loginMessage}`);
try { try {
emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...');
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']); const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
cachedInteractiveShellEnv = interactiveEnv; cachedInteractiveShellEnv = interactiveEnv;
return interactiveEnv; return interactiveEnv;
@ -124,6 +148,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
const interactiveMessage = const interactiveMessage =
interactiveError instanceof Error ? interactiveError.message : String(interactiveError); interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`); logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
return {}; return {};
} }
} finally { } finally {

View file

@ -220,6 +220,7 @@ interface ParsedTask {
reviewState?: unknown; reviewState?: unknown;
metadata?: { _internal?: unknown }; metadata?: { _internal?: unknown };
workIntervals?: unknown; workIntervals?: unknown;
reviewIntervals?: unknown;
historyEvents?: unknown; historyEvents?: unknown;
attachments?: unknown; attachments?: unknown;
sourceMessageId?: unknown; sourceMessageId?: unknown;
@ -231,6 +232,12 @@ interface RawWorkInterval {
completedAt?: unknown; completedAt?: unknown;
} }
interface RawReviewInterval {
reviewer?: unknown;
startedAt?: unknown;
completedAt?: unknown;
}
interface RawHistoryEvent { interface RawHistoryEvent {
id?: unknown; id?: unknown;
type?: unknown; type?: unknown;
@ -1215,6 +1222,27 @@ function normalizeWorkIntervals(
})); }));
} }
function normalizeReviewIntervals(
parsed: ParsedTask
): { reviewer: string; startedAt: string; completedAt?: string }[] | undefined {
if (!Array.isArray(parsed.reviewIntervals)) return undefined;
return (parsed.reviewIntervals as unknown[])
.filter(
(i): i is RawReviewInterval =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as RawReviewInterval).reviewer === 'string' &&
typeof (i as RawReviewInterval).startedAt === 'string' &&
((i as RawReviewInterval).completedAt === undefined ||
typeof (i as RawReviewInterval).completedAt === 'string')
)
.map((i) => ({
reviewer: i.reviewer as string,
startedAt: i.startedAt as string,
completedAt: i.completedAt as string | undefined,
}));
}
function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefined { function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefined {
if (!Array.isArray(parsed.historyEvents)) return undefined; if (!Array.isArray(parsed.historyEvents)) return undefined;
return (parsed.historyEvents as unknown[]) return (parsed.historyEvents as unknown[])
@ -1479,6 +1507,7 @@ async function readTasksDirForTeam(
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status, status,
workIntervals: normalizeWorkIntervals(parsed), workIntervals: normalizeWorkIntervals(parsed),
reviewIntervals: normalizeReviewIntervals(parsed),
historyEvents: normalizeHistoryEvents(parsed), historyEvents: normalizeHistoryEvents(parsed),
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined, blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined, blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,

View file

@ -17,6 +17,12 @@ export const RENDERER_BOOT = 'renderer:boot';
/** Renderer -> main heartbeat (detect renderer stalls) */ /** Renderer -> main heartbeat (detect renderer stalls) */
export const RENDERER_HEARTBEAT = 'renderer:heartbeat'; export const RENDERER_HEARTBEAT = 'renderer:heartbeat';
/** Renderer -> main startup status request */
export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
/** Main -> renderer startup progress update */
export const APP_STARTUP_PROGRESS = 'appStartup:progress';
// ============================================================================= // =============================================================================
// Config API Channels // Config API Channels
// ============================================================================= // =============================================================================

View file

@ -14,6 +14,8 @@ import {
API_KEYS_SAVE, API_KEYS_SAVE,
API_KEYS_STORAGE_STATUS, API_KEYS_STORAGE_STATUS,
APP_RELAUNCH, APP_RELAUNCH,
APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS,
CLI_INSTALLER_GET_PROVIDER_STATUS, CLI_INSTALLER_GET_PROVIDER_STATUS,
CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL, CLI_INSTALLER_INSTALL,
@ -249,6 +251,7 @@ import type {
AppConfig, AppConfig,
ApplyReviewRequest, ApplyReviewRequest,
ApplyReviewResult, ApplyReviewResult,
AppStartupStatus,
AttachmentFileData, AttachmentFileData,
BoardTaskActivityDetailResult, BoardTaskActivityDetailResult,
BoardTaskActivityEntry, BoardTaskActivityEntry,
@ -480,6 +483,18 @@ const electronAPI: ElectronAPI = {
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer), runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer), memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
memberLogStream: createMemberLogStreamBridge(), memberLogStream: createMemberLogStreamBridge(),
startup: {
getStatus: () => ipcRenderer.invoke(APP_STARTUP_GET_STATUS) as Promise<AppStartupStatus>,
onProgress: (callback: (status: AppStartupStatus) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: AppStartupStatus): void => {
callback(status);
};
ipcRenderer.on(APP_STARTUP_PROGRESS, listener);
return (): void => {
ipcRenderer.removeListener(APP_STARTUP_PROGRESS, listener);
};
},
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getProjects: () => ipcRenderer.invoke('get-projects'), getProjects: () => ipcRenderer.invoke('get-projects'),
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),

View file

@ -895,7 +895,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
key={team.teamName} key={team.teamName}
role="button" role="button"
tabIndex={0} tabIndex={0}
className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-l-[3px] border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]" className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
style={teamColorSet ? { borderLeftColor: teamColorSet.border } : undefined} style={teamColorSet ? { borderLeftColor: teamColorSet.border } : undefined}
onClick={() => openTeamTab(team.teamName, team.projectPath)} onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => { onKeyDown={(e) => {

View file

@ -1380,6 +1380,18 @@ export const CreateTeamDialog = ({
[request, launchTeam] [request, launchTeam]
); );
const modelValidationError = useMemo(() => { const modelValidationError = useMemo(() => {
if (selectedProviderId === 'opencode') {
if (!selectedModel.trim()) {
return 'OpenCode lead requires a selected model.';
}
const activeMemberCount = soloTeam
? 0
: effectiveMemberDrafts.filter((member) => !member.removedAt && member.name.trim()).length;
if (activeMemberCount === 0) {
return 'OpenCode lead requires at least one OpenCode teammate.';
}
}
const leadError = getTeamModelSelectionError( const leadError = getTeamModelSelectionError(
selectedProviderId, selectedProviderId,
selectedModel, selectedModel,
@ -1409,7 +1421,13 @@ export const CreateTeamDialog = ({
} }
return null; return null;
}, [effectiveMemberDrafts, runtimeProviderStatusById, selectedModel, selectedProviderId]); }, [
effectiveMemberDrafts,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
soloTeam,
]);
const leadModelIssueText = useMemo(() => { const leadModelIssueText = useMemo(() => {
const issue = getProvisioningModelIssue( const issue = getProvisioningModelIssue(
prepareChecks, prepareChecks,

View file

@ -138,8 +138,8 @@ import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibili
import { import {
computeEffectiveTeamModel, computeEffectiveTeamModel,
formatTeamModelSummary, formatTeamModelSummary,
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL, OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL,
OPENCODE_TEAM_LEAD_DISABLED_REASON, OPENCODE_ONE_SHOT_DISABLED_REASON,
TeamModelSelector, TeamModelSelector,
} from './TeamModelSelector'; } from './TeamModelSelector';
import { import {
@ -249,6 +249,14 @@ function getStoredTeamProvider(): TeamProviderId {
return normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true); return normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true);
} }
function normalizeOneShotProviderForMode(
providerId: TeamProviderId | undefined,
multimodelEnabled: boolean
): TeamProviderId {
const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled);
return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId;
}
function getStoredTeamModel(providerId: TeamProviderId): string { function getStoredTeamModel(providerId: TeamProviderId): string {
const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`); const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
if (stored === null) { if (stored === null) {
@ -412,10 +420,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() => const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() =>
normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled) isLaunchMode
? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
: normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled)
); );
const [selectedModel, setSelectedModelRaw] = useState(() => const [selectedModel, setSelectedModelRaw] = useState(() =>
getStoredTeamModel(normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)) getStoredTeamModel(
isLaunchMode
? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
: normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled)
)
); );
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]); const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false); const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false);
@ -623,7 +637,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}; };
const setSelectedProviderId = (value: TeamProviderId): void => { const setSelectedProviderId = (value: TeamProviderId): void => {
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled); const normalizedValue = isLaunchMode
? normalizeLeadProviderForMode(value, multimodelEnabled)
: normalizeOneShotProviderForMode(value, multimodelEnabled);
setSelectedProviderIdRaw(normalizedValue); setSelectedProviderIdRaw(normalizedValue);
localStorage.setItem('team:lastSelectedProvider', normalizedValue); localStorage.setItem('team:lastSelectedProvider', normalizedValue);
if (normalizedValue !== 'anthropic') { if (normalizedValue !== 'anthropic') {
@ -736,15 +752,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
promptDraft.setValue(schedule.launchConfig.prompt); promptDraft.setValue(schedule.launchConfig.prompt);
setCustomCwd(schedule.launchConfig.cwd); setCustomCwd(schedule.launchConfig.cwd);
setCwdMode('custom'); setCwdMode('custom');
const scheduleProviderId = normalizeLeadProviderForMode( const scheduleProviderId = normalizeOneShotProviderForMode(
schedule.launchConfig.providerId, schedule.launchConfig.providerId,
multimodelEnabled multimodelEnabled
); );
const scheduleSourceProviderId = normalizeOptionalTeamProviderId(
schedule.launchConfig.providerId
);
setSelectedProviderIdRaw(scheduleProviderId); setSelectedProviderIdRaw(scheduleProviderId);
setSelectedModelRaw( setSelectedModelRaw(
schedule.launchConfig.providerId !== 'gemini' && scheduleSourceProviderId !== 'gemini' &&
scheduleSourceProviderId !== 'opencode' &&
scheduleProviderId === scheduleProviderId ===
normalizeLeadProviderForMode(schedule.launchConfig.providerId, true) normalizeOneShotProviderForMode(schedule.launchConfig.providerId, true)
? (schedule.launchConfig.model ?? '') ? (schedule.launchConfig.model ?? '')
: getStoredTeamModel('anthropic') : getStoredTeamModel('anthropic')
); );
@ -765,7 +785,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setCwdMode('project'); setCwdMode('project');
setSelectedProjectPath(''); setSelectedProjectPath('');
setCustomCwd(''); setCustomCwd('');
const storedProviderId = normalizeLeadProviderForMode( const storedProviderId = normalizeOneShotProviderForMode(
getStoredTeamProvider(), getStoredTeamProvider(),
multimodelEnabled multimodelEnabled
); );
@ -1825,6 +1845,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
cronExpression, cronExpression,
]); ]);
const modelValidationError = useMemo(() => { const modelValidationError = useMemo(() => {
if (isLaunchMode && selectedProviderId === 'opencode') {
if (!selectedModel.trim()) {
return 'OpenCode lead requires a selected model.';
}
const activeMemberCount = effectiveMemberDrafts.filter(
(member) => !member.removedAt && member.name.trim()
).length;
if (activeMemberCount === 0) {
return 'OpenCode lead requires at least one OpenCode teammate.';
}
}
const leadError = getTeamModelSelectionError( const leadError = getTeamModelSelectionError(
selectedProviderId, selectedProviderId,
selectedModel, selectedModel,
@ -2674,10 +2706,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
id="dialog-model" id="dialog-model"
disableGeminiOption={isGeminiUiFrozen()} disableGeminiOption={isGeminiUiFrozen()}
providerDisabledReasonById={{ providerDisabledReasonById={{
opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON, opencode: OPENCODE_ONE_SHOT_DISABLED_REASON,
}} }}
providerDisabledBadgeLabelById={{ providerDisabledBadgeLabelById={{
opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL, opencode: OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL,
}} }}
/> />
<EffortLevelSelector <EffortLevelSelector

View file

@ -66,9 +66,9 @@ const PROVIDERS: ProviderDef[] = [
]; ];
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.'; const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
export const OPENCODE_TEAM_LEAD_DISABLED_REASON = export const OPENCODE_ONE_SHOT_DISABLED_REASON =
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.'; 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.';
export const OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL = 'side lane'; export const OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL = 'team only';
export function getTeamModelLabel(model: string): string { export function getTeamModelLabel(model: string): string {
return getCatalogTeamModelLabel(model) ?? model; return getCatalogTeamModelLabel(model) ?? model;
@ -118,9 +118,9 @@ export function formatTeamModelSummary(
/** /**
* Computes the effective model string for team provisioning. * Computes the effective model string for team provisioning.
* By default adds [1m] suffix for 1M context (Opus/Sonnet). * By default adds [1m] suffix for Opus 1M context.
* When limitContext=true, returns base model without [1m] (200K context). * When limitContext=true, returns base model without [1m] (200K context).
* Haiku does not support 1M always returned as-is. * Sonnet and Haiku default to standard context to avoid extra-usage-only variants.
*/ */
export function computeEffectiveTeamModel( export function computeEffectiveTeamModel(
selectedModel: string, selectedModel: string,

View file

@ -254,7 +254,7 @@ export function analyzeTeammateRuntimeCompatibility({
details.push( details.push(
names names
? `OpenCode-led mixed team: ${names} use a non-OpenCode provider.` ? `OpenCode-led mixed team: ${names} use a non-OpenCode provider.`
: 'OpenCode-led mixed teams are not supported in this phase.' : 'Mixed teams cannot use OpenCode as the lead in this phase.'
); );
} }
if (hasCodexNative) { if (hasCodexNative) {
@ -317,7 +317,7 @@ export function analyzeTeammateRuntimeCompatibility({
message: checking message: checking
? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.' ? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.'
: hasOpenCodeLeadMixedUnsupported : hasOpenCodeLeadMixedUnsupported
? 'OpenCode teammates can run as secondary runtime lanes under an Anthropic, Codex, or Gemini lead, but OpenCode-led mixed teams are not supported in this phase.' ? 'OpenCode can be added as a teammate under an Anthropic, Codex, or Gemini lead, but mixed teams cannot use OpenCode as the lead in this phase.'
: hasExplicitInProcess : hasExplicitInProcess
? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.' ? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.'
: 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.', : 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.',

View file

@ -20,9 +20,6 @@ vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default', getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
getTeamProviderLabel: (providerId: string) => providerId, getTeamProviderLabel: (providerId: string) => providerId,
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane',
OPENCODE_TEAM_LEAD_DISABLED_REASON:
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'), TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
})); }));

View file

@ -6,8 +6,6 @@ import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitCon
import { import {
getProviderScopedTeamModelLabel, getProviderScopedTeamModelLabel,
getTeamProviderLabel, getTeamProviderLabel,
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL,
OPENCODE_TEAM_LEAD_DISABLED_REASON,
TeamModelSelector, TeamModelSelector,
} from '@renderer/components/team/dialogs/TeamModelSelector'; } from '@renderer/components/team/dialogs/TeamModelSelector';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
@ -151,8 +149,6 @@ export const LeadModelRow = ({
onValueChange={onModelChange} onValueChange={onModelChange}
id="lead-model" id="lead-model"
disableGeminiOption={disableGeminiOption} disableGeminiOption={disableGeminiOption}
providerDisabledReasonById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON }}
providerDisabledBadgeLabelById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL }}
modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined} modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined}
/> />
<EffortLevelSelector <EffortLevelSelector

View file

@ -20,6 +20,7 @@ import {
buildMemberLaunchDiagnosticsPayload, buildMemberLaunchDiagnosticsPayload,
hasMemberLaunchDiagnosticsDetails, hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError, hasMemberLaunchDiagnosticsError,
normalizeMemberLaunchFailureReason,
} from '@renderer/utils/memberLaunchDiagnostics'; } from '@renderer/utils/memberLaunchDiagnostics';
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary'; import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
@ -97,15 +98,6 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
}; };
} }
function normalizeLaunchFailureReason(value: string | undefined): string | null {
const normalized = value
?.replace(/\s+/g, ' ')
.trim()
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
.replace(/^APIError\s*[-:]\s*/i, '');
return normalized && normalized.length > 0 ? normalized : null;
}
function getLaunchFailureLinkLabel(url: string): string { function getLaunchFailureLinkLabel(url: string): string {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
@ -180,6 +172,8 @@ export const MemberCard = memo(function MemberCard({
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled, spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawnEntry?.updatedAt,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
@ -197,6 +191,7 @@ export const MemberCard = memo(function MemberCard({
const launchStatusLabel = launchPresentation.launchStatusLabel; const launchStatusLabel = launchPresentation.launchStatusLabel;
const displayPresenceLabel = const displayPresenceLabel =
launchVisualState === 'queued' || launchVisualState === 'queued' ||
launchVisualState === 'starting_stale' ||
launchVisualState === 'bootstrap_stalled' || launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' || launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
@ -243,6 +238,7 @@ export const MemberCard = memo(function MemberCard({
(presenceLabel === 'starting' || (presenceLabel === 'starting' ||
presenceLabel === 'connecting' || presenceLabel === 'connecting' ||
launchVisualState === 'queued' || launchVisualState === 'queued' ||
launchVisualState === 'starting_stale' ||
launchVisualState === 'runtime_pending' || launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' || launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' || launchVisualState === 'runtime_candidate' ||
@ -289,7 +285,7 @@ export const MemberCard = memo(function MemberCard({
spawnEntry?.runtimeDiagnostic ?? spawnEntry?.runtimeDiagnostic ??
spawnEntry?.error; spawnEntry?.error;
const launchFailureReason = showFailedLaunchBadge const launchFailureReason = showFailedLaunchBadge
? normalizeLaunchFailureReason(rawLaunchFailureReason) ? normalizeMemberLaunchFailureReason(rawLaunchFailureReason)
: null; : null;
const hasLiveLaunchControls = const hasLiveLaunchControls =
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
@ -523,10 +519,17 @@ export const MemberCard = memo(function MemberCard({
className="flex shrink-0 items-center gap-1" className="flex shrink-0 items-center gap-1"
title={runtimeEntry?.runtimeDiagnostic} title={runtimeEntry?.runtimeDiagnostic}
> >
<SyncedLoader2 {launchVisualState === 'starting_stale' ? (
className="size-3.5 shrink-0 text-[var(--color-text-muted)]" <AlertTriangle
aria-label={launchBadgeLabel} className="size-3.5 shrink-0 text-amber-400"
/> aria-label={launchBadgeLabel}
/>
) : (
<SyncedLoader2
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
aria-label={launchBadgeLabel}
/>
)}
<Badge <Badge
variant="secondary" variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]" className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"

View file

@ -289,6 +289,8 @@ export const MemberDetailDialog = ({
spawnRuntimeAlive={spawnEntry?.runtimeAlive} spawnRuntimeAlive={spawnEntry?.runtimeAlive}
spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed} spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed}
spawnBootstrapStalled={spawnEntry?.bootstrapStalled} spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
spawnFirstSpawnAcceptedAt={spawnEntry?.firstSpawnAcceptedAt}
spawnUpdatedAt={spawnEntry?.updatedAt}
runtimeEntry={runtimeEntry} runtimeEntry={runtimeEntry}
isLaunchSettling={isLaunchSettling} isLaunchSettling={isLaunchSettling}
onUpdateRole={ onUpdateRole={

View file

@ -40,6 +40,8 @@ interface MemberDetailHeaderProps {
spawnRuntimeAlive?: boolean; spawnRuntimeAlive?: boolean;
spawnBootstrapConfirmed?: boolean; spawnBootstrapConfirmed?: boolean;
spawnBootstrapStalled?: boolean; spawnBootstrapStalled?: boolean;
spawnFirstSpawnAcceptedAt?: string;
spawnUpdatedAt?: string;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void; onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
updatingRole?: boolean; updatingRole?: boolean;
@ -58,6 +60,8 @@ export const MemberDetailHeader = ({
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapConfirmed, spawnBootstrapConfirmed,
spawnBootstrapStalled, spawnBootstrapStalled,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
isLaunchSettling, isLaunchSettling,
onUpdateRole, onUpdateRole,
updatingRole, updatingRole,
@ -85,6 +89,8 @@ export const MemberDetailHeader = ({
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapConfirmed, spawnBootstrapConfirmed,
spawnBootstrapStalled, spawnBootstrapStalled,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
@ -102,7 +108,8 @@ export const MemberDetailHeader = ({
const badgeLabel = const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel ? runtimeAdvisoryLabel
: launchVisualState === 'bootstrap_stalled' || : launchVisualState === 'starting_stale' ||
launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' || launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' || launchVisualState === 'shell_only' ||

View file

@ -159,6 +159,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({
spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled, spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawnEntry?.updatedAt,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
@ -176,7 +178,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({
const badgeLabel = const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel ? runtimeAdvisoryLabel
: launchVisualState === 'bootstrap_stalled' || : launchVisualState === 'starting_stale' ||
launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' || launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' || launchVisualState === 'shell_only' ||

View file

@ -122,6 +122,24 @@ function areTaskWorkIntervalsEquivalent(
}); });
} }
function areTaskReviewIntervalsEquivalent(
left: TeamTaskWithKanban['reviewIntervals'],
right: TeamTaskWithKanban['reviewIntervals']
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.length !== right.length) return false;
return left.every((interval, index) => {
const other = right[index];
if (!other) return false;
return (
interval.reviewer === other.reviewer &&
interval.startedAt === other.startedAt &&
interval.completedAt === other.completedAt
);
});
}
function areTaskHistoryEventsEquivalent( function areTaskHistoryEventsEquivalent(
left: TeamTaskWithKanban['historyEvents'], left: TeamTaskWithKanban['historyEvents'],
right: TeamTaskWithKanban['historyEvents'] right: TeamTaskWithKanban['historyEvents']
@ -166,6 +184,7 @@ function areMemberTaskMapsEquivalent(
leftTask.reviewState !== rightTask.reviewState || leftTask.reviewState !== rightTask.reviewState ||
leftTask.kanbanColumn !== rightTask.kanbanColumn || leftTask.kanbanColumn !== rightTask.kanbanColumn ||
!areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) || !areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) ||
!areTaskReviewIntervalsEquivalent(leftTask.reviewIntervals, rightTask.reviewIntervals) ||
!areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents) !areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents)
) { ) {
return false; return false;

View file

@ -114,8 +114,7 @@ export function normalizeLeadProviderForMode(
providerId: TeamProviderId | undefined, providerId: TeamProviderId | undefined,
multimodelEnabled: boolean multimodelEnabled: boolean
): TeamProviderId { ): TeamProviderId {
const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled); return normalizeProviderForMode(providerId, multimodelEnabled);
return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId;
} }
export function normalizeMemberDraftForProviderMode( export function normalizeMemberDraftForProviderMode(

View file

@ -237,11 +237,155 @@
animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards; animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards;
will-change: clip-path; will-change: clip-path;
} }
#splash-status-row {
display: flex;
width: min(320px, 78vw);
margin-top: 14px;
align-items: baseline;
justify-content: center;
gap: 6px;
}
#splash-status {
min-height: 16px;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 12px;
font-weight: 500;
color: rgba(212, 212, 216, 0.72);
line-height: 1.35;
overflow-wrap: anywhere;
}
#splash-elapsed::before {
content: '·';
margin-right: 6px;
color: rgba(212, 212, 216, 0.34);
}
#splash-elapsed {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
font-size: 11px;
font-weight: 500;
color: rgba(212, 212, 216, 0.5);
white-space: nowrap;
}
#splash-hint {
width: min(320px, 78vw);
min-height: 15px;
margin-top: 6px;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 11px;
color: rgba(212, 212, 216, 0.54);
line-height: 1.35;
}
#splash-timeline {
display: flex;
width: min(320px, 78vw);
max-height: 128px;
margin-top: 14px;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.splash-step {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
opacity: 0.56;
}
.splash-step.is-current {
opacity: 1;
}
.splash-step-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: rgba(212, 212, 216, 0.42);
}
.splash-step.is-current .splash-step-dot {
background: #a78bfa;
box-shadow: 0 0 12px rgba(167, 139, 250, 0.55);
}
.splash-step-label,
.splash-step-time {
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 11px;
line-height: 1.25;
}
.splash-step-label {
min-width: 0;
overflow: hidden;
color: rgba(212, 212, 216, 0.62);
text-overflow: ellipsis;
white-space: nowrap;
}
.splash-step.is-current .splash-step-label {
color: rgba(244, 244, 245, 0.86);
}
.splash-step-time {
color: rgba(212, 212, 216, 0.44);
white-space: nowrap;
}
#splash-progress {
position: relative;
width: min(240px, 64vw);
height: 3px;
margin-top: 10px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
}
#splash-progress-bar {
position: absolute;
inset: 0 auto 0 0;
width: 38%;
border-radius: inherit;
background: linear-gradient(90deg, #818cf8, #c084fc);
animation: splash-progress 1.2s ease-in-out infinite;
}
#splash.splash-status-error #splash-status {
color: #fca5a5;
}
#splash.splash-status-slow #splash-status {
color: #fde68a;
}
#splash.splash-status-error #splash-progress-bar {
background: #f87171;
animation: none;
}
@keyframes splash-tagline-type { @keyframes splash-tagline-type {
to { to {
clip-path: inset(0 0 0 0); clip-path: inset(0 0 0 0);
} }
} }
@keyframes splash-progress {
0% {
transform: translateX(-110%);
}
55% {
transform: translateX(90%);
}
100% {
transform: translateX(260%);
}
}
/* Logo node breathing - cycles through 3 agent nodes */ /* Logo node breathing - cycles through 3 agent nodes */
@keyframes splash-node { @keyframes splash-node {
@ -284,6 +428,26 @@
:root.light #splash-tagline { :root.light #splash-tagline {
color: rgba(63, 63, 70, 0.66); color: rgba(63, 63, 70, 0.66);
} }
:root.light #splash-status {
color: rgba(63, 63, 70, 0.7);
}
:root.light #splash-elapsed,
:root.light .splash-step-time {
color: rgba(63, 63, 70, 0.48);
}
:root.light #splash-hint,
:root.light .splash-step-label {
color: rgba(63, 63, 70, 0.58);
}
:root.light .splash-step.is-current .splash-step-label {
color: rgba(39, 39, 42, 0.82);
}
:root.light .splash-step-dot {
background: rgba(63, 63, 70, 0.34);
}
:root.light #splash-progress {
background: rgba(79, 70, 229, 0.14);
}
:root.light #splash-noise { :root.light #splash-noise {
opacity: 0.02; opacity: 0.02;
} }
@ -307,6 +471,7 @@
#splash, #splash,
#splash-enhanced-canvas, #splash-enhanced-canvas,
#splash-logo, #splash-logo,
#splash-progress-bar,
#splash-tagline > span, #splash-tagline > span,
.splash-node { .splash-node {
animation: none !important; animation: none !important;
@ -446,6 +611,13 @@
<div id="splash-copy"> <div id="splash-copy">
<div id="splash-text">Agent Teams AI</div> <div id="splash-text">Agent Teams AI</div>
<div id="splash-tagline"><span>Get more done by doing less.</span></div> <div id="splash-tagline"><span>Get more done by doing less.</span></div>
<div id="splash-status-row">
<div id="splash-status" aria-live="polite">Preparing workspace...</div>
<div id="splash-elapsed">0s</div>
</div>
<div id="splash-hint" aria-live="polite"></div>
<div id="splash-progress" aria-hidden="true"><div id="splash-progress-bar"></div></div>
<div id="splash-timeline" aria-label="Startup steps"></div>
</div> </div>
</div> </div>
<div id="root"></div> <div id="root"></div>

View file

@ -9,6 +9,8 @@ import { App } from './App';
import { initSentryRenderer } from './sentry'; import { initSentryRenderer } from './sentry';
import { initializeNotificationListeners } from './store'; import { initializeNotificationListeners } from './store';
import type { AppStartupStatus, AppStartupStep } from '@shared/types/api';
declare global { declare global {
interface Window { interface Window {
__claudeTeamsUiDidInit?: boolean; __claudeTeamsUiDidInit?: boolean;
@ -18,16 +20,199 @@ declare global {
// Sentry must be initialised before React renders. // Sentry must be initialised before React renders.
initSentryRenderer(); initSentryRenderer();
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev, let root: ReactDOM.Root | null = null;
// which can start duplicate IPC init chains. Make initialization a one-time let latestStartupStatus: AppStartupStatus | null = null;
// module-level side effect guarded by a global flag. let startupTicker: number | undefined;
if (!window.__claudeTeamsUiDidInit) {
window.__claudeTeamsUiDidInit = true; const SLOW_STEP_MS = 7_000;
initializeNotificationListeners(); const VERY_SLOW_STEP_MS = 14_000;
const TIMELINE_STEP_LIMIT = 6;
function getStartupErrorText(status: AppStartupStatus): string {
return status.error ? `Startup failed: ${status.error}` : 'Startup failed. Please restart.';
} }
ReactDOM.createRoot(document.getElementById('root')!).render( function formatDuration(ms: number): string {
<React.StrictMode> const safeMs = Math.max(0, ms);
<App /> const seconds = Math.floor(safeMs / 1000);
</React.StrictMode> if (seconds < 60) {
); return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${minutes}m ${rest.toString().padStart(2, '0')}s`;
}
function getCurrentStartupStep(status: AppStartupStatus): AppStartupStep | null {
const steps = status.steps ?? [];
const active = [...steps].reverse().find((step) => !step.finishedAt);
return active ?? steps[steps.length - 1] ?? null;
}
function getStepElapsedMs(step: AppStartupStep | null, status: AppStartupStatus): number {
if (!step) {
return Date.now() - status.startedAt;
}
return step.finishedAt ? step.finishedAt - step.startedAt : Date.now() - step.startedAt;
}
function getSlowStepHint(step: AppStartupStep | null, elapsedMs: number): string {
if (!step || step.finishedAt || elapsedMs < SLOW_STEP_MS) {
return '';
}
const phase = step.phase;
if (phase.includes('shell-env-login') || phase.includes('shell-env-interactive')) {
return elapsedMs >= VERY_SLOW_STEP_MS
? 'Shell startup is still running. Slow shell profile scripts can delay first launch.'
: 'Reading your shell PATH. This can take a few seconds on first launch.';
}
if (phase.includes('node-runtime')) {
return 'Checking Node.js for the local MCP server. This can wait up to 5 seconds.';
}
if (phase.includes('packaged-server-copy')) {
return 'Preparing the packaged MCP server copy. This should only happen after updates.';
}
if (phase.includes('path') || phase.includes('standard-locations') || phase.includes('nvm')) {
return 'Searching local runtime paths. A large PATH or slow disk can make this step longer.';
}
if (phase.includes('doctor')) {
return 'Using diagnostics fallback to locate the runtime.';
}
if (phase.includes('settings')) {
return 'Loading encrypted local settings.';
}
return 'Still working on this startup step.';
}
function renderStartupTimeline(status: AppStartupStatus): void {
const timeline = document.getElementById('splash-timeline');
if (!timeline) return;
const steps = (status.steps ?? []).slice(-TIMELINE_STEP_LIMIT);
timeline.replaceChildren();
for (const step of steps) {
const row = document.createElement('div');
const isCurrent = !step.finishedAt && !status.ready && !status.error;
row.className = `splash-step${isCurrent ? ' is-current' : ''}`;
const dot = document.createElement('div');
dot.className = 'splash-step-dot';
const label = document.createElement('div');
label.className = 'splash-step-label';
label.textContent = step.message;
label.title = step.message;
const time = document.createElement('div');
time.className = 'splash-step-time';
time.textContent = formatDuration(getStepElapsedMs(step, status));
row.append(dot, label, time);
timeline.append(row);
}
}
function updateStartupSplash(status: AppStartupStatus): void {
const splash = document.getElementById('splash');
const statusElement = document.getElementById('splash-status');
const elapsedElement = document.getElementById('splash-elapsed');
const hintElement = document.getElementById('splash-hint');
if (!splash || !statusElement) return;
latestStartupStatus = status;
const currentStep = getCurrentStartupStep(status);
const elapsedMs = getStepElapsedMs(currentStep, status);
const hint = getSlowStepHint(currentStep, elapsedMs);
splash.classList.toggle('splash-status-error', Boolean(status.error) && !status.ready);
splash.classList.toggle('splash-status-slow', Boolean(hint) && !status.error && !status.ready);
statusElement.textContent =
status.error && !status.ready
? getStartupErrorText(status)
: (currentStep?.message ?? status.message);
if (elapsedElement) {
elapsedElement.textContent = formatDuration(elapsedMs);
}
if (hintElement) {
hintElement.textContent = status.error || status.ready ? '' : hint;
}
renderStartupTimeline(status);
}
function startStartupTicker(): void {
if (startupTicker !== undefined) return;
startupTicker = window.setInterval(() => {
if (latestStartupStatus) {
updateStartupSplash(latestStartupStatus);
}
}, 1000);
}
function stopStartupTicker(): void {
if (startupTicker === undefined) return;
window.clearInterval(startupTicker);
startupTicker = undefined;
}
function mountApp(): void {
if (root) return;
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev,
// which can start duplicate IPC init chains. Make initialization a one-time
// module-level side effect guarded by a global flag.
if (!window.__claudeTeamsUiDidInit) {
window.__claudeTeamsUiDidInit = true;
initializeNotificationListeners();
}
root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
async function bootstrapRenderer(): Promise<void> {
const startupApi = window.electronAPI?.startup;
if (!startupApi) {
mountApp();
return;
}
let cleanup = (): void => undefined;
try {
let finished = false;
const handleStartupStatus = (nextStatus: AppStartupStatus): void => {
if (finished) {
return;
}
updateStartupSplash(nextStatus);
if (nextStatus.ready) {
finished = true;
cleanup();
stopStartupTicker();
mountApp();
} else if (nextStatus.error) {
finished = true;
cleanup();
stopStartupTicker();
} else {
startStartupTicker();
}
};
cleanup = startupApi.onProgress(handleStartupStatus);
handleStartupStatus(await startupApi.getStatus());
} catch (error) {
console.warn(`[startup] status bridge unavailable: ${String(error)}`);
cleanup();
stopStartupTicker();
mountApp();
}
}
void bootstrapRenderer();

View file

@ -2239,7 +2239,57 @@ function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
} }
normalizedAssignments[stableOwnerId] = assignment; normalizedAssignments[stableOwnerId] = assignment;
} }
return normalizedAssignments; return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds);
}
function normalizeLegacySixRowOrbitAssignments(
assignments: TeamGraphSlotAssignments,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments {
if (visibleOwnerIds.length !== 6) {
return assignments;
}
const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => {
const assignment = assignments[stableOwnerId];
return assignment ? [assignment] : [];
});
const hasLegacyTwoRowBottomMarker = visibleAssignments.some(
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
);
let changed = false;
const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments };
for (const stableOwnerId of visibleOwnerIds) {
const assignment = normalizedAssignments[stableOwnerId];
if (!assignment) {
continue;
}
if (
hasLegacyTwoRowBottomMarker &&
assignment.ringIndex === 1 &&
assignment.sectorIndex >= 0 &&
assignment.sectorIndex < 3
) {
normalizedAssignments[stableOwnerId] = {
ringIndex: 2,
sectorIndex: assignment.sectorIndex,
};
changed = true;
continue;
}
if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) {
normalizedAssignments[stableOwnerId] = {
ringIndex: 2,
sectorIndex: assignment.sectorIndex - 3,
};
changed = true;
}
}
return changed ? normalizedAssignments : assignments;
} }
function pruneTeamGraphSlotAssignmentsForVisibleOwners( function pruneTeamGraphSlotAssignmentsForVisibleOwners(

View file

@ -332,6 +332,47 @@ export function deriveReviewActivityTimerAnchor(
const memberKey = normalizeMemberName(params.memberName); const memberKey = normalizeMemberName(params.memberName);
if (!memberKey) return null; if (!memberKey) return null;
const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : [];
for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) {
const interval = reviewIntervals[index];
if (normalizeMemberName(interval?.reviewer) !== memberKey || interval?.completedAt) {
continue;
}
const startedAtMs = parseIsoMs(interval.startedAt);
if (startedAtMs <= 0) return null;
const cycleStartedAtMs = getCurrentReviewCycleStartedAtMs(task, startedAtMs);
let baseElapsedMs = 0;
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
const previous = reviewIntervals[previousIndex];
if (normalizeMemberName(previous?.reviewer) !== memberKey) continue;
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
const previousCompletedAtMs = parseIsoMs(previous?.completedAt);
if (
previousStartedAtMs >= cycleStartedAtMs &&
previousStartedAtMs > 0 &&
previousCompletedAtMs > previousStartedAtMs
) {
baseElapsedMs += previousCompletedAtMs - previousStartedAtMs;
}
}
return {
startedAt: interval.startedAt,
startedAtMs,
baseElapsedMs,
timerId: createMemberActivityTimerId({
teamName: params.teamName,
memberName: params.memberName,
phase: 'review',
taskId: task.id,
startedAt: interval.startedAt,
}),
};
}
if (reviewIntervals.length > 0) return null;
const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) { for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index]; const event = events[index];
@ -369,6 +410,27 @@ export function deriveReviewActivityTimerAnchor(
return null; return null;
} }
function getCurrentReviewCycleStartedAtMs(task: TeamTaskWithKanban, fallbackMs: number): number {
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event.type === 'review_started') {
const startedAtMs = parseIsoMs(event.timestamp);
return startedAtMs > 0 ? startedAtMs : fallbackMs;
}
if (
event.type === 'review_approved' ||
event.type === 'review_changes_requested' ||
event.type === 'task_created' ||
(event.type === 'status_changed' &&
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted'))
) {
return fallbackMs;
}
}
return fallbackMs;
}
export function resetMemberActivityTimerStoreForTests(): void { export function resetMemberActivityTimerStoreForTests(): void {
timers.clear(); timers.clear();
} }

View file

@ -133,6 +133,7 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
}; };
const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000; const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000;
export const MEMBER_STARTING_STALE_AFTER_MS = 2 * 60 * 1000;
function isLaunchStillStarting( function isLaunchStillStarting(
spawnStatus: MemberSpawnStatus | undefined, spawnStatus: MemberSpawnStatus | undefined,
@ -634,6 +635,7 @@ export type MemberLaunchVisualState =
| 'queued' | 'queued'
| 'waiting' | 'waiting'
| 'spawning' | 'spawning'
| 'starting_stale'
| 'permission_pending' | 'permission_pending'
| 'bootstrap_stalled' | 'bootstrap_stalled'
| 'runtime_pending' | 'runtime_pending'
@ -666,6 +668,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
return 'waiting to start'; return 'waiting to start';
case 'spawning': case 'spawning':
return 'starting'; return 'starting';
case 'starting_stale':
return 'starting stale';
case 'permission_pending': case 'permission_pending':
return 'awaiting permission'; return 'awaiting permission';
case 'bootstrap_stalled': case 'bootstrap_stalled':
@ -700,6 +704,8 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
case 'runtime_pending': case 'runtime_pending':
case 'runtime_candidate': case 'runtime_candidate':
return 'bg-amber-400 animate-pulse'; return 'bg-amber-400 animate-pulse';
case 'starting_stale':
return 'bg-amber-400';
case 'registered_only': case 'registered_only':
return SPAWN_DOT_COLORS.waiting; return SPAWN_DOT_COLORS.waiting;
case 'shell_only': case 'shell_only':
@ -794,6 +800,41 @@ function hasElapsedSinceIso(
return Number.isFinite(parsed) && nowMs - parsed >= thresholdMs; return Number.isFinite(parsed) && nowMs - parsed >= thresholdMs;
} }
export function isMemberStartingStale({
spawnStatus,
spawnLaunchState,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
nowMs = Date.now(),
}: {
spawnStatus?: MemberSpawnStatus;
spawnLaunchState?: MemberLaunchState;
spawnFirstSpawnAcceptedAt?: string;
spawnUpdatedAt?: string;
nowMs?: number;
}): boolean {
if (
spawnLaunchState === 'failed_to_start' ||
spawnLaunchState === 'confirmed_alive' ||
spawnLaunchState === 'skipped_for_launch' ||
spawnLaunchState === 'runtime_pending_permission' ||
spawnStatus === 'error' ||
spawnStatus === 'online' ||
spawnStatus === 'skipped'
) {
return false;
}
if (spawnLaunchState !== 'starting' && spawnStatus !== 'waiting' && spawnStatus !== 'spawning') {
return false;
}
return hasElapsedSinceIso(
spawnFirstSpawnAcceptedAt ?? spawnUpdatedAt,
MEMBER_STARTING_STALE_AFTER_MS,
nowMs
);
}
function hasBootstrapStallDiagnostic(value: string | undefined): boolean { function hasBootstrapStallDiagnostic(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase() ?? ''; const normalized = value?.trim().toLowerCase() ?? '';
return ( return (
@ -881,12 +922,15 @@ export function buildMemberLaunchPresentation({
spawnRuntimeAlive, spawnRuntimeAlive,
spawnBootstrapConfirmed, spawnBootstrapConfirmed,
spawnBootstrapStalled, spawnBootstrapStalled,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
runtimeAdvisory, runtimeAdvisory,
runtimeEntry, runtimeEntry,
isLaunchSettling = false, isLaunchSettling = false,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
leadActivity, leadActivity,
nowMs,
}: { }: {
member: ResolvedTeamMember; member: ResolvedTeamMember;
spawnStatus: MemberSpawnStatus | undefined; spawnStatus: MemberSpawnStatus | undefined;
@ -895,12 +939,15 @@ export function buildMemberLaunchPresentation({
spawnRuntimeAlive: boolean | undefined; spawnRuntimeAlive: boolean | undefined;
spawnBootstrapConfirmed?: boolean; spawnBootstrapConfirmed?: boolean;
spawnBootstrapStalled?: boolean; spawnBootstrapStalled?: boolean;
spawnFirstSpawnAcceptedAt?: string;
spawnUpdatedAt?: string;
runtimeAdvisory: MemberRuntimeAdvisory | undefined; runtimeAdvisory: MemberRuntimeAdvisory | undefined;
runtimeEntry?: TeamAgentRuntimeEntry; runtimeEntry?: TeamAgentRuntimeEntry;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
nowMs?: number;
}): MemberLaunchPresentation { }): MemberLaunchPresentation {
const hasConfirmedSpawnLaunch = const hasConfirmedSpawnLaunch =
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true; spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
@ -943,6 +990,15 @@ export function buildMemberLaunchPresentation({
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId); const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId);
const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory); const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory);
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
const startingIsStale =
!hasConfirmedSpawnLaunch &&
isMemberStartingStale({
spawnStatus,
spawnLaunchState,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
nowMs,
});
let launchVisualState: MemberLaunchVisualState = null; let launchVisualState: MemberLaunchVisualState = null;
if (isTeamAlive !== false || isTeamProvisioning) { if (isTeamAlive !== false || isTeamProvisioning) {
@ -969,6 +1025,8 @@ export function buildMemberLaunchPresentation({
runtimeEntry?.livenessKind === 'not_found') runtimeEntry?.livenessKind === 'not_found')
) { ) {
launchVisualState = 'stale_runtime'; launchVisualState = 'stale_runtime';
} else if (!hasConfirmedSpawnLaunch && startingIsStale) {
launchVisualState = 'starting_stale';
} else if ( } else if (
!hasConfirmedSpawnLaunch && !hasConfirmedSpawnLaunch &&
isQueuedOpenCodeLaunch( isQueuedOpenCodeLaunch(
@ -1007,6 +1065,7 @@ export function buildMemberLaunchPresentation({
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState); const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
const shouldShowLaunchStatusAsPresence = const shouldShowLaunchStatusAsPresence =
launchVisualState === 'queued' || launchVisualState === 'queued' ||
launchVisualState === 'starting_stale' ||
launchVisualState === 'permission_pending' || launchVisualState === 'permission_pending' ||
launchVisualState === 'bootstrap_stalled' || launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' || launchVisualState === 'runtime_pending' ||
@ -1023,7 +1082,9 @@ export function buildMemberLaunchPresentation({
const spawnBadgeLabel = const spawnBadgeLabel =
spawnStatus && spawnStatus !== 'online' spawnStatus && spawnStatus !== 'online'
? spawnStatus === 'waiting' || spawnStatus === 'spawning' ? spawnStatus === 'waiting' || spawnStatus === 'spawning'
? 'starting' ? startingIsStale
? 'starting stale'
: 'starting'
: spawnStatus : spawnStatus
: null; : null;
@ -1033,7 +1094,7 @@ export function buildMemberLaunchPresentation({
runtimeAdvisoryTone === 'error' runtimeAdvisoryTone === 'error'
? STATUS_DOT_COLORS.terminated ? STATUS_DOT_COLORS.terminated
: (launchVisualStateDotClass ?? baseDotClass), : (launchVisualStateDotClass ?? baseDotClass),
cardClass, cardClass: launchVisualState === 'starting_stale' ? 'opacity-90' : cardClass,
runtimeAdvisoryLabel, runtimeAdvisoryLabel,
runtimeAdvisoryTitle, runtimeAdvisoryTitle,
runtimeAdvisoryTone, runtimeAdvisoryTone,

View file

@ -13,6 +13,7 @@ export interface MemberLaunchDiagnosticsPayload {
teamName?: string; teamName?: string;
runId?: string; runId?: string;
memberName: string; memberName: string;
memberCardError?: string;
launchState?: MemberLaunchState; launchState?: MemberLaunchState;
spawnStatus?: MemberSpawnStatus; spawnStatus?: MemberSpawnStatus;
livenessKind?: TeamAgentRuntimeLivenessKind; livenessKind?: TeamAgentRuntimeLivenessKind;
@ -55,6 +56,15 @@ function boundedNumber(value: number | undefined): number | undefined {
: undefined; : undefined;
} }
export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null {
const normalized = value
?.replace(/\s+/g, ' ')
.trim()
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
.replace(/^APIError\s*[-:]\s*/i, '');
return normalized && normalized.length > 0 ? normalized : null;
}
function uniqueDiagnostics( function uniqueDiagnostics(
...groups: (readonly (string | undefined)[] | undefined)[] ...groups: (readonly (string | undefined)[] | undefined)[]
): string[] | undefined { ): string[] | undefined {
@ -91,7 +101,16 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
boundedString(runtimeEntry?.runtimeDiagnostic) ?? boundedString(runtimeEntry?.runtimeDiagnostic) ??
boundedString(spawnEntry?.hardFailureReason) ?? boundedString(spawnEntry?.hardFailureReason) ??
boundedString(spawnEntry?.error); boundedString(spawnEntry?.error);
const memberCardError = boundedString(
normalizeMemberLaunchFailureReason(
spawnEntry?.error ??
spawnEntry?.hardFailureReason ??
spawnEntry?.runtimeDiagnostic ??
runtimeEntry?.runtimeDiagnostic
) ?? undefined
);
const diagnostics = uniqueDiagnostics( const diagnostics = uniqueDiagnostics(
memberCardError ? [memberCardError] : undefined,
runtimeDiagnostic ? [runtimeDiagnostic] : undefined, runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined, spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
spawnEntry?.error ? [spawnEntry.error] : undefined, spawnEntry?.error ? [spawnEntry.error] : undefined,
@ -103,6 +122,7 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
...(params.teamName ? { teamName: params.teamName } : {}), ...(params.teamName ? { teamName: params.teamName } : {}),
...(runId ? { runId } : {}), ...(runId ? { runId } : {}),
memberName: params.memberName, memberName: params.memberName,
...(memberCardError ? { memberCardError } : {}),
...((spawnEntry?.launchState ?? params.launchState) ...((spawnEntry?.launchState ?? params.launchState)
? { launchState: spawnEntry?.launchState ?? params.launchState } ? { launchState: spawnEntry?.launchState ?? params.launchState }
: {}), : {}),
@ -161,6 +181,7 @@ export function hasMemberLaunchDiagnosticsDetails(
return Boolean( return Boolean(
(payload.launchState && payload.launchState !== 'confirmed_alive') || (payload.launchState && payload.launchState !== 'confirmed_alive') ||
(payload.spawnStatus && payload.spawnStatus !== 'online') || (payload.spawnStatus && payload.spawnStatus !== 'online') ||
payload.memberCardError ||
payload.bootstrapStalled === true || payload.bootstrapStalled === true ||
weakLiveness || weakLiveness ||
payload.runtimeDiagnostic || payload.runtimeDiagnostic ||
@ -182,7 +203,12 @@ export function getMemberLaunchDiagnosticsErrorMessage(
if (!hasMemberLaunchDiagnosticsError(payload)) { if (!hasMemberLaunchDiagnosticsError(payload)) {
return undefined; return undefined;
} }
return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed'; return (
payload.memberCardError ??
payload.runtimeDiagnostic ??
payload.diagnostics?.[0] ??
'Launch failed'
);
} }
export function formatMemberLaunchDiagnosticsPayload( export function formatMemberLaunchDiagnosticsPayload(

View file

@ -319,6 +319,34 @@ export interface UpdaterAPI {
onStatus: (callback: (event: unknown, status: unknown) => void) => () => void; onStatus: (callback: (event: unknown, status: unknown) => void) => () => void;
} }
// =============================================================================
// Startup API
// =============================================================================
export interface AppStartupStatus {
phase: string;
message: string;
ready: boolean;
error?: string | null;
startedAt: number;
updatedAt: number;
steps?: AppStartupStep[];
}
export interface AppStartupStep {
phase: string;
message: string;
startedAt: number;
updatedAt: number;
finishedAt?: number;
durationMs?: number;
}
export interface AppStartupAPI {
getStatus: () => Promise<AppStartupStatus>;
onProgress: (callback: (status: AppStartupStatus) => void) => () => void;
}
// ============================================================================= // =============================================================================
// Context API // Context API
// ============================================================================= // =============================================================================
@ -770,6 +798,7 @@ export interface ReviewAPI {
* Complete Electron API exposed to the renderer process via preload script. * Complete Electron API exposed to the renderer process via preload script.
*/ */
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi { export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
startup?: AppStartupAPI;
getAppVersion: () => Promise<string>; getAppVersion: () => Promise<string>;
getProjects: () => Promise<Project[]>; getProjects: () => Promise<Project[]>;
getSessions: (projectId: string) => Promise<Session[]>; getSessions: (projectId: string) => Promise<Session[]>;

View file

@ -105,6 +105,15 @@ export interface TaskWorkInterval {
completedAt?: string; completedAt?: string;
} }
export interface TaskReviewInterval {
/** Reviewer actively reviewing during this interval. */
reviewer: string;
/** ISO timestamp when reviewer started or resumed review. */
startedAt: string;
/** ISO timestamp when reviewer stopped, paused, approved, or requested changes. */
completedAt?: string;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task History Events — unified workflow event log // Task History Events — unified workflow event log
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -470,6 +479,10 @@ export interface TeamTask {
* We persist intervals for reliable log attribution without relying on heuristics. * We persist intervals for reliable log attribution without relying on heuristics.
*/ */
workIntervals?: TaskWorkInterval[]; workIntervals?: TaskWorkInterval[];
/**
* Review work periods, split across runtime pauses/restarts just like workIntervals.
*/
reviewIntervals?: TaskReviewInterval[];
/** /**
* Unified workflow event log. * Unified workflow event log.
* Append-only records task creation, status changes, and review transitions. * Append-only records task creation, status changes, and review transitions.

View file

@ -10,6 +10,16 @@ function isAnthropicHaikuModel(model: string): boolean {
return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-'); return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-');
} }
function isAnthropicSonnetModel(model: string): boolean {
const baseModel = stripOneMillionSuffix(model);
return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-');
}
function normalizeStandardOnlyAnthropicModel(model: string): string {
const baseModel = stripOneMillionSuffix(model);
return isAnthropicHaikuModel(baseModel) || isAnthropicSonnetModel(baseModel) ? baseModel : model;
}
function normalizeAvailableLaunchModels( function normalizeAvailableLaunchModels(
availableLaunchModels: Iterable<string> | undefined availableLaunchModels: Iterable<string> | undefined
): Set<string> { ): Set<string> {
@ -52,9 +62,10 @@ export function resolveAnthropicLaunchModel(params: {
if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) { if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) {
const staticDefault = getAnthropicDefaultTeamModel(params.limitContext); const staticDefault = getAnthropicDefaultTeamModel(params.limitContext);
const runtimeDefault = params.defaultLaunchModel?.trim() || null; const runtimeDefault = params.defaultLaunchModel?.trim() || null;
const rawPreferredDefault = runtimeDefault || staticDefault;
const preferredDefault = params.limitContext const preferredDefault = params.limitContext
? stripOneMillionSuffix(runtimeDefault || staticDefault) || staticDefault ? stripOneMillionSuffix(rawPreferredDefault) || staticDefault
: runtimeDefault || staticDefault; : normalizeStandardOnlyAnthropicModel(rawPreferredDefault) || staticDefault;
if (availableModels.size === 0) { if (availableModels.size === 0) {
return preferredDefault; return preferredDefault;
} }
@ -74,7 +85,11 @@ export function resolveAnthropicLaunchModel(params: {
return null; return null;
} }
if (params.limitContext || isAnthropicHaikuModel(baseModel)) { if (
params.limitContext ||
isAnthropicHaikuModel(baseModel) ||
isAnthropicSonnetModel(baseModel)
) {
return baseModel; return baseModel;
} }

View file

@ -33,6 +33,90 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignm
{ ringIndex: 0, sectorIndex: 2 }, { ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 0, sectorIndex: 3 }, { ringIndex: 0, sectorIndex: 3 },
], ],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 0, sectorIndex: 4 },
{ ringIndex: 0, sectorIndex: 5 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 2, sectorIndex: 0 },
{ ringIndex: 2, sectorIndex: 1 },
{ ringIndex: 2, sectorIndex: 2 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 1, sectorIndex: 0 },
{ ringIndex: 1, sectorIndex: 1 },
{ ringIndex: 2, sectorIndex: 0 },
{ ringIndex: 2, sectorIndex: 1 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 1, sectorIndex: 0 },
{ ringIndex: 1, sectorIndex: 1 },
{ ringIndex: 2, sectorIndex: 0 },
{ ringIndex: 2, sectorIndex: 1 },
{ ringIndex: 2, sectorIndex: 2 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 1, sectorIndex: 0 },
{ ringIndex: 1, sectorIndex: 1 },
{ ringIndex: 2, sectorIndex: 0 },
{ ringIndex: 2, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 0 },
{ ringIndex: 3, sectorIndex: 1 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 1, sectorIndex: 0 },
{ ringIndex: 1, sectorIndex: 1 },
{ ringIndex: 2, sectorIndex: 0 },
{ ringIndex: 2, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 0 },
{ ringIndex: 3, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 2 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 1, sectorIndex: 0 },
{ ringIndex: 1, sectorIndex: 1 },
{ ringIndex: 1, sectorIndex: 2 },
{ ringIndex: 2, sectorIndex: 0 },
{ ringIndex: 2, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 0 },
{ ringIndex: 3, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 2 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 1, sectorIndex: 0 },
{ ringIndex: 1, sectorIndex: 1 },
{ ringIndex: 1, sectorIndex: 2 },
{ ringIndex: 2, sectorIndex: 0 },
{ ringIndex: 2, sectorIndex: 1 },
{ ringIndex: 2, sectorIndex: 2 },
{ ringIndex: 3, sectorIndex: 0 },
{ ringIndex: 3, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 2 },
],
]; ];
export function buildOrderedVisibleTeamGraphOwnerIds( export function buildOrderedVisibleTeamGraphOwnerIds(

View file

@ -0,0 +1,268 @@
{
"generatedAt": "2026-05-08T18:34:37.950Z",
"runsPerModel": 1,
"qualification": {
"minimumAverageScore": 80,
"minimumSuccessfulRuns": 1,
"minimumConsistencyScore": 85,
"requireNoHardFailures": true
},
"models": [
{
"model": "opencode/big-pickle",
"verdict": "tested-only",
"confidence": "low",
"qualified": false,
"readinessScore": 73,
"averageScore": 90,
"consistencyScore": 100,
"behavioralAverageScore": 90,
"minScore": 90,
"successfulRuns": 0,
"countedRuns": 1,
"hardFailures": 1,
"providerInfraFailures": 0,
"runtimeTransportFailures": 0,
"modelBehaviorFailures": 1,
"harnessFailures": 0,
"p50DurationMs": 124249,
"p95DurationMs": 124249,
"stagePassRates": {
"launchBootstrap": {
"passed": 1,
"total": 1,
"rate": 100
},
"directReply": {
"passed": 1,
"total": 1,
"rate": 100
},
"peerRelayAB": {
"passed": 1,
"total": 1,
"rate": 100
},
"peerRelayBC": {
"passed": 1,
"total": 1,
"rate": 100
},
"concurrentReplies": {
"passed": 1,
"total": 1,
"rate": 100
},
"taskRefs": {
"passed": 0,
"total": 1,
"rate": 0
},
"cleanTranscript": {
"passed": 1,
"total": 1,
"rate": 100
},
"noDuplicateTokens": {
"passed": 1,
"total": 1,
"rate": 100
},
"latencyStable": {
"passed": 1,
"total": 1,
"rate": 100
}
},
"taskRefPassRates": {
"directReply": {
"passed": 1,
"total": 1,
"rate": 100
},
"peerRelayAB": {
"passed": 1,
"total": 1,
"rate": 100
},
"peerRelayBC": {
"passed": 1,
"total": 1,
"rate": 100
},
"concurrentBob": {
"passed": 0,
"total": 1,
"rate": 0
},
"concurrentTom": {
"passed": 1,
"total": 1,
"rate": 100
}
},
"protocolViolationTotals": {
"badMessages": 0,
"duplicateOrMissingTokens": 0,
"affectedRuns": 0
},
"stageFailureImpact": [
{
"stage": "taskRefs",
"failedRuns": 1,
"weightedLoss": 10,
"passRate": {
"passed": 0,
"total": 1,
"rate": 0
}
},
{
"stage": "cleanTranscript",
"failedRuns": 0,
"weightedLoss": 0,
"passRate": {
"passed": 1,
"total": 1,
"rate": 100
}
},
{
"stage": "concurrentReplies",
"failedRuns": 0,
"weightedLoss": 0,
"passRate": {
"passed": 1,
"total": 1,
"rate": 100
}
},
{
"stage": "directReply",
"failedRuns": 0,
"weightedLoss": 0,
"passRate": {
"passed": 1,
"total": 1,
"rate": 100
}
},
{
"stage": "latencyStable",
"failedRuns": 0,
"weightedLoss": 0,
"passRate": {
"passed": 1,
"total": 1,
"rate": 100
}
},
{
"stage": "launchBootstrap",
"failedRuns": 0,
"weightedLoss": 0,
"passRate": {
"passed": 1,
"total": 1,
"rate": 100
}
},
{
"stage": "noDuplicateTokens",
"failedRuns": 0,
"weightedLoss": 0,
"passRate": {
"passed": 1,
"total": 1,
"rate": 100
}
},
{
"stage": "peerRelayAB",
"failedRuns": 0,
"weightedLoss": 0,
"passRate": {
"passed": 1,
"total": 1,
"rate": 100
}
},
{
"stage": "peerRelayBC",
"failedRuns": 0,
"weightedLoss": 0,
"passRate": {
"passed": 1,
"total": 1,
"rate": 100
}
}
],
"scoreStability": {
"sampleSize": 1,
"minScore": 90,
"maxScore": 90,
"spread": 0,
"standardDeviation": 0,
"consistencyScore": 100
},
"dominantFailureCategory": "model-behavior",
"recommendationBlockers": [
"successful runs 0 < 1",
"hard failures 1",
"model-behavior failures 1",
"highest weighted stage loss taskRefs=10",
"weakest taskRefs concurrentBob=0/1 (0%)"
],
"runs": [
{
"runIndex": 1,
"passed": false,
"score": 90,
"countedForRecommendation": true,
"outcome": "behavioral-fail",
"failureCategory": "model-behavior",
"primaryFailure": null,
"durationMs": 124249,
"hardFailure": true,
"stageDurationsMs": {
"setup": 214,
"launchBootstrap": 23875,
"materializeTasks": 32,
"directReply": 11617,
"peerRelayAB": 27950,
"peerRelayBC": 25689,
"concurrentReplies": 25243,
"hygiene": 1
},
"stageFailures": {},
"taskRefChecks": {
"directReply": true,
"peerRelayAB": true,
"peerRelayBC": true,
"concurrentBob": false,
"concurrentTom": true
},
"protocolViolations": {
"badMessages": 0,
"duplicateOrMissingTokens": []
},
"stages": {
"launchBootstrap": true,
"directReply": true,
"peerRelayAB": true,
"peerRelayBC": true,
"concurrentReplies": true,
"taskRefs": false,
"cleanTranscript": true,
"noDuplicateTokens": true,
"latencyStable": true
},
"diagnostics": [
"runId=34e07fb0-df87-4419-be0c-0f5386847b23"
]
}
]
}
]
}

View file

@ -0,0 +1,37 @@
# OpenCode Model Gauntlet Results
Generated: 2026-05-08T18:34:37.950Z
Runs per model: 1
Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0
Provider-infra runs are reported separately and are not counted as model behavior. They still block a Recommended verdict until rerun succeeds.
Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC=15, concurrentReplies=15, taskRefs=10, cleanTranscript=10, noDuplicateTokens=5, latencyStable=5.
## Model Summary
| Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 |
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `opencode/big-pickle` | Tested only | low | 73 | 100 | 0 | 90 | 90 | 1/1 | 0/1 | taskRefs 0/1 (0%) | concurrentBob 0/1 (0%) | model-behavior | successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%) | 0 | 0 | 1 | 0 | 124249ms | 124249ms |
## opencode/big-pickle
Readiness score: 73.
Score stability: consistency=100, min=90, max=90, spread=0, stdDev=0, samples=1.
Recommendation blockers: successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%).
Weighted stage impact: taskRefs:loss=10, failed=1, pass=0/1 (0%).
Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:1/1 (100%), taskRefs:0/1 (0%), cleanTranscript:1/1 (100%), noDuplicateTokens:1/1 (100%), latencyStable:1/1 (100%).
TaskRef pass rates: directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentBob:0/1 (0%), concurrentTom:1/1 (100%).
Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0.
| Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics |
| ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- |
| 1 | behavioral-fail | model-behavior | 90 | yes | 124249ms | taskRefs | peerRelayAB:27950ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:fail, concurrentTom:ok | - | runId=34e07fb0-df87-4419-be0c-0f5386847b23 |

View file

@ -0,0 +1,271 @@
import { describe, expect, it } from 'vitest';
import {
buildStableSlotLayoutSnapshot,
resolveNearestSlotAssignment,
type StableRect,
type StableSlotLayoutSnapshot,
validateStableSlotLayout,
} from '../../packages/agent-graph/src/layout/stableSlots';
import type {
GraphLayoutPort,
GraphNode,
GraphOwnerSlotAssignment,
} from '../../packages/agent-graph/src/ports/types';
function ownerNode(id: string, kind: 'lead' | 'member' = 'member'): GraphNode {
return {
id,
kind,
label: id,
state: 'idle',
domainRef: {
kind,
teamName: 'test-team',
memberName: id,
},
};
}
function taskNode(id: string, ownerId: string, index: number): GraphNode {
return {
id,
kind: 'task',
label: id,
state: 'idle',
ownerId,
taskStatus: index === 0 ? 'in_progress' : 'pending',
reviewState: index === 1 ? 'review' : 'none',
domainRef: {
kind: 'task',
teamName: 'test-team',
taskId: id,
},
};
}
function buildOwnerGraph(
ownerCount: number,
slotAssignments: Record<string, GraphOwnerSlotAssignment>
): { nodes: GraphNode[]; layout: GraphLayoutPort } {
const nodes: GraphNode[] = [ownerNode('lead', 'lead')];
const ownerOrder = Array.from({ length: ownerCount }, (_, index) => `member-${index}`);
ownerOrder.forEach((ownerId, ownerIndex) => {
nodes.push(ownerNode(ownerId));
for (let taskIndex = 0; taskIndex < 3; taskIndex += 1) {
nodes.push(taskNode(`task-${ownerIndex}-${taskIndex}`, ownerId, taskIndex));
}
});
return {
nodes,
layout: {
version: 'stable-slots-v1',
mode: 'radial',
ownerOrder,
slotAssignments,
},
};
}
function buildSixOwnerGraph(): { nodes: GraphNode[]; layout: GraphLayoutPort } {
return buildOwnerGraph(
6,
Object.fromEntries(
Array.from({ length: 6 }, (_, index) => [
`member-${index}`,
{ ringIndex: 0, sectorIndex: index },
])
)
);
}
function buildRowOrbitGraph(
ownerCount: number,
rowCounts: readonly number[]
): {
nodes: GraphNode[];
layout: GraphLayoutPort;
} {
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
let ownerIndex = 0;
rowCounts.forEach((columnCount, ringIndex) => {
for (let sectorIndex = 0; sectorIndex < columnCount; sectorIndex += 1) {
assignments[`member-${ownerIndex}`] = { ringIndex, sectorIndex };
ownerIndex += 1;
}
});
return buildOwnerGraph(ownerCount, assignments);
}
function getSnapshot(nodes: GraphNode[], layout: GraphLayoutPort): StableSlotLayoutSnapshot {
const snapshot = buildStableSlotLayoutSnapshot({
teamName: 'test-team',
nodes,
layout,
});
expect(snapshot).not.toBeNull();
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
return snapshot!;
}
function rectsOverlap(left: StableRect, right: StableRect): boolean {
return (
left.left < right.right &&
left.right > right.left &&
left.top < right.bottom &&
left.bottom > right.top
);
}
function getRowCounts(snapshot: StableSlotLayoutSnapshot): number[] {
const rowCounts = new Map<number, number>();
for (const frame of snapshot.memberSlotFrames) {
rowCounts.set(frame.ringIndex, (rowCounts.get(frame.ringIndex) ?? 0) + 1);
}
return Array.from(rowCounts.entries())
.sort(([left], [right]) => left - right)
.map(([, count]) => count);
}
function getRowWidths(snapshot: StableSlotLayoutSnapshot): number[] {
const rows = new Map<number, StableRect[]>();
for (const frame of snapshot.memberSlotFrames) {
rows.set(frame.ringIndex, [...(rows.get(frame.ringIndex) ?? []), frame.bounds]);
}
return Array.from(rows.entries())
.sort(([left], [right]) => left - right)
.map(([, rects]) => {
const left = Math.min(...rects.map((rect) => rect.left));
const right = Math.max(...rects.map((rect) => rect.right));
return right - left;
});
}
describe('stable slot layout', () => {
it('packs six legacy radial owners into two row-orbit rows', () => {
const { nodes, layout } = buildSixOwnerGraph();
const snapshot = getSnapshot(nodes, layout);
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(getRowCounts(snapshot)).toEqual([3, 3]);
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 2, 2, 2]);
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
});
it('lets six radial owners move into an empty lead-level side slot', () => {
const { nodes, layout } = buildSixOwnerGraph();
const snapshot = getSnapshot(nodes, layout);
const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!;
const targetOwnerX =
snapshot.runtimeCentralExclusion.left - 160 - currentFrame.bounds.width / 2;
const result = resolveNearestSlotAssignment({
ownerId: 'member-0',
ownerX: targetOwnerX,
ownerY: 0,
nodes,
snapshot,
layout,
});
expect(result).toMatchObject({
assignment: { ringIndex: 1, sectorIndex: 0 },
previewOwnerX: targetOwnerX,
previewOwnerY: 0,
});
expect(result?.displacedOwnerId).toBeUndefined();
const nextSnapshot = getSnapshot(nodes, {
...layout,
slotAssignments: {
...layout.slotAssignments,
'member-0': result!.assignment,
},
});
expect(nextSnapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(nextSnapshot.memberSlotFrameByOwnerId.get('member-0')).toMatchObject({
ringIndex: 1,
sectorIndex: 0,
});
});
it('uses three grid columns for six owners in rows layout', () => {
const { nodes, layout } = buildSixOwnerGraph();
const snapshot = getSnapshot(nodes, {
...layout,
mode: 'grid-under-lead',
slotAssignments: {},
});
expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead');
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 1, 1, 1]);
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
});
it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => {
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
const snapshot = getSnapshot(nodes, layout);
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(getRowCounts(snapshot)).toEqual([3, 2, 3]);
const leadRowFrames = snapshot.memberSlotFrames.filter((frame) => frame.ringIndex === 1);
expect(leadRowFrames).toHaveLength(2);
for (const frame of leadRowFrames) {
expect(rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)).toBe(false);
if (frame.ownerX < 0) {
expect(frame.bounds.right).toBeLessThanOrEqual(snapshot.runtimeCentralExclusion.left - 160);
} else {
expect(frame.bounds.left).toBeGreaterThanOrEqual(
snapshot.runtimeCentralExclusion.right + 160
);
}
}
});
it('packs twelve radial owners into four safe rows with no four-column row width', () => {
const { nodes, layout } = buildRowOrbitGraph(12, [3, 3, 3, 3]);
const snapshot = getSnapshot(nodes, layout);
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(getRowCounts(snapshot)).toEqual([3, 3, 3, 3]);
const maxFrameWidth = Math.max(...snapshot.memberSlotFrames.map((frame) => frame.bounds.width));
const maxRowWidth = Math.max(...getRowWidths(snapshot));
expect(maxRowWidth).toBeLessThan(maxFrameWidth * 4);
});
it('swaps with the nearest existing row-orbit slot while dragging', () => {
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
const snapshot = getSnapshot(nodes, layout);
const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!;
const targetFrame = snapshot.memberSlotFrameByOwnerId.get('member-4')!;
const result = resolveNearestSlotAssignment({
ownerId: 'member-0',
ownerX: targetFrame.ownerX,
ownerY: targetFrame.ownerY,
nodes,
snapshot,
layout,
});
expect(result).toEqual({
assignment: {
ringIndex: targetFrame.ringIndex,
sectorIndex: targetFrame.sectorIndex,
},
displacedOwnerId: 'member-4',
displacedAssignment: {
ringIndex: currentFrame.ringIndex,
sectorIndex: currentFrame.sectorIndex,
},
previewOwnerX: targetFrame.ownerX,
previewOwnerY: targetFrame.ownerY,
});
});
});

View file

@ -108,10 +108,28 @@ describe('ProcessBootstrapTransportEvidence', () => {
expect(summary).not.toBeNull(); expect(summary).not.toBeNull();
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe( expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
'Bootstrap transport reached bootstrap prompt observed: prompt seen; waiting for bootstrap confirmation.' 'Bootstrap prompt has not been submitted yet. Last transport stage: bootstrap prompt observed: prompt seen.'
); );
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe( expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
'Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: bootstrap prompt observed: prompt seen' 'Bootstrap prompt was not submitted before timeout. Last transport stage: bootstrap prompt observed: prompt seen'
);
});
it('distinguishes submitted bootstrap prompts from not-submitted transport timeouts', () => {
const summary = summarizeProcessBootstrapTransportEvents([
{
type: 'bootstrap_submitted',
timestamp: '2026-05-07T10:00:02.000Z',
detail: 'messageId=abc',
},
]);
expect(summary).not.toBeNull();
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
'Bootstrap prompt was submitted; waiting for bootstrap confirmation. Last transport stage: bootstrap submitted: messageId=abc.'
);
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout. Last transport stage: bootstrap submitted: messageId=abc'
); );
}); });

View file

@ -13734,7 +13734,7 @@ describe('TeamProvisioningService', () => {
runtimeDiagnosticSeverity: 'warning', runtimeDiagnosticSeverity: 'warning',
}); });
expect(result.statuses.jack?.runtimeDiagnostic).toContain( expect(result.statuses.jack?.runtimeDiagnostic).toContain(
'Bootstrap transport reached bootstrap submit rejected' 'Bootstrap prompt has not been submitted yet. Last transport stage: bootstrap submit rejected'
); );
}); });

View file

@ -0,0 +1,250 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import { TeamTaskActivityIntervalService } from '../../../../src/main/services/team/TeamTaskActivityIntervalService';
let tempDir = '';
async function writeTask(teamName: string, task: Record<string, unknown>): Promise<void> {
const taskDir = path.join(tempDir, 'tasks', teamName);
const taskId = String(task.id);
await fs.mkdir(taskDir, { recursive: true });
await fs.writeFile(path.join(taskDir, `${taskId}.json`), JSON.stringify(task, null, 2), 'utf8');
}
async function readTask(teamName: string, taskId: string): Promise<Record<string, unknown>> {
return JSON.parse(
await fs.readFile(path.join(tempDir, 'tasks', teamName, `${taskId}.json`), 'utf8')
) as Record<string, unknown>;
}
describe('TeamTaskActivityIntervalService', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-task-activity-'));
setClaudeBasePathOverride(tempDir);
});
afterEach(async () => {
vi.useRealTimers();
setClaudeBasePathOverride(null);
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('pauses all active work and review intervals for a team without changing task status', async () => {
await writeTask('alpha', {
id: 'task-1',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:05:00.000Z' }],
historyEvents: [],
});
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
'alpha',
'2026-05-08T10:10:00.000Z'
);
const task = await readTask('alpha', 'task-1');
expect(result.changedTasks).toBe(1);
expect(task.status).toBe('in_progress');
expect(task.workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' },
]);
expect(task.reviewIntervals).toEqual([
{
reviewer: 'alice',
startedAt: '2026-05-08T10:05:00.000Z',
completedAt: '2026-05-08T10:10:00.000Z',
},
]);
});
it('pauses only the selected member work and review intervals', async () => {
await writeTask('alpha', {
id: 'bob-task',
subject: 'Bob',
owner: 'bob',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:01:00.000Z' }],
historyEvents: [],
});
await writeTask('alpha', {
id: 'tom-task',
subject: 'Tom',
owner: 'tom',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
reviewIntervals: [{ reviewer: 'bob', startedAt: '2026-05-08T10:02:00.000Z' }],
historyEvents: [],
});
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForMember(
'alpha',
'bob',
'2026-05-08T10:05:00.000Z'
);
expect(result.changedTasks).toBe(2);
expect((await readTask('alpha', 'bob-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
]);
expect((await readTask('alpha', 'bob-task')).reviewIntervals).toEqual([
{ reviewer: 'alice', startedAt: '2026-05-08T10:01:00.000Z' },
]);
expect((await readTask('alpha', 'tom-task')).workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z' },
]);
expect((await readTask('alpha', 'tom-task')).reviewIntervals).toEqual([
{
reviewer: 'bob',
startedAt: '2026-05-08T10:02:00.000Z',
completedAt: '2026-05-08T10:05:00.000Z',
},
]);
});
it('resumes active work and current review intervals for the selected member', async () => {
await writeTask('alpha', {
id: 'task-1',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
],
reviewIntervals: [
{
reviewer: 'bob',
startedAt: '2026-05-08T10:06:00.000Z',
completedAt: '2026-05-08T10:08:00.000Z',
},
],
historyEvents: [
{
id: 'event-review-started',
type: 'review_started',
timestamp: '2026-05-08T10:06:00.000Z',
actor: 'bob',
},
],
});
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember(
'alpha',
'bob',
'2026-05-08T10:20:00.000Z'
);
const task = await readTask('alpha', 'task-1');
expect(result.changedTasks).toBe(1);
expect(task.workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
{ startedAt: '2026-05-08T10:20:00.000Z' },
]);
expect(task.reviewIntervals).toEqual([
{
reviewer: 'bob',
startedAt: '2026-05-08T10:06:00.000Z',
completedAt: '2026-05-08T10:08:00.000Z',
},
{ reviewer: 'bob', startedAt: '2026-05-08T10:20:00.000Z' },
]);
});
it('repairs stale open intervals using last runtime evidence plus a small grace window', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
await writeTask('alpha', {
id: 'task-1',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:10:00.000Z' }],
historyEvents: [],
});
const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha', {
version: 2,
teamName: 'alpha',
updatedAt: '2026-05-08T10:31:00.000Z',
launchPhase: 'active',
expectedMembers: ['bob', 'alice'],
members: {
bob: {
name: 'bob',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
runtimeLastSeenAt: '2026-05-08T10:30:00.000Z',
lastEvaluatedAt: '2026-05-08T10:31:00.000Z',
},
alice: {
name: 'alice',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
lastHeartbeatAt: '2026-05-08T10:20:00.000Z',
lastEvaluatedAt: '2026-05-08T10:31:00.000Z',
},
},
summary: { confirmedCount: 2, pendingCount: 0, failedCount: 0, runtimeAlivePendingCount: 0 },
teamLaunchState: 'clean_success',
});
const task = await readTask('alpha', 'task-1');
expect(result.changedTasks).toBe(1);
expect(task.workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:30:05.000Z' },
]);
expect(task.reviewIntervals).toEqual([
{
reviewer: 'alice',
startedAt: '2026-05-08T10:10:00.000Z',
completedAt: '2026-05-08T10:20:05.000Z',
},
]);
});
it('repairs stale open intervals near their start time when no runtime evidence exists', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
await writeTask('alpha', {
id: 'task-1',
subject: 'Build',
owner: 'bob',
status: 'in_progress',
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:10:00.000Z' }],
historyEvents: [],
});
const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha');
const task = await readTask('alpha', 'task-1');
expect(result.changedTasks).toBe(1);
expect(task.workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:00:05.000Z' },
]);
expect(task.reviewIntervals).toEqual([
{
reviewer: 'alice',
startedAt: '2026-05-08T10:10:00.000Z',
completedAt: '2026-05-08T10:10:05.000Z',
},
]);
});
});

View file

@ -21,7 +21,7 @@ import {
} from '../../../src/main/utils/electronUserDataMigration'; } from '../../../src/main/utils/electronUserDataMigration';
class FakeElectronApp implements ElectronUserDataMigrationApp { class FakeElectronApp implements ElectronUserDataMigrationApp {
setPathCalls: Array<{ name: string; value: string }> = []; setPathCalls: { name: string; value: string }[] = [];
constructor(private userDataPath: string) {} constructor(private userDataPath: string) {}
@ -74,9 +74,9 @@ describe('electron userData migration', () => {
const parentPath = path.dirname(currentPath); const parentPath = path.dirname(currentPath);
expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([ expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([
path.join(parentPath, 'agent-teams-ai'),
path.join(parentPath, 'Claude Agent Teams UI'), path.join(parentPath, 'Claude Agent Teams UI'),
path.join(parentPath, 'claude-agent-teams-ui'), path.join(parentPath, 'claude-agent-teams-ui'),
path.join(parentPath, 'agent-teams-ai'),
path.join(parentPath, 'claude-devtools'), path.join(parentPath, 'claude-devtools'),
path.join(parentPath, 'claude-code-context'), path.join(parentPath, 'claude-code-context'),
]); ]);
@ -106,6 +106,34 @@ describe('electron userData migration', () => {
expect(fs.existsSync(currentPath)).toBe(false); expect(fs.existsSync(currentPath)).toBe(false);
}); });
it('does not invoke the copy migration in the default startup strategy', () => {
const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'agent-teams-ai');
const app = new FakeElectronApp(currentPath);
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app, {
copyDirectory: () => {
throw new Error('copy should not run during default startup');
},
});
expect(result).toMatchObject({
currentPath,
legacyPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
});
expect(app.setPathCalls).toEqual([
{ name: 'userData', value: legacyPath },
{ name: 'sessionData', value: legacyPath },
]);
expect(fs.existsSync(currentPath)).toBe(false);
});
it('does not treat a cache-only new userData directory as populated', () => { it('does not treat a cache-only new userData directory as populated', () => {
const root = createTempRoot(); const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui'); const legacyPath = path.join(root, 'claude-agent-teams-ui');
@ -132,6 +160,137 @@ describe('electron userData migration', () => {
]); ]);
}); });
it('does not treat Electron-generated shell files as populated new userData', () => {
const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'agent-teams-ai');
const app = new FakeElectronApp(currentPath);
writeFile(currentPath, 'Preferences', '{}');
writeFile(currentPath, 'Cookies', 'sqlite bytes');
writeFile(currentPath, 'DIPS', 'tracking state');
writeFile(currentPath, 'WebStorage/QuotaManager', 'quota');
writeFile(currentPath, '.updaterId', 'updater');
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath,
legacyPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
});
expect(app.setPathCalls).toEqual([
{ name: 'userData', value: legacyPath },
{ name: 'sessionData', value: legacyPath },
]);
});
it('does not treat regenerated runtime-only folders as completed migration evidence', () => {
const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'agent-teams-ai');
const app = new FakeElectronApp(currentPath);
writeFile(currentPath, 'opencode-bridge/production-e2e-evidence.json', '{}');
writeFile(currentPath, 'mcp-server/1.3.0/index.js', 'console.log("generated")');
writeFile(currentPath, 'mcp-configs/agent-teams-mcp-generated.json', '{}');
writeFile(currentPath, 'Local Storage/leveldb/000003.log', 'renderer local storage');
writeFile(currentPath, 'IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log', 'idb');
writeFile(currentPath, 'Partitions/dev/Local Storage/leveldb/000003.log', 'partition state');
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath,
legacyPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
});
expect(app.setPathCalls).toEqual([
{ name: 'userData', value: legacyPath },
{ name: 'sessionData', value: legacyPath },
]);
});
it('keeps a populated new userData directory after a completed migration', () => {
const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'agent-teams-ai');
const app = new FakeElectronApp(currentPath);
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current');
writeFile(currentPath, 'backups/registry.json', '{}');
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath,
legacyPath: null,
migrated: false,
fallbackToLegacy: false,
reason: 'current-populated',
});
expect(app.setPathCalls).toEqual([]);
});
it('prefers an already populated agent-teams-ai directory over older legacy data', () => {
const root = createTempRoot();
const completedNewPath = path.join(root, 'agent-teams-ai');
const olderLegacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'Agent Teams UI');
const app = new FakeElectronApp(currentPath);
writeFile(currentPath, 'opencode-bridge/production-e2e-evidence.json', '{}');
writeFile(completedNewPath, 'data/attachments/team-a/current.txt', 'current');
writeFile(olderLegacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath,
legacyPath: completedNewPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
});
expect(app.setPathCalls).toEqual([
{ name: 'userData', value: completedNewPath },
{ name: 'sessionData', value: completedNewPath },
]);
});
it('uses populated agent-teams-ai when both current product-name and new package-name paths exist', () => {
const root = createTempRoot();
const completedNewPath = path.join(root, 'agent-teams-ai');
const currentProductPath = path.join(root, 'Agent Teams UI');
const app = new FakeElectronApp(currentProductPath);
writeFile(currentProductPath, 'data/attachments/team-a/old.txt', 'old');
writeFile(completedNewPath, 'data/attachments/team-a/current.txt', 'current');
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath: currentProductPath,
legacyPath: completedNewPath,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-reused',
});
expect(app.setPathCalls).toEqual([
{ name: 'userData', value: completedNewPath },
{ name: 'sessionData', value: completedNewPath },
]);
expect(readFile(completedNewPath, 'data/attachments/team-a/current.txt')).toBe('current');
expect(readFile(currentProductPath, 'data/attachments/team-a/old.txt')).toBe('old');
});
it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => { it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => {
const root = createTempRoot(); const root = createTempRoot();
const legacyPath = path.join(root, 'Claude Agent Teams UI'); const legacyPath = path.join(root, 'Claude Agent Teams UI');
@ -383,12 +542,12 @@ describe('electron userData migration', () => {
}); });
}); });
it('uses the lowercase package-name legacy directory when product-name legacy data is absent', () => { it('uses the lowercase package-name legacy directory when product-name durable data is absent', () => {
const root = createTempRoot(); const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui'); const legacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'Agent Teams UI'); const currentPath = path.join(root, 'Agent Teams UI');
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}'); writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
const app = new FakeElectronApp(currentPath); const app = new FakeElectronApp(currentPath);
const result = migrateElectronUserDataDirectory(app); const result = migrateElectronUserDataDirectory(app);
@ -404,7 +563,31 @@ describe('electron userData migration', () => {
{ name: 'userData', value: legacyPath }, { name: 'userData', value: legacyPath },
{ name: 'sessionData', value: legacyPath }, { name: 'sessionData', value: legacyPath },
]); ]);
expect(fs.existsSync(path.join(currentPath, 'mcp-configs/legacy.json'))).toBe(false); expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/legacy.txt'))).toBe(
false
);
});
it('does not reuse non-durable legacy directories when no durable user data exists', () => {
const root = createTempRoot();
const legacyPath = path.join(root, 'claude-agent-teams-ui');
const currentPath = path.join(root, 'Agent Teams UI');
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
writeFile(legacyPath, 'opencode-bridge/command-ledger.json', '{"commands":[]}');
writeFile(legacyPath, 'Local Storage/leveldb/000003.log', 'renderer local storage');
const app = new FakeElectronApp(currentPath);
const result = migrateElectronUserDataDirectory(app);
expect(result).toMatchObject({
currentPath,
legacyPath: null,
migrated: false,
fallbackToLegacy: false,
reason: 'legacy-missing',
});
expect(app.setPathCalls).toEqual([]);
}); });
it('prefers populated older legacy data over an empty newer legacy directory', () => { it('prefers populated older legacy data over an empty newer legacy directory', () => {

View file

@ -128,9 +128,12 @@ describe('formatTeamModelSummary', () => {
}); });
describe('computeEffectiveTeamModel', () => { describe('computeEffectiveTeamModel', () => {
it('appends [1m] for anthropic models', () => { it('appends [1m] for Opus but keeps Sonnet on standard context', () => {
expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]'); expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]');
expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]'); expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet');
expect(computeEffectiveTeamModel('claude-sonnet-4-6', false, 'anthropic')).toBe(
'claude-sonnet-4-6'
);
}); });
it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => { it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => {
@ -177,7 +180,7 @@ describe('computeEffectiveTeamModel', () => {
it('does not double-append [1m] when input already has it', () => { it('does not double-append [1m] when input already has it', () => {
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]'); expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]'); expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet');
expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]'); expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]');
}); });
@ -185,6 +188,56 @@ describe('computeEffectiveTeamModel', () => {
expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]'); expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]');
}); });
it('keeps a Sonnet runtime default on standard context', () => {
expect(
computeEffectiveTeamModel('', false, 'anthropic', {
providerId: 'anthropic',
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-models-api',
status: 'ready',
fetchedAt: '2026-04-21T00:00:00.000Z',
staleAt: '2026-04-21T00:10:00.000Z',
defaultModelId: 'sonnet[1m]',
defaultLaunchModel: 'sonnet[1m]',
models: [
{
id: 'sonnet',
launchModel: 'sonnet',
displayName: 'Sonnet 4.6',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api',
},
{
id: 'sonnet[1m]',
launchModel: 'sonnet[1m]',
displayName: 'Sonnet 4.6 (1M)',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: null,
inputModalities: ['text', 'image'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'anthropic-models-api',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
})
).toBe('sonnet');
});
it('returns base model without [1m] when limitContext is true', () => { it('returns base model without [1m] when limitContext is true', () => {
expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus');
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus'); expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');

View file

@ -975,10 +975,10 @@ describe('TeamModelSelector disabled Codex models', () => {
onValueChange: () => undefined, onValueChange: () => undefined,
providerDisabledReasonById: { providerDisabledReasonById: {
opencode: opencode:
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.', 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.',
}, },
providerDisabledBadgeLabelById: { providerDisabledBadgeLabelById: {
opencode: 'side lane', opencode: 'team only',
}, },
}) })
); );
@ -990,9 +990,9 @@ describe('TeamModelSelector disabled Codex models', () => {
); );
expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
expect(openCodeButton?.getAttribute('title')).toBe( expect(openCodeButton?.getAttribute('title')).toBe(
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.' 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.'
); );
expect(openCodeButton?.textContent).toContain('side lane'); expect(openCodeButton?.textContent).toContain('team only');
await act(async () => { await act(async () => {
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));

View file

@ -135,7 +135,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
name: draft.name, name: draft.name,
role: draft.customRole || undefined, role: draft.customRole || undefined,
workflow: draft.workflow, workflow: draft.workflow,
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | undefined, providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined,
providerBackendId: draft.providerBackendId as 'codex-native' | undefined, providerBackendId: draft.providerBackendId as 'codex-native' | undefined,
model: draft.model, model: draft.model,
effort: draft.effort as 'low' | 'medium' | 'high' | undefined, effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
@ -170,8 +170,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
fastMode: member.fastMode, fastMode: member.fastMode,
})), })),
filterEditableMemberInputs: (members: unknown) => members, filterEditableMemberInputs: (members: unknown) => members,
normalizeLeadProviderForMode: (providerId: unknown) => normalizeLeadProviderForMode: (providerId: unknown) => providerId,
providerId === 'opencode' ? 'anthropic' : providerId,
normalizeMemberDraftForProviderMode: (member: unknown) => member, normalizeMemberDraftForProviderMode: (member: unknown) => member,
normalizeProviderForMode: (providerId: unknown) => providerId, normalizeProviderForMode: (providerId: unknown) => providerId,
validateMemberNameInline: () => null, validateMemberNameInline: () => null,
@ -385,9 +384,9 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
computeEffectiveTeamModel: (model: string) => model || undefined, computeEffectiveTeamModel: (model: string) => model || undefined,
formatTeamModelSummary: (providerId: string, model: string, effort?: string) => formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
[providerId, model, effort].filter(Boolean).join(' '), [providerId, model, effort].filter(Boolean).join(' '),
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane', OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only',
OPENCODE_TEAM_LEAD_DISABLED_REASON: OPENCODE_ONE_SHOT_DISABLED_REASON:
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.', 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.',
})); }));
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({ vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
@ -745,7 +744,7 @@ describe('LaunchTeamDialog', () => {
}); });
}); });
it('normalizes saved OpenCode lead hydration away from the unsupported lead path', async () => { it('launches a saved pure OpenCode team with OpenCode as the lead provider', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.mocked(isTeamModelAvailableForUi).mockImplementation( vi.mocked(isTeamModelAvailableForUi).mockImplementation(
(_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false (_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false
@ -769,7 +768,7 @@ describe('LaunchTeamDialog', () => {
}, },
], ],
} as any; } as any;
vi.mocked(api.teams.getSavedRequest).mockResolvedValue({ vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
teamName: 'team-alpha', teamName: 'team-alpha',
providerId: 'opencode', providerId: 'opencode',
model: 'opencode/minimax-m2.5-free', model: 'opencode/minimax-m2.5-free',
@ -777,7 +776,8 @@ describe('LaunchTeamDialog', () => {
{ {
name: 'alice', name: 'alice',
role: 'Reviewer', role: 'Reviewer',
model: 'gemini-3-pro-preview', providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
}, },
], ],
} as any); } as any);
@ -812,7 +812,7 @@ describe('LaunchTeamDialog', () => {
const opencodePrepareCalls = vi const opencodePrepareCalls = vi
.mocked(runProviderPrepareDiagnostics) .mocked(runProviderPrepareDiagnostics)
.mock.calls.filter((call) => call[0]?.providerId === 'opencode'); .mock.calls.filter((call) => call[0]?.providerId === 'opencode');
expect(opencodePrepareCalls).toHaveLength(0); expect(opencodePrepareCalls.length).toBeGreaterThan(0);
const submitButton = Array.from(host.querySelectorAll('button')).find( const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Launch team' (button) => button.textContent === 'Launch team'
@ -831,7 +831,8 @@ describe('LaunchTeamDialog', () => {
{ {
name: 'alice', name: 'alice',
role: 'Reviewer', role: 'Reviewer',
model: '', providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
}, },
], ],
}); });
@ -840,9 +841,217 @@ describe('LaunchTeamDialog', () => {
onLaunch.mock.calls as Array<[{ providerId?: string; model?: string }]> onLaunch.mock.calls as Array<[{ providerId?: string; model?: string }]>
)[0]?.[0] as { providerId?: string; model?: string } | undefined; )[0]?.[0] as { providerId?: string; model?: string } | undefined;
expect(launchRequest).toMatchObject({ expect(launchRequest).toMatchObject({
providerId: 'anthropic', providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
}); });
expect(launchRequest?.model).not.toBe('opencode/minimax-m2.5-free');
await act(async () => {
root.unmount();
await flush();
});
});
it('blocks OpenCode lead launch until a model is selected', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',
providers: [
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
statusMessage: null,
detailMessage: null,
models: ['opencode/minimax-m2.5-free'],
capabilities: {
teamLaunch: true,
oneShot: false,
},
},
],
} as any;
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
teamName: 'team-alpha',
providerId: 'opencode',
model: '',
members: [{ name: 'alice', role: 'Reviewer', providerId: 'opencode' }],
} as any);
const onLaunch = vi.fn(async () => {});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(LaunchTeamDialog, {
mode: 'launch',
open: true,
teamName: 'team-alpha',
members: [],
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onLaunch,
})
);
await flush();
await flush();
});
expect(host.textContent).toContain('OpenCode lead requires a selected model.');
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Launch team'
);
expect(submitButton?.hasAttribute('disabled')).toBe(true);
expect(onLaunch).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await flush();
});
});
it('blocks OpenCode lead launch without an OpenCode teammate', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',
providers: [
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
statusMessage: null,
detailMessage: null,
models: ['opencode/minimax-m2.5-free'],
capabilities: {
teamLaunch: true,
oneShot: false,
},
},
],
} as any;
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
teamName: 'team-alpha',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
members: [],
} as any);
const onLaunch = vi.fn(async () => {});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(LaunchTeamDialog, {
mode: 'launch',
open: true,
teamName: 'team-alpha',
members: [],
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onLaunch,
})
);
await flush();
await flush();
});
expect(host.textContent).toContain('OpenCode lead requires at least one OpenCode teammate.');
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Launch team'
);
expect(submitButton?.hasAttribute('disabled')).toBe(true);
expect(onLaunch).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await flush();
});
});
it('keeps OpenCode lead mixed-provider launches blocked', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',
providers: [
{
providerId: 'opencode',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
statusMessage: null,
detailMessage: null,
models: ['opencode/minimax-m2.5-free'],
capabilities: {
teamLaunch: true,
oneShot: false,
},
},
{
providerId: 'codex',
supported: true,
authenticated: true,
authMethod: 'codex_api_key',
verificationState: 'verified',
statusMessage: null,
detailMessage: null,
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
models: ['gpt-5.4'],
capabilities: {
teamLaunch: true,
oneShot: false,
},
},
],
} as any;
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
teamName: 'team-alpha',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
members: [{ name: 'alice', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.4' }],
} as any);
const onLaunch = vi.fn(async () => {});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(LaunchTeamDialog, {
mode: 'launch',
open: true,
teamName: 'team-alpha',
members: [],
defaultProjectPath: '/tmp/project',
provisioningError: null,
clearProvisioningError: vi.fn(),
activeTeams: [],
onClose: vi.fn(),
onLaunch,
})
);
await flush();
await flush();
});
expect(host.textContent).toContain('OpenCode cannot lead mixed-provider teams');
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Launch team'
);
expect(submitButton?.hasAttribute('disabled')).toBe(true);
expect(onLaunch).not.toHaveBeenCalled();
await act(async () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -94,7 +94,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
expect(result.blocksSubmission).toBe(true); expect(result.blocksSubmission).toBe(true);
expect(result.title).toBe('OpenCode cannot lead mixed-provider teams'); expect(result.title).toBe('OpenCode cannot lead mixed-provider teams');
expect(result.message).toContain('OpenCode-led mixed teams are not supported'); expect(result.message).toContain('mixed teams cannot use OpenCode as the lead');
expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead'); expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead');
}); });

View file

@ -13,8 +13,8 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember } from '@shared/types';
describe('members editor editable input filtering', () => { describe('members editor editable input filtering', () => {
it('normalizes OpenCode away from the team lead while keeping other multimodel providers', () => { it('keeps OpenCode available for the team lead only when multimodel is enabled', () => {
expect(normalizeLeadProviderForMode('opencode', true)).toBe('anthropic'); expect(normalizeLeadProviderForMode('opencode', true)).toBe('opencode');
expect(normalizeLeadProviderForMode('codex', true)).toBe('codex'); expect(normalizeLeadProviderForMode('codex', true)).toBe('codex');
expect(normalizeLeadProviderForMode('anthropic', true)).toBe('anthropic'); expect(normalizeLeadProviderForMode('anthropic', true)).toBe('anthropic');
expect(normalizeLeadProviderForMode('opencode', false)).toBe('anthropic'); expect(normalizeLeadProviderForMode('opencode', false)).toBe('anthropic');

View file

@ -228,7 +228,9 @@ function restoreWindowAnimationFrame(): void {
originalWindowAnimationFrame.hasRequest originalWindowAnimationFrame.hasRequest
? originalWindowAnimationFrame.requestAnimationFrame ? originalWindowAnimationFrame.requestAnimationFrame
: undefined, : undefined,
originalWindowAnimationFrame.hasCancel ? originalWindowAnimationFrame.cancelAnimationFrame : undefined originalWindowAnimationFrame.hasCancel
? originalWindowAnimationFrame.cancelAnimationFrame
: undefined
); );
} }
@ -524,9 +526,7 @@ describe('teamSlice actions', () => {
member: 'bob', member: 'bob',
text: 'hello', text: 'hello',
}); });
await store await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
.getState()
.refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
expect(store.getState().sendMessageWarning).toBe( expect(store.getState().sendMessageWarning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.' 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
@ -975,6 +975,51 @@ describe('teamSlice actions', () => {
}); });
}); });
it('normalizes legacy six-owner row-orbit slots before preserving manual layout', () => {
const store = createSliceStore();
const members = [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
{ name: 'nova', agentId: 'agent-nova' },
{ name: 'atlas', agentId: 'agent-atlas' },
];
store.setState({
slotAssignmentsByTeam: {
'my-team': {
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-atlas': { ringIndex: 0, sectorIndex: 1 },
'agent-bob': { ringIndex: 0, sectorIndex: 2 },
'agent-jack': { ringIndex: 1, sectorIndex: 0 },
'agent-nova': { ringIndex: 1, sectorIndex: 1 },
'agent-tom': { ringIndex: 1, sectorIndex: 2 },
},
},
graphLayoutSessionByTeam: {
'my-team': {
mode: 'manual',
signature: null,
},
},
});
store.getState().ensureTeamGraphSlotAssignments('my-team', members);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-atlas': { ringIndex: 0, sectorIndex: 1 },
'agent-bob': { ringIndex: 0, sectorIndex: 2 },
'agent-jack': { ringIndex: 2, sectorIndex: 0 },
'agent-nova': { ringIndex: 2, sectorIndex: 1 },
'agent-tom': { ringIndex: 2, sectorIndex: 2 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'manual',
signature: null,
});
});
it('resets graph slot assignments back to defaults when reopening the graph surface', () => { it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
const store = createSliceStore(); const store = createSliceStore();
store.setState({ store.setState({
@ -1352,10 +1397,7 @@ describe('teamSlice actions', () => {
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false }); const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
expect(hoisted.getData).toHaveBeenCalledTimes(2); expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(hoisted.getData.mock.calls[0]).toEqual([ expect(hoisted.getData.mock.calls[0]).toEqual(['my-team', { includeMemberBranches: false }]);
'my-team',
{ includeMemberBranches: false },
]);
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']); expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
thinRequest.resolve(thinSnapshot); thinRequest.resolve(thinSnapshot);
@ -1414,7 +1456,9 @@ describe('teamSlice actions', () => {
hoisted.getData hoisted.getData
.mockImplementationOnce(() => alphaThin.promise) .mockImplementationOnce(() => alphaThin.promise)
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })) .mockResolvedValueOnce(
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
)
.mockResolvedValueOnce(alphaFull); .mockResolvedValueOnce(alphaFull);
const alphaSelect = store.getState().selectTeam('alpha-team'); const alphaSelect = store.getState().selectTeam('alpha-team');
@ -1427,7 +1471,9 @@ describe('teamSlice actions', () => {
await store.getState().selectTeam('beta-team'); await store.getState().selectTeam('beta-team');
alphaThin.resolve(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } })); alphaThin.resolve(
createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } })
);
await alphaSelect; await alphaSelect;
await flushAsyncWork(); await flushAsyncWork();
@ -1509,8 +1555,12 @@ describe('teamSlice actions', () => {
const store = createSliceStore(); const store = createSliceStore();
hoisted.getData hoisted.getData
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } })) .mockResolvedValueOnce(
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })); createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } })
)
.mockResolvedValueOnce(
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
);
await store.getState().selectTeam('alpha-team'); await store.getState().selectTeam('alpha-team');
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({ expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
@ -3480,9 +3530,7 @@ describe('teamSlice actions', () => {
const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team'); const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team');
expect(result.failed).toEqual([ expect(result.failed).toEqual([{ memberName: 'alice', error: 'OpenRouter credits exhausted' }]);
{ memberName: 'alice', error: 'OpenRouter credits exhausted' },
]);
expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team'); expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team');
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team'); expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team'); expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');

View file

@ -165,6 +165,42 @@ describe('memberActivityTimer', () => {
).toBe('2026-05-07T09:35:00.000Z'); ).toBe('2026-05-07T09:35:00.000Z');
}); });
it('anchors review timers to persisted review intervals and adds paused review time', () => {
const task: TeamTaskWithKanban = {
...baseTask,
status: 'completed',
reviewState: 'review',
kanbanColumn: 'review',
reviewer: 'alice',
historyEvents: [
{
id: 'evt-1',
type: 'review_started',
from: 'review',
to: 'review',
actor: 'alice',
timestamp: '2026-05-07T09:30:00.000Z',
},
],
reviewIntervals: [
{
reviewer: 'alice',
startedAt: '2026-05-07T09:30:00.000Z',
completedAt: '2026-05-07T09:35:00.000Z',
},
{ reviewer: 'alice', startedAt: '2026-05-07T09:40:00.000Z' },
],
};
const anchor = deriveReviewActivityTimerAnchor(task, {
teamName: 'alpha',
memberName: 'alice',
});
expect(anchor?.startedAt).toBe('2026-05-07T09:40:00.000Z');
expect(anchor?.baseElapsedMs).toBe(300_000);
});
it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => { it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => {
const timerId = createMemberActivityTimerId({ const timerId = createMemberActivityTimerId({
teamName: 'alpha', teamName: 'alpha',

View file

@ -195,6 +195,30 @@ describe('memberHelpers spawn-aware presence', () => {
}); });
}); });
it('marks long-running starting states as stale without making them failed', () => {
const presentation = buildMemberLaunchPresentation({
member,
spawnStatus: 'waiting',
spawnLaunchState: 'starting',
spawnLivenessSource: undefined,
spawnRuntimeAlive: false,
spawnUpdatedAt: '2026-05-08T12:00:00.000Z',
runtimeAdvisory: undefined,
isLaunchSettling: true,
isTeamAlive: true,
isTeamProvisioning: false,
nowMs: Date.parse('2026-05-08T12:03:00.000Z'),
});
expect(presentation.presenceLabel).toBe('starting stale');
expect(presentation.launchVisualState).toBe('starting_stale');
expect(presentation.launchStatusLabel).toBe('starting stale');
expect(presentation.dotClass).toContain('bg-amber-400');
expect(presentation.dotClass).not.toContain('animate-pulse');
expect(presentation.cardClass).not.toContain('member-waiting-shimmer');
expect(presentation.spawnBadgeLabel).toBe('starting stale');
});
it('keeps OpenCode runtime evidence states more specific than queued', () => { it('keeps OpenCode runtime evidence states more specific than queued', () => {
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' }; const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };

View file

@ -4,6 +4,7 @@ import {
buildMemberLaunchDiagnosticsPayload, buildMemberLaunchDiagnosticsPayload,
formatMemberLaunchDiagnosticsPayload, formatMemberLaunchDiagnosticsPayload,
hasMemberLaunchDiagnosticsDetails, hasMemberLaunchDiagnosticsDetails,
getMemberLaunchDiagnosticsErrorMessage,
} from '@renderer/utils/memberLaunchDiagnostics'; } from '@renderer/utils/memberLaunchDiagnostics';
describe('member launch diagnostics', () => { describe('member launch diagnostics', () => {
@ -62,4 +63,31 @@ describe('member launch diagnostics', () => {
expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true); expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true);
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"livenessKind": "shell_only"'); expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"livenessKind": "shell_only"');
}); });
it('includes the exact normalized member card error in copy diagnostics', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'jack',
spawnEntry: {
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
hardFailureReason:
'Latest assistant message msg_123 failed with APIError - OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys',
runtimeDiagnostic: 'persisted runtime pid is not alive',
runtimeDiagnosticSeverity: 'error',
updatedAt: '2026-05-08T12:00:00.000Z',
},
});
expect(payload.memberCardError).toBe(
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
);
expect(payload.diagnostics?.[0]).toBe(
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
);
expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBe(
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
);
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"');
});
}); });

View file

@ -47,16 +47,31 @@ describe('resolveAnthropicLaunchModel', () => {
availableLaunchModels: ['opus', 'opus[1m]'], availableLaunchModels: ['opus', 'opus[1m]'],
}) })
).toBe('opus'); ).toBe('opus');
});
it('preserves limitContext requests and never manufactures 1M Haiku variants', () => {
expect( expect(
resolveAnthropicLaunchModel({ resolveAnthropicLaunchModel({
selectedModel: 'sonnet', selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION,
limitContext: true, limitContext: false,
defaultLaunchModel: 'sonnet[1m]',
availableLaunchModels: ['sonnet', 'sonnet[1m]'], availableLaunchModels: ['sonnet', 'sonnet[1m]'],
}) })
).toBe('sonnet'); ).toBe('sonnet');
});
it('preserves limitContext requests and never manufactures 1M Sonnet or Haiku variants', () => {
expect(
resolveAnthropicLaunchModel({
selectedModel: 'sonnet',
limitContext: false,
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
})
).toBe('sonnet');
expect(
resolveAnthropicLaunchModel({
selectedModel: 'claude-sonnet-4-6',
limitContext: false,
availableLaunchModels: ['claude-sonnet-4-6', 'claude-sonnet-4-6[1m]'],
})
).toBe('claude-sonnet-4-6');
expect( expect(
resolveAnthropicLaunchModel({ resolveAnthropicLaunchModel({
selectedModel: 'haiku', selectedModel: 'haiku',

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
describe('team graph default layout', () => {
function members(count: number): Array<{ name: string; agentId: string }> {
return Array.from({ length: count }, (_, index) => ({
name: `member-${index}`,
agentId: `agent-${index}`,
}));
}
it('seeds six visible owners into two row-orbit rows', () => {
const teamMembers = members(6);
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
'agent-0': { ringIndex: 0, sectorIndex: 0 },
'agent-1': { ringIndex: 0, sectorIndex: 1 },
'agent-2': { ringIndex: 0, sectorIndex: 2 },
'agent-3': { ringIndex: 2, sectorIndex: 0 },
'agent-4': { ringIndex: 2, sectorIndex: 1 },
'agent-5': { ringIndex: 2, sectorIndex: 2 },
});
});
it('seeds eight visible owners into row-orbit defaults', () => {
const teamMembers = members(8);
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
'agent-0': { ringIndex: 0, sectorIndex: 0 },
'agent-1': { ringIndex: 0, sectorIndex: 1 },
'agent-2': { ringIndex: 0, sectorIndex: 2 },
'agent-3': { ringIndex: 1, sectorIndex: 0 },
'agent-4': { ringIndex: 1, sectorIndex: 1 },
'agent-5': { ringIndex: 2, sectorIndex: 0 },
'agent-6': { ringIndex: 2, sectorIndex: 1 },
'agent-7': { ringIndex: 2, sectorIndex: 2 },
});
});
it('seeds twelve visible owners into four row-orbit rows', () => {
const teamMembers = members(12);
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
'agent-0': { ringIndex: 0, sectorIndex: 0 },
'agent-1': { ringIndex: 0, sectorIndex: 1 },
'agent-2': { ringIndex: 0, sectorIndex: 2 },
'agent-3': { ringIndex: 1, sectorIndex: 0 },
'agent-4': { ringIndex: 1, sectorIndex: 1 },
'agent-5': { ringIndex: 1, sectorIndex: 2 },
'agent-6': { ringIndex: 2, sectorIndex: 0 },
'agent-7': { ringIndex: 2, sectorIndex: 1 },
'agent-8': { ringIndex: 2, sectorIndex: 2 },
'agent-9': { ringIndex: 3, sectorIndex: 0 },
'agent-10': { ringIndex: 3, sectorIndex: 1 },
'agent-11': { ringIndex: 3, sectorIndex: 2 },
});
});
});

View file

@ -11,6 +11,7 @@ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')) as
export default defineConfig({ export default defineConfig({
root: resolve(ROOT, 'src/renderer'), root: resolve(ROOT, 'src/renderer'),
cacheDir: resolve(ROOT, 'node_modules/.vite/web-renderer'),
plugins: [react()], plugins: [react()],
server: { server: {
host: '127.0.0.1', host: '127.0.0.1',