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() : '';
}
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) {
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
if (!actor) return null;
@ -130,7 +157,9 @@ function getReviewStartActor(context, task, flags) {
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) {
@ -155,18 +184,25 @@ function getLatestReviewStartedActor(task) {
function getReviewDecisionActor(context, task, flags, actionName) {
const explicit = resolveKnownActorName(context, flags.from, 'review actor');
const startedActor = tryResolveKnownActorName(context, getLatestReviewStartedActor(task), 'review actor');
const assignedReviewer = tryResolveKnownActorName(context, getLatestReviewRequestedReviewer(task), 'reviewer');
const startedActor = tryResolveKnownActorName(
context,
getLatestReviewStartedActor(task),
'review actor'
);
const assignedReviewer = tryResolveKnownActorName(
context,
getLatestReviewRequestedReviewer(task),
'reviewer'
);
const inferredActor =
startedActor &&
(!assignedReviewer ||
resolveActorIdentityKey(context, startedActor) === resolveActorIdentityKey(context, assignedReviewer))
resolveActorIdentityKey(context, startedActor) ===
resolveActorIdentityKey(context, assignedReviewer))
? startedActor
: assignedReviewer;
const actor =
explicit ||
inferredActor ||
resolveKnownActorName(context, 'team-lead', 'review actor');
explicit || inferredActor || resolveKnownActorName(context, 'team-lead', 'review actor');
assertMatchesAssignedReviewer(context, task, actor, actionName);
return actor;
}
@ -176,12 +212,16 @@ function assertReviewTransitionAllowed(context, task, transitionName) {
throw new Error(`Task #${task.displayId || task.id} is deleted`);
}
if (task.status !== 'completed') {
throw new Error(`Task #${task.displayId || task.id} must be completed before ${transitionName}`);
throw new Error(
`Task #${task.displayId || task.id} must be completed before ${transitionName}`
);
}
const reviewState = getEffectiveReviewState(context, task);
if (reviewState !== 'review') {
throw new Error(`Task #${task.displayId || task.id} must be in review before ${transitionName}`);
throw new Error(
`Task #${task.displayId || task.id} must be in review before ${transitionName}`
);
}
return reviewState;
}
@ -223,9 +263,14 @@ function startReview(context, taskId, flags = {}) {
if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
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
? Boolean(runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { allowLeadAliases: true }))
? Boolean(
runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, {
allowLeadAliases: true,
})
)
: false;
const assignedReviewer = tryResolveKnownActorName(
context,
@ -235,7 +280,8 @@ function startReview(context, taskId, flags = {}) {
const existingMatchesAssigned =
!assignedReviewer ||
(existingActorValid &&
resolveActorIdentityKey(context, existingActor) === resolveActorIdentityKey(context, assignedReviewer));
resolveActorIdentityKey(context, existingActor) ===
resolveActorIdentityKey(context, assignedReviewer));
const requestedActor =
typeof flags.from === 'string' && flags.from.trim()
? getReviewStartActor(context, task, flags)
@ -244,38 +290,52 @@ function startReview(context, taskId, flags = {}) {
existingActorValid &&
existingMatchesAssigned &&
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' });
if (!existingActorValid || !existingMatchesAssigned) {
const repairedActor = requestedActor || getReviewStartActor(context, task, flags);
const timestamp = new Date().toISOString();
tasks.updateTask(context, task.id, (t) => {
openReviewInterval(t, repairedActor, timestamp);
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_started',
from: prevReviewState,
to: 'review',
actor: repairedActor,
timestamp,
});
t.reviewState = 'review';
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' };
}
assertReviewTransitionAllowed(context, task, 'starting review');
const from = getReviewStartActor(context, task, flags);
const timestamp = new Date().toISOString();
try {
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
tasks.updateTask(context, task.id, (t) => {
openReviewInterval(t, from, timestamp);
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_started',
from: prevReviewState,
to: 'review',
actor: from,
timestamp,
});
t.reviewState = 'review';
return t;
@ -285,7 +345,10 @@ function startReview(context, taskId, flags = {}) {
try {
kanban.clearKanban(context, task.id, { transition: 'rollback' });
} 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;
}
@ -296,17 +359,23 @@ function requestReview(context, taskId, flags = {}) {
const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => {
const currentTask = tasks.getTask(context, taskId);
if (currentTask.status !== 'completed') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`);
throw new Error(
`Task #${currentTask.displayId || currentTask.id} must be completed before review`
);
}
const nextFrom =
resolveKnownActorName(context, flags.from, 'review requester') ||
resolveKnownActorName(context, 'team-lead', 'review requester');
const rawReviewer = getReviewer(context, flags);
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
const nextReviewer = rawReviewer
? resolveKnownActorName(context, rawReviewer, 'reviewer')
: null;
const prevReviewState = getEffectiveReviewState(context, currentTask);
if (prevReviewState === 'approved') {
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
throw new Error(
`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`
);
}
try {
@ -326,7 +395,10 @@ function requestReview(context, taskId, flags = {}) {
try {
kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
} 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;
}
@ -383,7 +455,9 @@ function approveReview(context, taskId, flags = {}) {
if (prevReviewState === 'approved') {
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' });
return {
@ -399,15 +473,18 @@ function approveReview(context, taskId, flags = {}) {
}
assertReviewTransitionAllowed(context, currentTask, 'approval');
const timestamp = new Date().toISOString();
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
tasks.updateTask(context, currentTask.id, (t) => {
closeReviewIntervals(t, timestamp);
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_approved',
from: prevReviewState,
to: 'approved',
...(nextNote ? { note: nextNote } : {}),
actor: nextFrom,
timestamp,
});
t.reviewState = 'approved';
return t;
@ -472,15 +549,22 @@ function requestChanges(context, taskId, flags = {}) {
typeof flags.comment === 'string' && flags.comment.trim()
? flags.comment.trim()
: '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) => {
closeReviewIntervals(t, timestamp);
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
type: 'review_changes_requested',
from: prevReviewState,
to: 'needsFix',
...(nextComment ? { note: nextComment } : {}),
actor: nextFrom,
timestamp,
});
t.reviewState = 'needsFix';
return t;

View file

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

View file

@ -54,7 +54,10 @@ describe('agent-teams-controller API', () => {
const address = server.address();
return {
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('Working directory: /tmp/project-x');
expect(briefing).toContain('Task briefing for bob:');
expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.');
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
expect(briefing).toContain(
'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(
'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 bootstrap silence rule');
expect(briefing).toContain(
'If it shows no actionable tasks, stop and wait silently.'
);
expect(briefing).toContain('If it shows no actionable tasks, stop and wait silently.');
expect(briefing).toContain(
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
);
@ -478,7 +483,10 @@ describe('agent-teams-controller API', () => {
owner: '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({
subject: 'Fix after review',
owner: 'bob',
@ -517,7 +525,9 @@ describe('agent-teams-controller API', () => {
expect(ownerInbox[0].text).toContain('task_get');
expect(ownerInbox[0].text).toContain('task_start');
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(
'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].leadSessionId).toBe('lead-session-1');
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');
const briefing = await controller.tasks.taskBriefing('bob');
@ -549,9 +561,7 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain(`#${reviewTask.displayId}`);
expect(briefing).toContain('reason=review_reviewer_missing');
expect(briefing).toContain(`#${completedTask.displayId}`);
expect(briefing).not.toContain(
'Completed task description should stay out of compact rows'
);
expect(briefing).not.toContain('Completed task description should stay out of compact rows');
expect(briefing).toContain(`#${approvedTask.displayId}`);
expect(briefing).toContain('Counters: actionable=4, awareness=3');
});
@ -709,12 +719,7 @@ describe('agent-teams-controller API', () => {
const firstEvent = restored.historyEvents[0];
expect(firstEvent.status).toBe('pending');
const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
expect(statusChanges).toEqual([
'in_progress',
'completed',
'deleted',
'pending',
]);
expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']);
});
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.to).toBe('review');
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
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 startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
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 () => {
@ -841,7 +879,10 @@ describe('agent-teams-controller API', () => {
it('uses the assigned reviewer when review_start omits from', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Queued for implicit reviewer', owner: 'bob' });
const task = controller.tasks.createTask({
subject: 'Queued for implicit reviewer',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
@ -866,15 +907,23 @@ describe('agent-teams-controller API', () => {
'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');
expect(() =>
controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' })
).toThrow('must be in review before requesting changes');
const deletedTask = controller.tasks.createTask({ subject: 'Deleted review task', owner: 'bob' });
const deletedTask = controller.tasks.createTask({
subject: 'Deleted review task',
owner: 'bob',
});
controller.tasks.softDeleteTask(deletedTask.id, 'bob');
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow('is deleted');
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow(
'is deleted'
);
expect(() =>
controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' })
).toThrow('is deleted');
@ -885,13 +934,19 @@ describe('agent-teams-controller API', () => {
const claudeDir = makeClaudeDir();
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(
'must be completed before starting review'
);
expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none');
const completedTask = controller.tasks.createTask({ subject: 'Completed without review request', owner: 'bob' });
const completedTask = controller.tasks.createTask({
subject: 'Completed without review request',
owner: 'bob',
});
controller.tasks.completeTask(completedTask.id, 'bob');
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
'must be in review before starting review'
@ -907,12 +962,18 @@ describe('agent-teams-controller API', () => {
const claudeDir = makeClaudeDir();
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(
'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');
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
'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.approveReview(task.id, { from: 'alice' });
expect(() => controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })).toThrow(
'is already approved'
);
expect(() =>
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })
).toThrow('is already approved');
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
});
@ -963,7 +1024,9 @@ describe('agent-teams-controller API', () => {
controller.review.startReview(task.id, { from: 'alice' });
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review');
expect(
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_started')
controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_started')
).toHaveLength(1);
controller.review.approveReview(task.id, { from: 'alice' });
@ -976,7 +1039,9 @@ describe('agent-teams-controller API', () => {
expect(approvedAgain.alreadyApproved).toBe(true);
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
expect(
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_approved')
controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_approved')
).toHaveLength(1);
});
@ -1189,7 +1254,11 @@ describe('agent-teams-controller API', () => {
it('wakes task owner on regular comment from another member', () => {
const claudeDir = makeClaudeDir();
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, {
from: 'alice',
@ -1354,7 +1423,10 @@ describe('agent-teams-controller API', () => {
it('rejects task comments from unknown authors', () => {
const claudeDir = makeClaudeDir();
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(() =>
controller.tasks.addTaskComment(task.id, {
@ -1374,7 +1446,10 @@ describe('agent-teams-controller API', () => {
claudeDir,
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, {
from: 'user',
@ -1803,11 +1878,19 @@ describe('agent-teams-controller API', () => {
);
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(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');
controller.kanban.addReviewer('lead');
@ -1822,8 +1905,12 @@ describe('agent-teams-controller API', () => {
.historyEvents.filter((event) => event.type === 'review_requested')
.at(-1);
expect(requested.reviewer).toBe('leadbot');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(
true
);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
false
);
});
it('rejects task_briefing for unknown members', async () => {
@ -1879,7 +1966,10 @@ describe('agent-teams-controller API', () => {
it('clears kanban tasks and column order when task_set_status deletes a review task', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Generic status delete cleanup', owner: 'bob' });
const task = controller.tasks.createTask({
subject: 'Generic status delete cleanup',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
@ -1956,7 +2046,10 @@ describe('agent-teams-controller API', () => {
it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Do not unapprove directly', owner: 'bob' });
const task = controller.tasks.createTask({
subject: 'Do not unapprove directly',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
@ -1980,11 +2073,13 @@ describe('agent-teams-controller API', () => {
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' });
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow('Unknown task owner: boob');
controller.tasks.completeTask(task.id, 'bob');
expect(() => controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })).toThrow(
'Unknown reviewer: boob'
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow(
'Unknown task owner: boob'
);
controller.tasks.completeTask(task.id, 'bob');
expect(() =>
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })
).toThrow('Unknown reviewer: boob');
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
@ -2006,8 +2101,12 @@ describe('agent-teams-controller API', () => {
controller.tasks.softDeleteTask(task.id, 'bob');
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow('use task_restore before starting work');
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow('use task_restore before changing status');
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow(
'use task_restore before starting work'
);
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow(
'use task_restore before changing status'
);
expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
'use task_restore before changing status'
);
@ -2020,7 +2119,10 @@ describe('agent-teams-controller API', () => {
it('rejects task_restore for non-deleted tasks', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
const task = controller.tasks.createTask({
subject: 'Approved task must stay approved',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
@ -2047,7 +2149,9 @@ describe('agent-teams-controller API', () => {
delete state.tasks[task.id];
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)).toContain(task.id);
expect(
controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)
).toContain(task.id);
expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0);
});
@ -2090,7 +2194,10 @@ describe('agent-teams-controller API', () => {
config.members.push({ name: 'carol', role: 'reviewer' });
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Repair mismatched reviewer actor', owner: 'bob' });
const task = controller.tasks.createTask({
subject: 'Repair mismatched reviewer actor',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
@ -2124,7 +2231,11 @@ describe('agent-teams-controller API', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const longSubject = `Long subject ${'x'.repeat(5000)}`;
const task = controller.tasks.createTask({ subject: longSubject, owner: 'bob', notifyOwner: false });
const task = controller.tasks.createTask({
subject: longSubject,
owner: 'bob',
notifyOwner: false,
});
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
fs.writeFileSync(
kanbanPath,
@ -2147,7 +2258,11 @@ describe('agent-teams-controller API', () => {
'utf8'
);
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();

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: {
cacheDir: resolve(__dirname, 'node_modules/.vite/electron-renderer'),
optimizeDeps: {
include: ['@codemirror/language-data'],
exclude: ['@claude-teams/agent-graph']

View file

@ -50,6 +50,8 @@ export interface SlotFrame {
taskColumnCount: number;
}
type OwnerSlotLayoutKind = 'radial-sector' | 'row-orbit' | 'grid-under-lead';
export interface StableSlotLayoutSnapshot {
version: GraphLayoutPort['version'];
teamName: string;
@ -61,6 +63,7 @@ export interface StableSlotLayoutSnapshot {
launchAnchor: { x: number; y: number } | null;
leadCentralReservedBlock: StableRect;
runtimeCentralExclusion: StableRect;
ownerSlotLayoutKind: OwnerSlotLayoutKind;
centralCollisionRects: StableRect[];
memberSlotFrames: SlotFrame[];
memberSlotFrameByOwnerId: Map<string, SlotFrame>;
@ -104,6 +107,20 @@ interface 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 = {
...STABLE_SLOT_GEOMETRY,
activityColumnHeight:
@ -129,11 +146,19 @@ const PROCESS_RAIL_NODE_GAP = 42;
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
const GEOMETRY_EPSILON = 0.001;
const FEED_HEADER_BOTTOM_GAP = 4;
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
const STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS = 96;
const STRICT_SMALL_TEAM_RADIUS_EPSILON = 0.5;
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_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 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: 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>> =
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) =>
layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
)
const ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT: Readonly<Record<number, readonly number[]>> = {
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({
@ -201,10 +260,14 @@ export function buildStableSlotLayoutSnapshot({
SLOT_GEOMETRY.centralPadding
);
const memberSlotFrames =
const memberSlotLayout =
(layout?.mode ?? 'radial') === 'grid-under-lead'
? planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects)
? {
frames: planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects),
kind: 'grid-under-lead' as const,
}
: planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout);
const memberSlotFrames = memberSlotLayout.frames;
const memberSlotFrameByOwnerId = new Map(
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
);
@ -223,6 +286,7 @@ export function buildStableSlotLayoutSnapshot({
launchAnchor: null,
leadCentralReservedBlock,
runtimeCentralExclusion,
ownerSlotLayoutKind: memberSlotLayout.kind,
centralCollisionRects,
memberSlotFrames,
memberSlotFrameByOwnerId,
@ -457,6 +521,21 @@ export function resolveNearestSlotAssignment(args: {
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({
ownerId: args.ownerId,
ownerX: args.ownerX,
@ -568,11 +647,119 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
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: {
frame: SlotFrame;
distanceSquared: number;
} | null = null;
for (const frame of strictFrames) {
for (const frame of args.frames) {
const dx = frame.ownerX - args.ownerX;
const dy = frame.ownerY - args.ownerY;
const distanceSquared = dx * dx + dy * dy;
@ -613,7 +800,7 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
}
function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null {
if (frames.length === 0 || frames.length > 4) {
if (frames.length === 0 || frames.length > 6) {
return null;
}
const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length];
@ -968,7 +1155,22 @@ function planOwnerSlots(
centralCollisionRects: readonly StableRect[],
runtimeCentralExclusion: StableRect,
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)
? planStrictSmallTeamOwnerSlots(
ownerFootprints,
@ -978,7 +1180,10 @@ function planOwnerSlots(
)
: null;
if (strictSmallTeamFrames) {
return strictSmallTeamFrames;
return {
frames: strictSmallTeamFrames,
kind: 'radial-sector',
};
}
const placedFrames: SlotFrame[] = [];
@ -1002,7 +1207,354 @@ function planOwnerSlots(
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(
@ -1012,16 +1564,14 @@ function planGridUnderLeadOwnerSlots(
const frames: SlotFrame[] = [];
const centralBlock = unionRects([...centralCollisionRects]);
let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP;
const columnCount = getGridUnderLeadColumnCount(ownerFootprints.length);
for (
let rowStartIndex = 0;
rowStartIndex < ownerFootprints.length;
rowStartIndex += GRID_UNDER_LEAD_COLUMN_COUNT
rowStartIndex += columnCount
) {
const rowFootprints = ownerFootprints.slice(
rowStartIndex,
rowStartIndex + GRID_UNDER_LEAD_COLUMN_COUNT
);
const rowFootprints = ownerFootprints.slice(rowStartIndex, rowStartIndex + columnCount);
const rowWidth =
rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) +
Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap;
@ -1035,7 +1585,7 @@ function planGridUnderLeadOwnerSlots(
buildSlotFrameAtOwnerAnchor(
footprint,
{
ringIndex: Math.floor(rowStartIndex / GRID_UNDER_LEAD_COLUMN_COUNT),
ringIndex: Math.floor(rowStartIndex / columnCount),
sectorIndex: columnIndex,
},
ownerX,
@ -1051,11 +1601,17 @@ function planGridUnderLeadOwnerSlots(
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(
ownerFootprints: readonly OwnerFootprint[],
layout?: GraphLayoutPort
): boolean {
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) {
if (ownerFootprints.length === 0 || ownerFootprints.length > 6) {
return false;
}
@ -1090,7 +1646,7 @@ function planStrictSmallTeamOwnerSlots(
runtimeCentralExclusion: StableRect,
layout?: GraphLayoutPort
): SlotFrame[] | null {
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) {
if (ownerFootprints.length === 0 || ownerFootprints.length > 6) {
return null;
}
@ -1098,13 +1654,16 @@ function planStrictSmallTeamOwnerSlots(
if (!preset || preset.length !== ownerFootprints.length) {
return null;
}
const vectorByAssignmentKey = new Map(
preset.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
);
const slotConfigs = ownerFootprints.map((footprint) => {
const assignment = layout?.slotAssignments?.[footprint.ownerId];
if (!assignment) {
return null;
}
const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment));
const vector = vectorByAssignmentKey.get(buildAssignmentKey(assignment));
if (!vector) {
return null;
}
@ -1119,72 +1678,164 @@ function planStrictSmallTeamOwnerSlots(
return null;
}
const baseRadiusByAxis = resolveStrictSmallTeamRadiusByAxis(
return packStrictSmallTeamOwnerSlots(
slotConfigs.map((slot) => slot!),
centralCollisionRects,
runtimeCentralExclusion
);
}
for (let iteration = 0; iteration < 48; iteration += 1) {
const radiusBump = iteration * SMALL_TEAM_CARDINAL_RADIUS_STEP;
const frames = slotConfigs.map((slot) => {
const axis = resolveStrictSmallTeamVectorAxis(slot!.vector);
return buildSlotFrameAtRadiusWithVector(
slot!.footprint,
slot!.assignment,
baseRadiusByAxis[axis] +
(axis === 'vertical' ? SMALL_TEAM_CARDINAL_VERTICAL_PADDING : 0) +
radiusBump,
slot!.vector
);
});
const allValid = frames.every((frame, frameIndex) =>
isSlotFramePlacementValid(
frame,
frames.filter((_, index) => index !== frameIndex),
centralCollisionRects
)
function packStrictSmallTeamOwnerSlots(
slotConfigs: readonly {
footprint: OwnerFootprint;
assignment: GraphOwnerSlotAssignment;
vector: { x: number; y: number };
}[],
centralCollisionRects: readonly StableRect[],
runtimeCentralExclusion: StableRect
): SlotFrame[] | null {
const radii = slotConfigs.map((slot) =>
resolveMinimumDirectionalRadiusForVector({
vector: slot.vector,
footprint: slot.footprint,
centralCollisionRects,
runtimeCentralExclusion,
})
);
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;
}
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;
}
function resolveStrictSmallTeamRadiusByAxis(
function buildStrictSmallTeamFrames(
slotConfigs: readonly {
footprint: OwnerFootprint;
assignment: GraphOwnerSlotAssignment;
vector: { x: number; y: number };
}[],
centralCollisionRects: readonly StableRect[],
runtimeCentralExclusion: StableRect
): Record<'horizontal' | 'vertical', number> {
const radiusByAxis = {
horizontal: 0,
vertical: 0,
};
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;
radii: readonly number[]
): SlotFrame[] {
return slotConfigs.map((slot, index) =>
buildSlotFrameAtRadiusWithVector(
slot.footprint,
slot.assignment,
radii[index] ?? 0,
slot.vector
)
);
}
function resolveStrictSmallTeamVectorAxis(vector: {
x: number;
y: number;
}): 'horizontal' | 'vertical' {
return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical';
function findFirstOwnerSlotFrameConflict(
frames: readonly SlotFrame[]
): { leftIndex: number; rightIndex: number } | null {
for (const [leftIndex, left] of frames.entries()) {
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(

View file

@ -80,7 +80,16 @@ export interface TeamGraphData extends TeamViewSnapshot {
function toGraphLaunchVisualState(
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
): 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 {

View file

@ -26,7 +26,7 @@ export const RecentProjectCard = ({
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
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={{
borderLeftColor: color.border,
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.
// 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 './sentry';
@ -76,8 +77,9 @@ import {
} from '@main/services/team/TeamMcpConfigBuilder';
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import {
APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS,
CONTEXT_CHANGED,
SCHEDULE_CHANGE,
SKILLS_CHANGED,
@ -105,6 +107,7 @@ import { join } from 'path';
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { registerRendererLogHandlers } from './ipc/rendererLogs';
import { setReviewMainWindow } from './ipc/review';
import { setTmuxMainWindow } from './ipc/tmux';
import {
@ -209,7 +212,7 @@ import {
} from './services';
import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types';
import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
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. */
const suppressedSources = new Set(['user_sent']);
async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapterRegistry> {
const binaryPath = await ClaudeBinaryResolver.resolve();
async function createOpenCodeRuntimeAdapterRegistry(
reportProgress: (phase: string, message: string) => void = () => undefined
): Promise<TeamRuntimeAdapterRegistry> {
const binaryPath = await ClaudeBinaryResolver.resolve({
onProgress: ({ phase, message }) => reportProgress(`runtime-${phase}`, message),
});
if (!binaryPath) {
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;
return new TeamRuntimeAdapterRegistry();
}
reportProgress('runtime-environment', 'Preparing runtime environment...');
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
try {
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
teamsBasePath: getTeamsBasePath(),
provider: 'opencode',
@ -282,7 +295,10 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
);
}
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];
if (mcpEntry) {
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({
binaryPath,
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
@ -624,6 +641,11 @@ let teamBackupService: TeamBackupService | null = null;
let branchStatusService: BranchStatusService | null = null;
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
let rendererRecoveryAttempts = 0;
let servicesReady = false;
let rendererDidFinishLoad = false;
let fileWatcherStartupStarted = false;
let backgroundStartupTasksStarted = false;
let appStartupHandlersRegistered = false;
// File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null;
@ -636,6 +658,24 @@ const startupTimers = new Set<ReturnType<typeof setTimeout>>();
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
const STARTUP_RECOVERY_DELAY_MS = 10_000;
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 {
return shutdownComplete || shutdownPromise !== null;
@ -653,6 +693,74 @@ function scheduleStartupTask(action: () => void, delayMs: number): void {
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>(
items: readonly T[],
concurrency: number,
@ -1063,6 +1171,12 @@ function reconfigureLocalContextForClaudeRoot(): void {
*/
async function initializeServices(): Promise<void> {
logger.info('Initializing services...');
publishStartupStatus({
phase: 'services',
message: 'Preparing app services...',
ready: false,
error: null,
});
// Initialize SSH connection manager
sshConnectionManager = new SshConnectionManager();
@ -1167,10 +1281,20 @@ async function initializeServices(): Promise<void> {
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
});
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
publishStartupStatus({
phase: 'runtime',
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)
void new TeamMcpConfigBuilder().gcStaleConfigs();
void teamDataService
@ -1267,6 +1391,10 @@ async function initializeServices(): Promise<void> {
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
const apiKeyService = new ApiKeyService();
providerConnectionService.setApiKeyService(apiKeyService);
publishStartupStatus({
phase: 'settings',
message: 'Loading secure settings...',
});
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
// warmup() and ensureInstalled() are deferred to after window creation
// (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
// (did-finish-load handler) to avoid thread pool contention at startup.
publishStartupStatus({
phase: 'ipc',
message: 'Wiring app actions...',
});
// Initialize IPC handlers with registry
initializeIpcHandlers(
contextRegistry,
@ -1529,6 +1662,10 @@ async function initializeServices(): Promise<void> {
}
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);
}
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 {
if (isShutdownStarted()) {
return;
@ -1759,6 +1975,7 @@ function createWindow(): void {
if (isShutdownStarted()) {
return;
}
rendererDidFinishLoad = false;
const isMac = process.platform === 'darwin';
const isDev = process.env.NODE_ENV === 'development';
@ -1780,7 +1997,7 @@ function createWindow(): void {
backgroundColor: '#1a1a1a',
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
title: 'Agent Teams UI',
title: 'Agent Teams AI',
});
markRendererUnavailable(mainWindow);
@ -1850,6 +2067,7 @@ function createWindow(): void {
if (isShutdownStarted()) {
return;
}
rendererDidFinishLoad = false;
markRendererUnavailable(mainWindow);
branchStatusService?.resetAllTracking();
});
@ -1874,57 +2092,8 @@ function createWindow(): void {
}
}, 0);
fullscreenSyncTimer.unref?.();
// Start file watchers now that the window is visible and responsive.
// Deferred from initializeServices() to avoid blocking window creation
// with fs.watch() setup (especially slow on Windows with recursive watchers).
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);
rendererDidFinishLoad = true;
runPostRendererStartupTasks();
}
});
@ -2037,34 +2206,16 @@ function createWindow(): void {
return;
}
markRendererUnavailable(mainWindow);
rendererDidFinishLoad = false;
fileWatcherStartupStarted = false;
branchStatusService?.resetAllTracking();
const activeContext = contextRegistry.getActive();
activeContext?.stopFileWatcher();
contextRegistry?.getActive()?.stopFileWatcher();
if (mainWindow) {
scheduleRendererRecovery(mainWindow);
}
});
// Set main window reference for notification manager and updater
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);
attachMainWindowToServices();
logger.info('Main window created');
}
@ -2074,18 +2225,14 @@ function createWindow(): void {
*/
void app.whenReady().then(async () => {
logger.info('App ready, initializing...');
// 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();
registerAppStartupHandlers();
try {
// Initialize services first
await initializeServices();
publishStartupStatus({
phase: 'electron-ready',
message: 'Opening window...',
});
// Apply configuration settings
const config = configManager.getConfig();
// 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.
}
// Then create window
createWindow();
await initializeServices();
servicesReady = true;
attachMainWindowToServices();
publishStartupStatus({
phase: 'ready',
message: 'Ready',
ready: true,
error: null,
});
runPostRendererStartupTasks();
// Listen for notification click events
notificationManager.on('notification-clicked', (_error) => {
if (isShutdownStarted()) {
@ -2124,6 +2281,12 @@ void app.whenReady().then(async () => {
});
} catch (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) {
createWindow();
}

View file

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

View file

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

View file

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

View file

@ -16,6 +16,15 @@ export interface McpLaunchSpec {
args: string[];
}
export interface McpLaunchSpecResolveProgress {
phase: string;
message: string;
}
export interface McpLaunchSpecResolveOptions {
onProgress?: (progress: McpLaunchSpecResolveProgress) => void;
}
const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const logger = createLogger('Service:TeamMcpConfigBuilder');
@ -158,15 +167,24 @@ export function clearResolvedNodePathForTests(): void {
_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
* Electron binary NOT node so we must resolve node separately.
* 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;
try {
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
const resolved = await new Promise<string>((resolve, reject) => {
execFile(
'node',
@ -180,12 +198,14 @@ async function resolveNodePath(): Promise<string> {
});
if (resolved) {
_resolvedNodePath = resolved;
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
return _resolvedNodePath;
}
} catch {
// node not found or timed out — use bare 'node' and let the OS resolve it
}
_resolvedNodePath = 'node';
emitProgress(options, 'node-runtime-fallback', 'Using system Node.js command...');
return _resolvedNodePath;
}
@ -199,10 +219,11 @@ async function resolveNodePath(): Promise<string> {
*
* 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();
if (!isPackagedApp()) return fallbackEntry;
emitProgress(options, 'packaged-server', 'Checking packaged MCP server...');
const appVersion = getAppVersion();
const baseDir = getMcpServerBasePath();
const finalDir = path.join(baseDir, appVersion);
@ -210,6 +231,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
// Reuse existing valid copy
if (await hasValidServerCopy(finalDir)) {
emitProgress(options, 'packaged-server-reuse', 'Using cached MCP server copy...');
return finalEntry;
}
@ -230,6 +252,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
return fallbackEntry;
}
emitProgress(options, 'packaged-server-copy', 'Copying MCP server to app data...');
// Atomic: copy to temp dir, then rename to final
const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`);
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})`);
emitProgress(options, 'packaged-server-ready', 'MCP server copy is ready...');
return finalEntry;
} catch (error) {
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[] = [];
// 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath
if (isPackagedApp()) {
const packagedEntry = await resolvePackagedServerEntry();
const packagedEntry = await resolvePackagedServerEntry(options);
checked.push(packagedEntry);
if (await pathExists(packagedEntry)) {
return {
command: await resolveNodePath(),
command: await resolveNodePath(options),
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
const sourceEntry = getSourceServerEntry();
emitProgress(options, 'source-entry', 'Checking MCP source entry...');
checked.push(sourceEntry);
if (await pathExists(sourceEntry)) {
emitProgress(options, 'tsx-runner', 'Resolving MCP TypeScript runner...');
const tsxCli = await resolveWorkspaceTsxCli(checked);
if (tsxCli) {
return {
command: await resolveNodePath(),
command: await resolveNodePath(options),
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
const builtEntry = getBuiltServerEntry();
emitProgress(options, 'built-entry', 'Checking built MCP server entry...');
checked.push(builtEntry);
if (await pathExists(builtEntry)) {
return {
command: await resolveNodePath(),
command: await resolveNodePath(options),
args: [builtEntry],
};
}

View file

@ -42,9 +42,9 @@ import {
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill';
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import {
@ -157,15 +157,6 @@ import {
parseBootstrapRuntimeProofDetail,
validateBootstrapRuntimeProofEnvelope,
} from './bootstrap/BootstrapProofValidation';
import {
buildProcessBootstrapPendingDiagnostic,
buildProcessBootstrapTimeoutDiagnostic,
deriveProcessTransportProjectionPhase,
sanitizeProcessRuntimeEventFilePrefix,
summarizeProcessBootstrapTransportEvents,
type ProcessBootstrapTransportEvent,
type ProcessBootstrapTransportSummary,
} from './ProcessBootstrapTransportEvidence';
import {
buildNativeAppManagedBootstrapSpecs,
type NativeAppManagedBootstrapSpec,
@ -247,6 +238,15 @@ import {
} from './idleNotificationMainProcessSemantics';
import { withInboxLock } from './inboxLock';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import {
buildProcessBootstrapPendingDiagnostic,
buildProcessBootstrapTimeoutDiagnostic,
deriveProcessTransportProjectionPhase,
type ProcessBootstrapTransportEvent,
type ProcessBootstrapTransportSummary,
sanitizeProcessRuntimeEventFilePrefix,
summarizeProcessBootstrapTransportEvents,
} from './ProcessBootstrapTransportEvidence';
import {
boundLaunchDiagnostics,
buildProgressLiveOutput,
@ -289,6 +289,7 @@ import {
sanitizeProcessCommandForDiagnostics,
} from './TeamRuntimeLivenessResolver';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskActivityIntervalService } from './TeamTaskActivityIntervalService';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
@ -5451,6 +5452,8 @@ export class TeamProvisioningService {
| null = null;
private readonly memberLogsFinder: TeamMemberLogsFinder;
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService();
private readonly crashRepairedActivityIntervalsByTeam = new Set<string>();
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
private helpOutputCache: string | null = null;
private helpOutputCacheTime = 0;
@ -5504,6 +5507,15 @@ export class TeamProvisioningService {
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 {
void cleanupStaleAnthropicTeamApiKeyHelpers({
baseClaudeDir: getClaudeBasePath(),
@ -12255,6 +12267,20 @@ export class TeamProvisioningService {
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);
if (
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
@ -12394,10 +12420,21 @@ export class TeamProvisioningService {
}> {
const readPersistedStatuses = async (resolvedRunId: string | null) => {
const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName);
this.repairStaleTaskActivityIntervalsOnce(teamName, snapshot);
const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, {
openCodeSecondaryBootstrapPendingMembers:
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 summary = expectedMembers
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
@ -16886,6 +16923,10 @@ export class TeamProvisioningService {
if (existingProvisioningRunId) {
return { runId: existingProvisioningRunId };
}
const previousLaunchSnapshot = await this.launchStateStore
.read(request.teamName)
.catch(() => null);
this.repairStaleTaskActivityIntervalsOnce(request.teamName, previousLaunchSnapshot);
const stopAllGenerationAtStart = this.stopAllTeamsGeneration;
assertAppDeterministicBootstrapEnabled();
if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) {
@ -25806,6 +25847,7 @@ export class TeamProvisioningService {
*/
async stopTeam(teamName: string): Promise<void> {
this.invalidateRuntimeSnapshotCaches(teamName);
this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName);
this.stopPersistentTeamMembers(teamName);
const runId = this.getTrackedRunId(teamName);
@ -26301,6 +26343,7 @@ export class TeamProvisioningService {
if (orphanOnly.length > 0) {
logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`);
for (const teamName of orphanOnly) {
this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName);
this.stopPersistentTeamMembers(teamName);
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
}
@ -30828,7 +30871,14 @@ export class TeamProvisioningService {
return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode';
});
if (configHasOpenCodeMember) {
return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId);
return this.buildConfigLaunchCompatibilityReport(
teamName,
configMembers,
leadProviderId,
{
ignoredInboxNames: true,
}
);
}
const configMembersByName = new Map(
configMembers.map((member) => [member.name.toLowerCase(), member] as const)
@ -30915,7 +30965,8 @@ export class TeamProvisioningService {
private buildConfigLaunchCompatibilityReport(
teamName: string,
configMembers: TeamCreateRequest['members'],
leadProviderId?: TeamProviderId
leadProviderId?: TeamProviderId,
options: { ignoredInboxNames?: boolean } = {}
): TeamLaunchCompatibilityReport {
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
return {
@ -30957,8 +31008,10 @@ export class TeamProvisioningService {
rosterSource: 'config',
members: configMembers,
warnings: [
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
'Run a fresh team bootstrap to persist stable member metadata.',
options.ignoredInboxNames
? '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: [],
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,
TaskHistoryEvent,
TaskRef,
TaskReviewInterval,
TaskWorkInterval,
TeamTask,
} from '@shared/types';
@ -194,6 +195,25 @@ export class TeamTaskReader {
completedAt: i.completedAt,
}))
: 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(
parsed.status as TeamTask['status']
)
@ -223,6 +243,7 @@ export class TeamTaskReader {
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status,
workIntervals,
reviewIntervals,
historyEvents,
blocks: Array.isArray(parsed.blocks)
? (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';
const LEGACY_USER_DATA_DIR_NAMES = [
'Claude Agent Teams UI',
'claude-agent-teams-ui',
'agent-teams-ai',
'Agent Teams UI',
'Claude Agent Teams UI',
'claude-agent-teams-ui',
'claude-devtools',
'claude-code-context',
] as const;
@ -72,6 +72,9 @@ const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([
'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;
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)) {
return {
currentPath,
@ -209,15 +236,23 @@ export function migrateElectronUserDataDirectory(
}
function selectLegacyElectronUserDataPath(currentPath: string): string | null {
const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists);
return (
candidates.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ??
candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ??
candidates[0] ??
null
getLegacyElectronUserDataCandidates(currentPath)
.filter(directoryExists)
.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? 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(
app: ElectronUserDataMigrationApp,
legacyPath: string,
@ -252,7 +287,7 @@ function copyLegacyUserDataDirectory(
copyDirectory(legacyPath, tempPath);
if (directoryExists(currentPath) && !directoryHasEntries(currentPath)) {
if (directoryExists(currentPath) && directoryIsEmpty(currentPath)) {
fs.rmdirSync(currentPath);
}
@ -360,9 +395,9 @@ function directoryExists(targetPath: string): boolean {
}
}
function directoryHasEntries(targetPath: string): boolean {
function directoryIsEmpty(targetPath: string): boolean {
try {
return fs.readdirSync(targetPath).length > 0;
return fs.readdirSync(targetPath).length === 0;
} catch {
return false;
}
@ -381,6 +416,12 @@ function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath:
for (const entry of entries) {
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)) {
continue;
}

View file

@ -20,6 +20,23 @@ const SHELL_ENV_TIMEOUT_MS = 12_000;
let cachedInteractiveShellEnv: 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 {
const parsed: NodeJS.ProcessEnv = {};
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`).
* 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) {
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
return cachedInteractiveShellEnv;
}
if (shellEnvResolvePromise) {
emitProgress(options, 'shell-env-waiting', 'Waiting for shell environment...');
return shellEnvResolvePromise;
}
if (process.platform === 'win32') {
emitProgress(options, 'shell-env-skipped', 'Skipping shell environment on Windows...');
cachedInteractiveShellEnv = {};
return cachedInteractiveShellEnv;
}
@ -110,6 +132,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
shellEnvResolvePromise = (async () => {
const shellPath = process.env.SHELL || '/bin/zsh';
try {
emitProgress(options, 'shell-env-login', 'Reading login shell environment...');
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
cachedInteractiveShellEnv = loginEnv;
return loginEnv;
@ -117,6 +140,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
logger.warn(`Failed to resolve login shell env: ${loginMessage}`);
try {
emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...');
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
cachedInteractiveShellEnv = interactiveEnv;
return interactiveEnv;
@ -124,6 +148,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
const interactiveMessage =
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
return {};
}
} finally {

View file

@ -220,6 +220,7 @@ interface ParsedTask {
reviewState?: unknown;
metadata?: { _internal?: unknown };
workIntervals?: unknown;
reviewIntervals?: unknown;
historyEvents?: unknown;
attachments?: unknown;
sourceMessageId?: unknown;
@ -231,6 +232,12 @@ interface RawWorkInterval {
completedAt?: unknown;
}
interface RawReviewInterval {
reviewer?: unknown;
startedAt?: unknown;
completedAt?: unknown;
}
interface RawHistoryEvent {
id?: 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 {
if (!Array.isArray(parsed.historyEvents)) return undefined;
return (parsed.historyEvents as unknown[])
@ -1479,6 +1507,7 @@ async function readTasksDirForTeam(
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status,
workIntervals: normalizeWorkIntervals(parsed),
reviewIntervals: normalizeReviewIntervals(parsed),
historyEvents: normalizeHistoryEvents(parsed),
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks 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) */
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
// =============================================================================

View file

@ -14,6 +14,8 @@ import {
API_KEYS_SAVE,
API_KEYS_STORAGE_STATUS,
APP_RELAUNCH,
APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS,
CLI_INSTALLER_GET_PROVIDER_STATUS,
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL,
@ -249,6 +251,7 @@ import type {
AppConfig,
ApplyReviewRequest,
ApplyReviewResult,
AppStartupStatus,
AttachmentFileData,
BoardTaskActivityDetailResult,
BoardTaskActivityEntry,
@ -480,6 +483,18 @@ const electronAPI: ElectronAPI = {
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
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'),
getProjects: () => ipcRenderer.invoke('get-projects'),
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}
role="button"
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}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {

View file

@ -1380,6 +1380,18 @@ export const CreateTeamDialog = ({
[request, launchTeam]
);
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(
selectedProviderId,
selectedModel,
@ -1409,7 +1421,13 @@ export const CreateTeamDialog = ({
}
return null;
}, [effectiveMemberDrafts, runtimeProviderStatusById, selectedModel, selectedProviderId]);
}, [
effectiveMemberDrafts,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
soloTeam,
]);
const leadModelIssueText = useMemo(() => {
const issue = getProvisioningModelIssue(
prepareChecks,

View file

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

View file

@ -66,9 +66,9 @@ const PROVIDERS: ProviderDef[] = [
];
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
export const 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.';
export const OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL = 'side lane';
export const OPENCODE_ONE_SHOT_DISABLED_REASON =
'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_ONE_SHOT_DISABLED_BADGE_LABEL = 'team only';
export function getTeamModelLabel(model: string): string {
return getCatalogTeamModelLabel(model) ?? model;
@ -118,9 +118,9 @@ export function formatTeamModelSummary(
/**
* 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).
* 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(
selectedModel: string,

View file

@ -254,7 +254,7 @@ export function analyzeTeammateRuntimeCompatibility({
details.push(
names
? `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) {
@ -317,7 +317,7 @@ export function analyzeTeammateRuntimeCompatibility({
message: checking
? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.'
: 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
? '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.',

View file

@ -20,9 +20,6 @@ vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
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'),
}));

View file

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

View file

@ -20,6 +20,7 @@ import {
buildMemberLaunchDiagnosticsPayload,
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
normalizeMemberLaunchFailureReason,
} from '@renderer/utils/memberLaunchDiagnostics';
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
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 {
try {
const parsed = new URL(url);
@ -180,6 +172,8 @@ export const MemberCard = memo(function MemberCard({
spawnRuntimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawnEntry?.updatedAt,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
@ -197,6 +191,7 @@ export const MemberCard = memo(function MemberCard({
const launchStatusLabel = launchPresentation.launchStatusLabel;
const displayPresenceLabel =
launchVisualState === 'queued' ||
launchVisualState === 'starting_stale' ||
launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
@ -243,6 +238,7 @@ export const MemberCard = memo(function MemberCard({
(presenceLabel === 'starting' ||
presenceLabel === 'connecting' ||
launchVisualState === 'queued' ||
launchVisualState === 'starting_stale' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
@ -289,7 +285,7 @@ export const MemberCard = memo(function MemberCard({
spawnEntry?.runtimeDiagnostic ??
spawnEntry?.error;
const launchFailureReason = showFailedLaunchBadge
? normalizeLaunchFailureReason(rawLaunchFailureReason)
? normalizeMemberLaunchFailureReason(rawLaunchFailureReason)
: null;
const hasLiveLaunchControls =
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
@ -523,10 +519,17 @@ export const MemberCard = memo(function MemberCard({
className="flex shrink-0 items-center gap-1"
title={runtimeEntry?.runtimeDiagnostic}
>
<SyncedLoader2
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
aria-label={launchBadgeLabel}
/>
{launchVisualState === 'starting_stale' ? (
<AlertTriangle
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
variant="secondary"
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}
spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed}
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
spawnFirstSpawnAcceptedAt={spawnEntry?.firstSpawnAcceptedAt}
spawnUpdatedAt={spawnEntry?.updatedAt}
runtimeEntry={runtimeEntry}
isLaunchSettling={isLaunchSettling}
onUpdateRole={

View file

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

View file

@ -159,6 +159,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
spawnUpdatedAt: spawnEntry?.updatedAt,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
@ -176,7 +178,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({
const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel
: launchVisualState === 'bootstrap_stalled' ||
: launchVisualState === 'starting_stale' ||
launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
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(
left: TeamTaskWithKanban['historyEvents'],
right: TeamTaskWithKanban['historyEvents']
@ -166,6 +184,7 @@ function areMemberTaskMapsEquivalent(
leftTask.reviewState !== rightTask.reviewState ||
leftTask.kanbanColumn !== rightTask.kanbanColumn ||
!areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) ||
!areTaskReviewIntervalsEquivalent(leftTask.reviewIntervals, rightTask.reviewIntervals) ||
!areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents)
) {
return false;

View file

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

View file

@ -237,11 +237,155 @@
animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards;
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 {
to {
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 */
@keyframes splash-node {
@ -284,6 +428,26 @@
:root.light #splash-tagline {
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 {
opacity: 0.02;
}
@ -307,6 +471,7 @@
#splash,
#splash-enhanced-canvas,
#splash-logo,
#splash-progress-bar,
#splash-tagline > span,
.splash-node {
animation: none !important;
@ -446,6 +611,13 @@
<div id="splash-copy">
<div id="splash-text">Agent Teams AI</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 id="root"></div>

View file

@ -9,6 +9,8 @@ import { App } from './App';
import { initSentryRenderer } from './sentry';
import { initializeNotificationListeners } from './store';
import type { AppStartupStatus, AppStartupStep } from '@shared/types/api';
declare global {
interface Window {
__claudeTeamsUiDidInit?: boolean;
@ -18,16 +20,199 @@ declare global {
// Sentry must be initialised before React renders.
initSentryRenderer();
// 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();
let root: ReactDOM.Root | null = null;
let latestStartupStatus: AppStartupStatus | null = null;
let startupTicker: number | undefined;
const SLOW_STEP_MS = 7_000;
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(
<React.StrictMode>
<App />
</React.StrictMode>
);
function formatDuration(ms: number): string {
const safeMs = Math.max(0, ms);
const seconds = Math.floor(safeMs / 1000);
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;
}
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(

View file

@ -332,6 +332,47 @@ export function deriveReviewActivityTimerAnchor(
const memberKey = normalizeMemberName(params.memberName);
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 : [];
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
@ -369,6 +410,27 @@ export function deriveReviewActivityTimerAnchor(
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 {
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;
export const MEMBER_STARTING_STALE_AFTER_MS = 2 * 60 * 1000;
function isLaunchStillStarting(
spawnStatus: MemberSpawnStatus | undefined,
@ -634,6 +635,7 @@ export type MemberLaunchVisualState =
| 'queued'
| 'waiting'
| 'spawning'
| 'starting_stale'
| 'permission_pending'
| 'bootstrap_stalled'
| 'runtime_pending'
@ -666,6 +668,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
return 'waiting to start';
case 'spawning':
return 'starting';
case 'starting_stale':
return 'starting stale';
case 'permission_pending':
return 'awaiting permission';
case 'bootstrap_stalled':
@ -700,6 +704,8 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
case 'runtime_pending':
case 'runtime_candidate':
return 'bg-amber-400 animate-pulse';
case 'starting_stale':
return 'bg-amber-400';
case 'registered_only':
return SPAWN_DOT_COLORS.waiting;
case 'shell_only':
@ -794,6 +800,41 @@ function hasElapsedSinceIso(
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 {
const normalized = value?.trim().toLowerCase() ?? '';
return (
@ -881,12 +922,15 @@ export function buildMemberLaunchPresentation({
spawnRuntimeAlive,
spawnBootstrapConfirmed,
spawnBootstrapStalled,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
runtimeAdvisory,
runtimeEntry,
isLaunchSettling = false,
isTeamAlive,
isTeamProvisioning,
leadActivity,
nowMs,
}: {
member: ResolvedTeamMember;
spawnStatus: MemberSpawnStatus | undefined;
@ -895,12 +939,15 @@ export function buildMemberLaunchPresentation({
spawnRuntimeAlive: boolean | undefined;
spawnBootstrapConfirmed?: boolean;
spawnBootstrapStalled?: boolean;
spawnFirstSpawnAcceptedAt?: string;
spawnUpdatedAt?: string;
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
runtimeEntry?: TeamAgentRuntimeEntry;
isLaunchSettling?: boolean;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
nowMs?: number;
}): MemberLaunchPresentation {
const hasConfirmedSpawnLaunch =
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
@ -943,6 +990,15 @@ export function buildMemberLaunchPresentation({
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId);
const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory);
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
const startingIsStale =
!hasConfirmedSpawnLaunch &&
isMemberStartingStale({
spawnStatus,
spawnLaunchState,
spawnFirstSpawnAcceptedAt,
spawnUpdatedAt,
nowMs,
});
let launchVisualState: MemberLaunchVisualState = null;
if (isTeamAlive !== false || isTeamProvisioning) {
@ -969,6 +1025,8 @@ export function buildMemberLaunchPresentation({
runtimeEntry?.livenessKind === 'not_found')
) {
launchVisualState = 'stale_runtime';
} else if (!hasConfirmedSpawnLaunch && startingIsStale) {
launchVisualState = 'starting_stale';
} else if (
!hasConfirmedSpawnLaunch &&
isQueuedOpenCodeLaunch(
@ -1007,6 +1065,7 @@ export function buildMemberLaunchPresentation({
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
const shouldShowLaunchStatusAsPresence =
launchVisualState === 'queued' ||
launchVisualState === 'starting_stale' ||
launchVisualState === 'permission_pending' ||
launchVisualState === 'bootstrap_stalled' ||
launchVisualState === 'runtime_pending' ||
@ -1023,7 +1082,9 @@ export function buildMemberLaunchPresentation({
const spawnBadgeLabel =
spawnStatus && spawnStatus !== 'online'
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
? 'starting'
? startingIsStale
? 'starting stale'
: 'starting'
: spawnStatus
: null;
@ -1033,7 +1094,7 @@ export function buildMemberLaunchPresentation({
runtimeAdvisoryTone === 'error'
? STATUS_DOT_COLORS.terminated
: (launchVisualStateDotClass ?? baseDotClass),
cardClass,
cardClass: launchVisualState === 'starting_stale' ? 'opacity-90' : cardClass,
runtimeAdvisoryLabel,
runtimeAdvisoryTitle,
runtimeAdvisoryTone,

View file

@ -13,6 +13,7 @@ export interface MemberLaunchDiagnosticsPayload {
teamName?: string;
runId?: string;
memberName: string;
memberCardError?: string;
launchState?: MemberLaunchState;
spawnStatus?: MemberSpawnStatus;
livenessKind?: TeamAgentRuntimeLivenessKind;
@ -55,6 +56,15 @@ function boundedNumber(value: number | undefined): number | 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(
...groups: (readonly (string | undefined)[] | undefined)[]
): string[] | undefined {
@ -91,7 +101,16 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
boundedString(runtimeEntry?.runtimeDiagnostic) ??
boundedString(spawnEntry?.hardFailureReason) ??
boundedString(spawnEntry?.error);
const memberCardError = boundedString(
normalizeMemberLaunchFailureReason(
spawnEntry?.error ??
spawnEntry?.hardFailureReason ??
spawnEntry?.runtimeDiagnostic ??
runtimeEntry?.runtimeDiagnostic
) ?? undefined
);
const diagnostics = uniqueDiagnostics(
memberCardError ? [memberCardError] : undefined,
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
spawnEntry?.error ? [spawnEntry.error] : undefined,
@ -103,6 +122,7 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
...(params.teamName ? { teamName: params.teamName } : {}),
...(runId ? { runId } : {}),
memberName: params.memberName,
...(memberCardError ? { memberCardError } : {}),
...((spawnEntry?.launchState ?? params.launchState)
? { launchState: spawnEntry?.launchState ?? params.launchState }
: {}),
@ -161,6 +181,7 @@ export function hasMemberLaunchDiagnosticsDetails(
return Boolean(
(payload.launchState && payload.launchState !== 'confirmed_alive') ||
(payload.spawnStatus && payload.spawnStatus !== 'online') ||
payload.memberCardError ||
payload.bootstrapStalled === true ||
weakLiveness ||
payload.runtimeDiagnostic ||
@ -182,7 +203,12 @@ export function getMemberLaunchDiagnosticsErrorMessage(
if (!hasMemberLaunchDiagnosticsError(payload)) {
return undefined;
}
return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed';
return (
payload.memberCardError ??
payload.runtimeDiagnostic ??
payload.diagnostics?.[0] ??
'Launch failed'
);
}
export function formatMemberLaunchDiagnosticsPayload(

View file

@ -319,6 +319,34 @@ export interface UpdaterAPI {
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
// =============================================================================
@ -770,6 +798,7 @@ export interface ReviewAPI {
* Complete Electron API exposed to the renderer process via preload script.
*/
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
startup?: AppStartupAPI;
getAppVersion: () => Promise<string>;
getProjects: () => Promise<Project[]>;
getSessions: (projectId: string) => Promise<Session[]>;

View file

@ -105,6 +105,15 @@ export interface TaskWorkInterval {
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
// ---------------------------------------------------------------------------
@ -470,6 +479,10 @@ export interface TeamTask {
* We persist intervals for reliable log attribution without relying on heuristics.
*/
workIntervals?: TaskWorkInterval[];
/**
* Review work periods, split across runtime pauses/restarts just like workIntervals.
*/
reviewIntervals?: TaskReviewInterval[];
/**
* Unified workflow event log.
* 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-');
}
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(
availableLaunchModels: Iterable<string> | undefined
): Set<string> {
@ -52,9 +62,10 @@ export function resolveAnthropicLaunchModel(params: {
if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) {
const staticDefault = getAnthropicDefaultTeamModel(params.limitContext);
const runtimeDefault = params.defaultLaunchModel?.trim() || null;
const rawPreferredDefault = runtimeDefault || staticDefault;
const preferredDefault = params.limitContext
? stripOneMillionSuffix(runtimeDefault || staticDefault) || staticDefault
: runtimeDefault || staticDefault;
? stripOneMillionSuffix(rawPreferredDefault) || staticDefault
: normalizeStandardOnlyAnthropicModel(rawPreferredDefault) || staticDefault;
if (availableModels.size === 0) {
return preferredDefault;
}
@ -74,7 +85,11 @@ export function resolveAnthropicLaunchModel(params: {
return null;
}
if (params.limitContext || isAnthropicHaikuModel(baseModel)) {
if (
params.limitContext ||
isAnthropicHaikuModel(baseModel) ||
isAnthropicSonnetModel(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: 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(

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(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(
'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',
});
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';
class FakeElectronApp implements ElectronUserDataMigrationApp {
setPathCalls: Array<{ name: string; value: string }> = [];
setPathCalls: { name: string; value: string }[] = [];
constructor(private userDataPath: string) {}
@ -74,9 +74,9 @@ describe('electron userData migration', () => {
const parentPath = path.dirname(currentPath);
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, 'agent-teams-ai'),
path.join(parentPath, 'claude-devtools'),
path.join(parentPath, 'claude-code-context'),
]);
@ -106,6 +106,34 @@ describe('electron userData migration', () => {
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', () => {
const root = createTempRoot();
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 () => {
const root = createTempRoot();
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 legacyPath = path.join(root, 'claude-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 result = migrateElectronUserDataDirectory(app);
@ -404,7 +563,31 @@ describe('electron userData migration', () => {
{ name: 'userData', 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', () => {

View file

@ -128,9 +128,12 @@ describe('formatTeamModelSummary', () => {
});
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('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', () => {
@ -177,7 +180,7 @@ describe('computeEffectiveTeamModel', () => {
it('does not double-append [1m] when input already has it', () => {
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]');
});
@ -185,6 +188,56 @@ describe('computeEffectiveTeamModel', () => {
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', () => {
expect(computeEffectiveTeamModel('opus', 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,
providerDisabledReasonById: {
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: {
opencode: 'side lane',
opencode: 'team only',
},
})
);
@ -990,9 +990,9 @@ describe('TeamModelSelector disabled Codex models', () => {
);
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
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 () => {
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));

View file

@ -135,7 +135,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
name: draft.name,
role: draft.customRole || undefined,
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,
model: draft.model,
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
@ -170,8 +170,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
fastMode: member.fastMode,
})),
filterEditableMemberInputs: (members: unknown) => members,
normalizeLeadProviderForMode: (providerId: unknown) =>
providerId === 'opencode' ? 'anthropic' : providerId,
normalizeLeadProviderForMode: (providerId: unknown) => providerId,
normalizeMemberDraftForProviderMode: (member: unknown) => member,
normalizeProviderForMode: (providerId: unknown) => providerId,
validateMemberNameInline: () => null,
@ -385,9 +384,9 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
computeEffectiveTeamModel: (model: string) => model || undefined,
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
[providerId, model, effort].filter(Boolean).join(' '),
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.',
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only',
OPENCODE_ONE_SHOT_DISABLED_REASON:
'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', () => ({
@ -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.mocked(isTeamModelAvailableForUi).mockImplementation(
(_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false
@ -769,7 +768,7 @@ describe('LaunchTeamDialog', () => {
},
],
} as any;
vi.mocked(api.teams.getSavedRequest).mockResolvedValue({
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
teamName: 'team-alpha',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
@ -777,7 +776,8 @@ describe('LaunchTeamDialog', () => {
{
name: 'alice',
role: 'Reviewer',
model: 'gemini-3-pro-preview',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
],
} as any);
@ -812,7 +812,7 @@ describe('LaunchTeamDialog', () => {
const opencodePrepareCalls = vi
.mocked(runProviderPrepareDiagnostics)
.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(
(button) => button.textContent === 'Launch team'
@ -831,7 +831,8 @@ describe('LaunchTeamDialog', () => {
{
name: 'alice',
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 }]>
)[0]?.[0] as { providerId?: string; model?: string } | undefined;
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 () => {
root.unmount();

View file

@ -94,7 +94,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
expect(result.blocksSubmission).toBe(true);
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');
});

View file

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

View file

@ -228,7 +228,9 @@ function restoreWindowAnimationFrame(): void {
originalWindowAnimationFrame.hasRequest
? originalWindowAnimationFrame.requestAnimationFrame
: undefined,
originalWindowAnimationFrame.hasCancel ? originalWindowAnimationFrame.cancelAnimationFrame : undefined
originalWindowAnimationFrame.hasCancel
? originalWindowAnimationFrame.cancelAnimationFrame
: undefined
);
}
@ -524,9 +526,7 @@ describe('teamSlice actions', () => {
member: 'bob',
text: 'hello',
});
await store
.getState()
.refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
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.'
@ -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', () => {
const store = createSliceStore();
store.setState({
@ -1352,10 +1397,7 @@ describe('teamSlice actions', () => {
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
expect(hoisted.getData).toHaveBeenCalledTimes(2);
expect(hoisted.getData.mock.calls[0]).toEqual([
'my-team',
{ includeMemberBranches: false },
]);
expect(hoisted.getData.mock.calls[0]).toEqual(['my-team', { includeMemberBranches: false }]);
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
thinRequest.resolve(thinSnapshot);
@ -1414,7 +1456,9 @@ describe('teamSlice actions', () => {
hoisted.getData
.mockImplementationOnce(() => alphaThin.promise)
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }))
.mockResolvedValueOnce(
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
)
.mockResolvedValueOnce(alphaFull);
const alphaSelect = store.getState().selectTeam('alpha-team');
@ -1427,7 +1471,9 @@ describe('teamSlice actions', () => {
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 flushAsyncWork();
@ -1509,8 +1555,12 @@ describe('teamSlice actions', () => {
const store = createSliceStore();
hoisted.getData
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } }))
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }));
.mockResolvedValueOnce(
createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } })
)
.mockResolvedValueOnce(
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
);
await store.getState().selectTeam('alpha-team');
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
@ -3480,9 +3530,7 @@ describe('teamSlice actions', () => {
const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team');
expect(result.failed).toEqual([
{ memberName: 'alice', error: 'OpenRouter credits exhausted' },
]);
expect(result.failed).toEqual([{ memberName: 'alice', error: 'OpenRouter credits exhausted' }]);
expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team');
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');

View file

@ -165,6 +165,42 @@ describe('memberActivityTimer', () => {
).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', () => {
const timerId = createMemberActivityTimerId({
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', () => {
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };

View file

@ -4,6 +4,7 @@ import {
buildMemberLaunchDiagnosticsPayload,
formatMemberLaunchDiagnosticsPayload,
hasMemberLaunchDiagnosticsDetails,
getMemberLaunchDiagnosticsErrorMessage,
} from '@renderer/utils/memberLaunchDiagnostics';
describe('member launch diagnostics', () => {
@ -62,4 +63,31 @@ describe('member launch diagnostics', () => {
expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true);
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]'],
})
).toBe('opus');
});
it('preserves limitContext requests and never manufactures 1M Haiku variants', () => {
expect(
resolveAnthropicLaunchModel({
selectedModel: 'sonnet',
limitContext: true,
selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION,
limitContext: false,
defaultLaunchModel: 'sonnet[1m]',
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
})
).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(
resolveAnthropicLaunchModel({
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({
root: resolve(ROOT, 'src/renderer'),
cacheDir: resolve(ROOT, 'node_modules/.vite/web-renderer'),
plugins: [react()],
server: {
host: '127.0.0.1',