merge: integrate dev runtime 0.0.9

This commit is contained in:
777genius 2026-04-26 00:16:32 +03:00
commit ee6404dc05
152 changed files with 23243 additions and 1946 deletions

View file

@ -12,5 +12,16 @@ For new features:
- Reference implementation: `src/features/recent-projects`
- Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md)
## Review guidelines
- Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority.
- Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints.
- Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases.
- Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`.
- Flag changes that can break `isMeta` semantics, chunk generation, teammate message parsing, task/subagent filtering, or structured task references.
- Ensure IPC and main-process handlers validate inputs, fail gracefully, and do not expose unsafe filesystem or process access.
- Confirm user-visible workflows have focused tests or a clear verification path when they touch parsing, persistence, IPC, Git, provider auth, or review flows.
- Prefer `pnpm` commands for verification and avoid recommending `pnpm lint:fix` unless the PR explicitly intends broad formatting changes.
Do not treat this file as a second source of truth.
Keep architecture rules centralized in [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md).

View file

@ -1,7 +1,36 @@
const messageStore = require('./messageStore.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const { isOpenCodeMember } = require('./memberMessagingProtocol.js');
const PLACEHOLDER_TASK_REF_PREFIX = /^\s*#0{8}\b\s*(?:[:.-]\s*)?/i;
const IDLE_ACK_MAX_CHARS = 180;
const IDLE_ACK_EXACT_TEXT = new Set([
'ok',
'okay',
'understood',
'got it',
'ready',
'waiting',
'waiting for tasks',
'awaiting tasks',
'no tasks',
'no assigned tasks',
'no actionable tasks',
'понял',
'поняла',
'понял жду',
'понял жду задачи',
'принял',
'приняла',
'ок',
'окей',
'готов',
'готов к работе',
'жду',
'жду задачи',
'нет задач',
'нет назначенных задач',
]);
function stripPlaceholderTaskRefPrefix(value) {
if (typeof value !== 'string' || !PLACEHOLDER_TASK_REF_PREFIX.test(value)) {
@ -22,6 +51,82 @@ function normalizePlaceholderTaskRefPrefixes(flags) {
return next;
}
function normalizeIdleAckText(value) {
return String(value || '')
.toLowerCase()
.replace(/[#*_`"'“”‘’«»()[\]{}.,!?;:<>/\\|-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function looksLikeIdleAckOnlyText(value) {
const normalized = normalizeIdleAckText(value);
if (!normalized || normalized.length > IDLE_ACK_MAX_CHARS) {
return false;
}
if (IDLE_ACK_EXACT_TEXT.has(normalized)) {
return true;
}
const hasNoTaskPhrase =
normalized.includes('нет назначенных задач') ||
normalized.includes('нет задач') ||
normalized.includes('no assigned tasks') ||
normalized.includes('no actionable tasks') ||
normalized.includes('no tasks');
const hasWaitingPhrase =
normalized.includes('жду задачи') ||
normalized.includes('ожидаю задачи') ||
normalized.includes('waiting for tasks') ||
normalized.includes('awaiting tasks');
const hasReadyPhrase =
normalized.includes('готов к работе') ||
normalized.includes('готов работать') ||
normalized.includes('ready to work');
const hasNoMoreMessagingPhrase =
normalized.includes('больше не буду') &&
(normalized.includes('писать') ||
normalized.includes('отправлять') ||
normalized.includes('message') ||
normalized.includes('send'));
const hasIdlePhrase =
normalized.includes('idle') &&
(normalized.includes('task') || normalized.includes('wait') || normalized.includes('ready'));
return (
hasNoTaskPhrase ||
hasWaitingPhrase ||
hasReadyPhrase ||
hasNoMoreMessagingPhrase ||
hasIdlePhrase
);
}
function hasExplicitDeliveryContext(flags) {
if (typeof flags.relayOfMessageId === 'string' && flags.relayOfMessageId.trim()) return true;
if (Array.isArray(flags.taskRefs) && flags.taskRefs.length > 0) return true;
if (Array.isArray(flags.attachments) && flags.attachments.length > 0) return true;
if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) return true;
return false;
}
function findResolvedMember(paths, memberName) {
const resolvedName = runtimeHelpers.resolveExplicitTeamMemberName(paths, memberName, {
allowLeadAliases: true,
});
if (!resolvedName) return null;
const key = resolvedName.toLowerCase();
const members = runtimeHelpers.resolveTeamMembers(paths).members || [];
return members.find((member) => String(member?.name || '').trim().toLowerCase() === key) || null;
}
function isLeadRecipient(paths, to) {
const target = String(to || '').trim().toLowerCase();
if (!target) return false;
const lead = runtimeHelpers.inferLeadName(paths).trim().toLowerCase();
return target === 'lead' || target === 'team-lead' || (lead && target === lead);
}
function normalizeMessageSendFlags(context, flags) {
const next = { ...(flags || {}) };
const rawTo =
@ -81,9 +186,31 @@ function assertUserDirectedMessageHasSender(context, flags) {
});
}
function assertOpenCodeMessageIsNotBootstrapNoise(context, flags) {
const to = typeof flags.to === 'string' ? flags.to.trim().toLowerCase() : '';
if (to !== 'user' && !isLeadRecipient(context.paths, to)) {
return;
}
if (hasExplicitDeliveryContext(flags)) {
return;
}
const from = typeof flags.from === 'string' ? flags.from.trim() : '';
const sender = findResolvedMember(context.paths, from);
if (!isOpenCodeMember(sender)) {
return;
}
if (!looksLikeIdleAckOnlyText(flags.text) && !looksLikeIdleAckOnlyText(flags.summary)) {
return;
}
throw new Error(
'OpenCode idle/ack-only message_send was not delivered. Wait silently unless replying to an app-delivered message or actionable task.'
);
}
function sendMessage(context, flags) {
const normalized = normalizeMessageSendFlags(context, normalizePlaceholderTaskRefPrefixes(flags));
assertUserDirectedMessageHasSender(context, normalized);
assertOpenCodeMessageIsNotBootstrapNoise(context, normalized);
return messageStore.sendInboxMessage(context.paths, normalized);
}

View file

@ -129,6 +129,37 @@ function buildAssignmentMessage(context, task, options = {}) {
return lines.join('\n');
}
function buildTaskRef(context, task) {
return {
taskId: task.id,
displayId: task.displayId || task.id,
teamName: context.teamName,
};
}
function mergeTaskRefs(primaryTaskRef, extraTaskRefs) {
const refs = [primaryTaskRef, ...(Array.isArray(extraTaskRefs) ? extraTaskRefs : [])]
.filter((ref) => ref && typeof ref === 'object');
const seen = new Set();
const merged = [];
for (const ref of refs) {
const taskId = typeof ref.taskId === 'string' ? ref.taskId.trim() : '';
const displayId = typeof ref.displayId === 'string' ? ref.displayId.trim() : '';
const teamName = typeof ref.teamName === 'string' ? ref.teamName.trim() : '';
const key = `${teamName || ''}:${taskId || displayId}`;
if ((!taskId && !displayId) || seen.has(key)) {
continue;
}
seen.add(key);
merged.push({
...(taskId ? { taskId } : {}),
...(displayId ? { displayId } : {}),
...(teamName ? { teamName } : {}),
});
}
return merged.length > 0 ? merged : undefined;
}
function buildCommentNotificationMessage(context, task, comment) {
const taskLabel = `#${task.displayId || task.id}`;
return [
@ -171,7 +202,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
...options,
messagingProtocol,
}),
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
taskRefs: mergeTaskRefs(buildTaskRef(context, task), options.taskRefs),
summary,
source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}),
@ -869,6 +900,9 @@ async function memberBriefing(context, memberName, options = {}) {
...(messagingProtocol.runtimeProvider === 'opencode'
? [
'OpenCode visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.',
'OpenCode bootstrap silence rule: if this briefing was requested because the desktop app attached or reconnected you, do not send readiness, understood, idle, or no-task acknowledgements to the user, lead, or teammates.',
'This briefing already includes your current Task briefing. If it shows no actionable tasks, stop and wait silently. Do not call task_briefing again in the same bootstrap turn just to check for work.',
'Use agent-teams_message_send only for actual app-delivered messages, actionable task coordination, blockers, or task results.',
'For cross-team replies or messages to another team, call agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to.',
]
: []),

View file

@ -173,6 +173,10 @@ describe('agent-teams-controller API', () => {
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
);
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
expect(briefing).toContain('OpenCode bootstrap silence rule');
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"'
);
@ -182,6 +186,54 @@ describe('agent-teams-controller API', () => {
expect(briefing).not.toContain('notify your team lead via SendMessage');
});
it('rejects OpenCode idle acknowledgements without explicit delivery context', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
expect(() =>
controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Понял.',
})
).toThrow('OpenCode idle/ack-only message_send was not delivered');
expect(() =>
controller.messages.sendMessage({
to: 'team-lead',
from: 'bob',
text: 'Нет назначенных задач.',
})
).toThrow('OpenCode idle/ack-only message_send was not delivered');
expect(() =>
controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Понял.',
source: 'runtime_delivery',
})
).toThrow('OpenCode idle/ack-only message_send was not delivered');
const delivered = controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Понял.',
source: 'runtime_delivery',
relayOfMessageId: 'msg-inbound-1',
});
expect(delivered.deliveredToInbox).toBe(true);
});
it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -1047,6 +1099,26 @@ describe('agent-teams-controller API', () => {
expect(rows[0].leadSessionId).toBe('lead-session-1');
});
it('includes the assigned task ref in owner assignment notifications', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Implement runtime handoff',
owner: 'bob',
descriptionTaskRefs: [{ taskId: 'related-task', displayId: 'rel12345', teamName: 'my-team' }],
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].summary).toBe(`New task #${task.displayId} assigned`);
expect(rows[0].taskRefs).toEqual([
{ taskId: task.id, displayId: task.displayId, teamName: 'my-team' },
{ taskId: 'related-task', displayId: 'rel12345', teamName: 'my-team' },
]);
});
it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'message_send',
description:
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. When to is "user", from is required and must be your configured teammate name. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
parameters: z.object({
...toolContextSchema,
to: z.string().min(1),
@ -22,6 +22,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
from: z.string().optional(),
summary: z.string().optional(),
source: z.string().optional(),
relayOfMessageId: z.string().optional(),
leadSessionId: z.string().optional(),
attachments: z
.array(
@ -51,6 +52,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
from,
summary,
source,
relayOfMessageId,
leadSessionId,
attachments,
taskRefs,
@ -64,6 +66,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
...(from ? { from } : {}),
...(summary ? { summary } : {}),
...(source ? { source } : {}),
...(relayOfMessageId ? { relayOfMessageId } : {}),
...(leadSessionId ? { leadSessionId } : {}),
...(attachments?.length ? { attachments } : {}),
...(taskRefs?.length ? { taskRefs } : {}),

View file

@ -591,6 +591,8 @@ describe('agent-teams-mcp tools', () => {
openCodeMemberBriefing as { content: Array<{ text: string }> }
).content[0]?.text;
expect(openCodeMemberBriefingText).toContain('agent-teams_message_send');
expect(openCodeMemberBriefingText).toContain('OpenCode bootstrap silence rule');
expect(openCodeMemberBriefingText).toContain('stop and wait silently');
expect(openCodeMemberBriefingText).toContain('Full details in task comment e5f6a7b8');
expect(openCodeMemberBriefingText).toContain(
'Never invent placeholder task refs such as #00000000'
@ -1224,6 +1226,7 @@ describe('agent-teams-mcp tools', () => {
from: 'lead',
summary: 'Metadata test',
source: 'system_notification',
relayOfMessageId: 'msg-original-1',
leadSessionId: 'session-42',
attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }],
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName }],
@ -1234,6 +1237,7 @@ describe('agent-teams-mcp tools', () => {
const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows[0].source).toBe('system_notification');
expect(rows[0].relayOfMessageId).toBe('msg-original-1');
expect(rows[0].leadSessionId).toBe('session-42');
expect(rows[0].attachments[0].filename).toBe('note.txt');
expect(rows[0].taskRefs).toEqual([{ taskId: 'task-1', displayId: 'abcd1234', teamName }]);

View file

@ -18,48 +18,60 @@ export interface UseGraphInteractionResult {
handleDoubleClick: (wx: number, wy: number, nodes: GraphNode[]) => string | null;
}
export interface UseGraphInteractionOptions {
canDragNode?: (node: GraphNode) => boolean;
}
export function useGraphInteraction(
onDragNode?: (nodeId: string, x: number, y: number) => void,
options?: UseGraphInteractionOptions
): UseGraphInteractionResult {
const hoveredNodeId = useRef<string | null>(null);
const dragNodeId = useRef<string | null>(null);
const isDragging = useRef(false);
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
const clickedNodeId = useRef<string | null>(null);
const canDragNode = options?.canDragNode;
const handleMouseDown = useCallback((wx: number, wy: number, nodes: GraphNode[]) => {
mouseDownPos.current = { x: wx, y: wy };
const hit = findNodeAt(wx, wy, nodes);
clickedNodeId.current = hit;
const handleMouseDown = useCallback(
(wx: number, wy: number, nodes: GraphNode[]) => {
mouseDownPos.current = { x: wx, y: wy };
const hit = findNodeAt(wx, wy, nodes);
clickedNodeId.current = hit;
if (hit) {
// Stable-slot layout keeps lead fixed in the center. Only members can be dragged between slots.
const hitNode = nodes.find((n) => n.id === hit);
if (hitNode?.kind === 'member') {
dragNodeId.current = hit;
if (hit) {
// Stable-slot layout keeps lead fixed in the center. Only members can be dragged between slots.
const hitNode = nodes.find((n) => n.id === hit);
if (hitNode?.kind === 'member' && (canDragNode?.(hitNode) ?? true)) {
dragNodeId.current = hit;
}
}
}
}, []);
},
[canDragNode]
);
const handleMouseMove = useCallback((wx: number, wy: number, nodes: GraphNode[]) => {
// Check drag threshold
if (mouseDownPos.current && dragNodeId.current) {
const dx = wx - mouseDownPos.current.x;
const dy = wy - mouseDownPos.current.y;
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
isDragging.current = true;
const handleMouseMove = useCallback(
(wx: number, wy: number, nodes: GraphNode[]) => {
// Check drag threshold
if (mouseDownPos.current && dragNodeId.current) {
const dx = wx - mouseDownPos.current.x;
const dy = wy - mouseDownPos.current.y;
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
isDragging.current = true;
}
}
}
// Drag node
if (isDragging.current && dragNodeId.current) {
onDragNode?.(dragNodeId.current, wx, wy);
return;
}
// Drag node
if (isDragging.current && dragNodeId.current) {
onDragNode?.(dragNodeId.current, wx, wy);
return;
}
// Hover detection
hoveredNodeId.current = findNodeAt(wx, wy, nodes);
}, [onDragNode]);
// Hover detection
hoveredNodeId.current = findNodeAt(wx, wy, nodes);
},
[onDragNode]
);
const handleMouseUp = useCallback((): string | null => {
const wasDragging = isDragging.current;
@ -77,9 +89,12 @@ export function useGraphInteraction(
return null;
}, []);
const handleDoubleClick = useCallback((wx: number, wy: number, nodes: GraphNode[]): string | null => {
return findNodeAt(wx, wy, nodes);
}, []);
const handleDoubleClick = useCallback(
(wx: number, wy: number, nodes: GraphNode[]): string | null => {
return findNodeAt(wx, wy, nodes);
},
[]
);
return useMemo(
() => ({

View file

@ -4,6 +4,7 @@ import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
import { getStateColor } from '../constants/colors';
import {
buildStableSlotLayoutSnapshot,
resolveNearestGridOwnerTarget,
resolveNearestSlotAssignment,
snapshotToWorldBounds,
translateSlotFrame,
@ -14,7 +15,13 @@ import {
} from '../layout/stableSlots';
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
import type { GraphEdge, GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment, GraphParticle } from '../ports/types';
import type {
GraphEdge,
GraphLayoutPort,
GraphNode,
GraphOwnerSlotAssignment,
GraphParticle,
} from '../ports/types';
import type { WorldBounds } from '../layout/launchAnchor';
import { createCompleteEffect, createSpawnEffect, type VisualEffect } from '../canvas/draw-effects';
@ -50,6 +57,15 @@ export interface UseGraphSimulationResult {
previewOwnerX: number;
previewOwnerY: number;
} | null;
resolveNearestOwnerGridTarget: (
nodeId: string,
x: number,
y: number
) => {
targetOwnerId: string;
previewOwnerX: number;
previewOwnerY: number;
} | null;
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
getActivityWorldRect: (nodeId: string) => StableRect | null;
getExtraWorldBounds: () => WorldBounds[];
@ -145,7 +161,12 @@ export function useGraphSimulation(): UseGraphSimulationResult {
layoutRef.current = layout;
preserveReusableNodePositions(nodes, state.nodes);
recordNodeLifecycleEffects(state.effects, nodes, prevNodeStatesRef.current, allKnownNodeIdsRef.current);
recordNodeLifecycleEffects(
state.effects,
nodes,
prevNodeStatesRef.current,
allKnownNodeIdsRef.current
);
prevNodeIdsRef.current = new Set(nodes.map((node) => node.id));
prevNodeStatesRef.current = new Map(nodes.map((node) => [node.id, node.state]));
@ -210,23 +231,33 @@ export function useGraphSimulation(): UseGraphSimulationResult {
applyCurrentLayout();
}, [applyCurrentLayout]);
const resolveNearestOwnerSlot = useCallback(
(nodeId: string, x: number, y: number) => {
const snapshot = layoutSnapshotRef.current;
if (!snapshot) {
return null;
}
return resolveNearestSlotAssignment({
ownerId: nodeId,
ownerX: x,
ownerY: y,
nodes: stateRef.current.nodes,
snapshot,
layout: layoutRef.current,
});
},
[]
);
const resolveNearestOwnerSlot = useCallback((nodeId: string, x: number, y: number) => {
const snapshot = layoutSnapshotRef.current;
if (!snapshot) {
return null;
}
return resolveNearestSlotAssignment({
ownerId: nodeId,
ownerX: x,
ownerY: y,
nodes: stateRef.current.nodes,
snapshot,
layout: layoutRef.current,
});
}, []);
const resolveNearestOwnerGridTarget = useCallback((nodeId: string, x: number, y: number) => {
const snapshot = layoutSnapshotRef.current;
if (!snapshot || layoutRef.current?.mode !== 'grid-under-lead') {
return null;
}
return resolveNearestGridOwnerTarget({
ownerId: nodeId,
ownerX: x,
ownerY: y,
snapshot,
});
}, []);
useEffect(() => {
return () => {
@ -248,6 +279,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
resolveNearestOwnerGridTarget,
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
@ -260,6 +292,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
resolveNearestOwnerGridTarget,
]
);
}
@ -381,17 +414,13 @@ function resetToFallbackLayout(args: {
KanbanLayoutEngine.layout(nodes);
}
function preserveReusableNodePositions(
nodes: GraphNode[],
previousNodes: GraphNode[]
): void {
function preserveReusableNodePositions(nodes: GraphNode[], previousNodes: GraphNode[]): void {
const previousPositionById = new Map(
previousNodes
.filter((node) => node.x != null && node.y != null)
.map((node) => [
node.id,
{ x: node.x!, y: node.y!, vx: node.vx ?? 0, vy: node.vy ?? 0 },
] as const)
.map(
(node) => [node.id, { x: node.x!, y: node.y!, vx: node.vx ?? 0, vy: node.vy ?? 0 }] as const
)
);
for (const node of nodes) {
@ -498,7 +527,10 @@ function positionProcessNodes(nodes: GraphNode[], frames: readonly SlotFrame[]):
}
}
function positionCrossTeamNodes(nodes: GraphNode[], fitBounds: StableSlotLayoutSnapshot['fitBounds']): void {
function positionCrossTeamNodes(
nodes: GraphNode[],
fitBounds: StableSlotLayoutSnapshot['fitBounds']
): void {
const crossTeamNodes = nodes.filter((node) => node.kind === 'crossteam');
if (crossTeamNodes.length === 0) {
return;
@ -515,8 +547,7 @@ function positionCrossTeamNodes(nodes: GraphNode[], fitBounds: StableSlotLayoutS
const endAngle = (150 * Math.PI) / 180;
crossTeamNodes.forEach((node, index) => {
const t =
crossTeamNodes.length === 1 ? 0.5 : index / Math.max(crossTeamNodes.length - 1, 1);
const t = crossTeamNodes.length === 1 ? 0.5 : index / Math.max(crossTeamNodes.length - 1, 1);
const angle = startAngle + (endAngle - startAngle) * t;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;

View file

@ -26,6 +26,7 @@ export type {
GraphActivityItem,
GraphOwnerSlotAssignment,
GraphLayoutPort,
GraphLayoutMode,
GraphLayoutVersion,
GraphNodeKind,
GraphNodeState,

View file

@ -1,7 +1,8 @@
export const STABLE_SLOT_GEOMETRY = {
slotVerticalGap: 24,
slotHorizontalGap: 32,
slotVerticalGap: 12,
slotHorizontalGap: 77.7,
ringGap: 140,
centralHorizontalGap: 77.7,
centralSafetyPadding: 48,
memberSlotInnerPadding: 16,
centralBlockGap: 56,
@ -9,7 +10,7 @@ export const STABLE_SLOT_GEOMETRY = {
unassignedGap: 72,
maxGeneratedRings: 12,
ownerCollisionPadding: 28,
ownerBandHeight: 96,
ownerBandHeight: 32,
ownerMinWidth: 200,
processBandHeight: 32,
processRailWidth: 220,

View file

@ -2,10 +2,7 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
import { ACTIVITY_LANE } from './activityLane';
import type { WorldBounds } from './launchAnchor';
import {
STABLE_SLOT_GEOMETRY,
STABLE_SLOT_SECTOR_VECTORS,
} from './stableSlotGeometry';
import { STABLE_SLOT_GEOMETRY, STABLE_SLOT_SECTOR_VECTORS } from './stableSlotGeometry';
export type StableSlotWidthBucket = 'S' | 'M' | 'L';
@ -81,6 +78,12 @@ interface NearestSlotAssignmentResult {
previewOwnerY: number;
}
interface NearestGridOwnerTargetResult {
targetOwnerId: string;
previewOwnerX: number;
previewOwnerY: number;
}
interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult {
distanceSquared: number;
}
@ -110,8 +113,7 @@ const SLOT_GEOMETRY = {
boardColumnGap: 24,
processRailMinWidth: STABLE_SLOT_GEOMETRY.processRailWidth,
kanbanBandHeight:
KANBAN_ZONE.headerHeight +
STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight,
KANBAN_ZONE.headerHeight + STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight,
centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding,
} as const;
@ -119,6 +121,10 @@ const PROCESS_RAIL_NODE_GAP = 42;
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
const GEOMETRY_EPSILON = 0.001;
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
@ -165,12 +171,8 @@ export function buildStableSlotLayoutSnapshot({
}
const leadCoreRect = createCenteredRect(0, 0, 200, 96);
const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id);
const leadSlotFrame = buildSlotFrameAtRadius(
leadFootprint,
{ ringIndex: 0, sectorIndex: 0 },
0
);
const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id, layout);
const leadSlotFrame = buildSlotFrameAtRadius(leadFootprint, { ringIndex: 0, sectorIndex: 0 }, 0);
const leadActivityRect = leadSlotFrame.activityColumnRect;
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
const leadCentralReservedBlock = buildLeadCentralReservedBlock({
@ -190,20 +192,15 @@ export function buildStableSlotLayoutSnapshot({
SLOT_GEOMETRY.centralPadding
);
const memberSlotFrames = planOwnerSlots(
ownerFootprints,
centralCollisionRects,
runtimeCentralExclusion,
layout
);
const memberSlotFrames =
(layout?.mode ?? 'radial') === 'grid-under-lead'
? planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects)
: planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout);
const memberSlotFrameByOwnerId = new Map(
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
);
const fitBounds = unionRects(
[
runtimeCentralExclusion,
...memberSlotFrames.map((frame) => frame.bounds),
].filter(Boolean)
[runtimeCentralExclusion, ...memberSlotFrames.map((frame) => frame.bounds)].filter(Boolean)
);
return {
@ -254,10 +251,7 @@ function buildLeadCentralReservedBlock(args: {
]);
}
function padCentralCollisionRects(
rects: readonly StableRect[],
padding: number
): StableRect[] {
function padCentralCollisionRects(rects: readonly StableRect[], padding: number): StableRect[] {
return rects.map((rect) => padRect(rect, padding));
}
@ -265,7 +259,9 @@ function rectOverlapsAnyCentralRect(
rect: StableRect,
centralCollisionRects: readonly StableRect[]
): boolean {
return centralCollisionRects.some((centralRect) => rectsOverlap(rect, centralRect));
return centralCollisionRects.some((centralRect) =>
rectsOverlapWithAxisGap(rect, centralRect, SLOT_GEOMETRY.centralHorizontalGap, 0)
);
}
export function computeOwnerFootprints(
@ -273,6 +269,7 @@ export function computeOwnerFootprints(
layout?: GraphLayoutPort
): OwnerFootprint[] {
const ownerNodes = nodes.filter((node) => node.kind === 'member');
const showActivity = layout?.showActivity ?? true;
const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const));
const taskColumnsByOwnerId = new Map<string, Set<string>>();
const processCountByOwnerId = new Map<string, number>();
@ -306,6 +303,7 @@ export function computeOwnerFootprints(
ownerId,
taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0,
processCount: processCountByOwnerId.get(ownerId) ?? 0,
showActivity,
}),
];
});
@ -313,7 +311,8 @@ export function computeOwnerFootprints(
function computeOwnerFootprintForOwnerId(
nodes: readonly GraphNode[],
ownerId: string
ownerId: string,
layout?: GraphLayoutPort
): OwnerFootprint {
const taskColumns = new Set<string>();
let processCount = 0;
@ -331,6 +330,7 @@ function computeOwnerFootprintForOwnerId(
ownerId,
taskColumnCount: taskColumns.size,
processCount,
showActivity: layout?.showActivity ?? true,
});
}
@ -338,25 +338,19 @@ function buildOwnerFootprint(args: {
ownerId: string;
taskColumnCount: number;
processCount: number;
showActivity: boolean;
}): OwnerFootprint {
const activityColumnWidth = args.showActivity ? SLOT_GEOMETRY.activityColumnWidth : 0;
const activityColumnHeight = args.showActivity ? SLOT_GEOMETRY.activityColumnHeight : 0;
const activityToKanbanGap = args.showActivity ? SLOT_GEOMETRY.boardColumnGap : 0;
const kanbanBandWidth =
args.taskColumnCount <= 1
? TASK_PILL.width
: TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
const processBandWidth = computeProcessBandWidth(args.processCount);
const boardBandWidth =
SLOT_GEOMETRY.activityColumnWidth +
SLOT_GEOMETRY.boardColumnGap +
kanbanBandWidth;
const boardBandHeight = Math.max(
SLOT_GEOMETRY.activityColumnHeight,
SLOT_GEOMETRY.kanbanBandHeight
);
const innerContentWidth = Math.max(
SLOT_GEOMETRY.ownerMinWidth,
processBandWidth,
boardBandWidth
);
const boardBandWidth = activityColumnWidth + activityToKanbanGap + kanbanBandWidth;
const boardBandHeight = Math.max(activityColumnHeight, SLOT_GEOMETRY.kanbanBandHeight);
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
const slotHeight =
SLOT_GEOMETRY.memberSlotInnerPadding * 2 +
@ -381,8 +375,8 @@ function buildOwnerFootprint(args: {
slotHeight,
widthBucket: classifyWidthBucket(slotWidth),
radialDepth,
activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth,
activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight,
activityColumnWidth,
activityColumnHeight,
processBandWidth,
kanbanBandWidth,
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
@ -408,8 +402,7 @@ export function computeProcessBandWidth(processCount: number): number {
return SLOT_GEOMETRY.processRailMinWidth;
}
const occupiedWidth =
(processCount - 1) * PROCESS_RAIL_NODE_GAP + PROCESS_RAIL_NODE_FOOTPRINT;
const occupiedWidth = (processCount - 1) * PROCESS_RAIL_NODE_GAP + PROCESS_RAIL_NODE_FOOTPRINT;
return Math.max(SLOT_GEOMETRY.processRailMinWidth, occupiedWidth);
}
@ -421,10 +414,12 @@ export function resolveNearestSlotAssignment(args: {
snapshot: StableSlotLayoutSnapshot;
layout?: GraphLayoutPort;
}): NearestSlotAssignmentResult | null {
if ((args.layout?.mode ?? 'radial') === 'grid-under-lead') {
return null;
}
const allFootprints = computeOwnerFootprints(args.nodes, args.layout);
const footprintByOwnerId = new Map(
allFootprints.map((item) => [item.ownerId, item] as const)
);
const footprintByOwnerId = new Map(allFootprints.map((item) => [item.ownerId, item] as const));
const footprint = footprintByOwnerId.get(args.ownerId);
if (!footprint) {
return null;
@ -446,7 +441,9 @@ export function resolveNearestSlotAssignment(args: {
return strictSmallTeamCandidate;
}
const existingFrames = args.snapshot.memberSlotFrames.filter((frame) => frame.ownerId !== args.ownerId);
const existingFrames = args.snapshot.memberSlotFrames.filter(
(frame) => frame.ownerId !== args.ownerId
);
const maxOccupiedRing = existingFrames.reduce((max, frame) => Math.max(max, frame.ringIndex), 0);
const candidateAssignments = buildCandidateAssignments(
Math.max(SLOT_GEOMETRY.maxGeneratedRings, maxOccupiedRing + allFootprints.length + 2)
@ -497,6 +494,41 @@ export function resolveNearestSlotAssignment(args: {
: null;
}
export function resolveNearestGridOwnerTarget(args: {
ownerId: string;
ownerX: number;
ownerY: number;
snapshot: StableSlotLayoutSnapshot;
}): NearestGridOwnerTargetResult | null {
if (!args.snapshot.memberSlotFrameByOwnerId.has(args.ownerId)) {
return null;
}
let best: {
frame: SlotFrame;
distanceSquared: number;
} | null = null;
for (const frame of args.snapshot.memberSlotFrames) {
const dx = frame.ownerX - args.ownerX;
const dy = frame.ownerY - args.ownerY;
const distanceSquared = dx * dx + dy * dy;
if (!best || distanceSquared < best.distanceSquared) {
best = { frame, distanceSquared };
}
}
if (!best) {
return null;
}
return {
targetOwnerId: best.frame.ownerId,
previewOwnerX: best.frame.ownerX,
previewOwnerY: best.frame.ownerY,
};
}
function resolveStrictSmallTeamNearestSlotAssignment(args: {
ownerId: string;
ownerX: number;
@ -509,12 +541,10 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
return null;
}
let best:
| {
frame: SlotFrame;
distanceSquared: number;
}
| null = null;
let best: {
frame: SlotFrame;
distanceSquared: number;
} | null = null;
for (const frame of strictFrames) {
const dx = frame.ownerX - args.ownerX;
const dy = frame.ownerY - args.ownerY;
@ -565,7 +595,9 @@ function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFr
}
const actualAssignmentKeys = frames
.map((frame) => buildAssignmentKey({ ringIndex: frame.ringIndex, sectorIndex: frame.sectorIndex }))
.map((frame) =>
buildAssignmentKey({ ringIndex: frame.ringIndex, sectorIndex: frame.sectorIndex })
)
.sort();
const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort();
@ -597,12 +629,7 @@ export function validateStableSlotLayout(
const seenOwnerIds = new Set<string>();
const seenAssignments = new Set<string>();
for (const frame of snapshot.memberSlotFrames) {
const frameValidation = validateMemberSlotFrame(
frame,
snapshot,
seenOwnerIds,
seenAssignments
);
const frameValidation = validateMemberSlotFrame(frame, snapshot, seenOwnerIds, seenAssignments);
if (frameValidation) {
return frameValidation;
}
@ -670,8 +697,13 @@ function validateLeadSnapshotRects(
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)) {
return { valid: false, reason: 'lead processBandRect must fit inside leadCentralReservedBlock' };
if (
!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)
) {
return {
valid: false,
reason: 'lead processBandRect must fit inside leadCentralReservedBlock',
};
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.kanbanBandRect)) {
return { valid: false, reason: 'lead kanbanBandRect must fit inside leadCentralReservedBlock' };
@ -689,7 +721,10 @@ function validateLeadSnapshotRects(
};
}
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
return {
valid: false,
reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock',
};
}
const paddedCentralCollisionRects = padCentralCollisionRects(
snapshot.centralCollisionRects,
@ -852,13 +887,10 @@ function buildUnassignedTaskRect(
leadCentralReservedBlock: StableRect
): StableRect | null {
const visibleOwnerIds = new Set(
nodes
.filter((node) => node.kind === 'lead' || node.kind === 'member')
.map((node) => node.id)
nodes.filter((node) => node.kind === 'lead' || node.kind === 'member').map((node) => node.id)
);
const unassignedTasks = nodes.filter(
(node) =>
node.kind === 'task' && (!node.ownerId || !visibleOwnerIds.has(node.ownerId))
(node) => node.kind === 'task' && (!node.ownerId || !visibleOwnerIds.has(node.ownerId))
);
if (unassignedTasks.length === 0) {
return null;
@ -920,6 +952,52 @@ function planOwnerSlots(
return placedFrames;
}
function planGridUnderLeadOwnerSlots(
ownerFootprints: readonly OwnerFootprint[],
centralCollisionRects: readonly StableRect[]
): SlotFrame[] {
const frames: SlotFrame[] = [];
const centralBlock = unionRects([...centralCollisionRects]);
let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP;
for (
let rowStartIndex = 0;
rowStartIndex < ownerFootprints.length;
rowStartIndex += GRID_UNDER_LEAD_COLUMN_COUNT
) {
const rowFootprints = ownerFootprints.slice(
rowStartIndex,
rowStartIndex + GRID_UNDER_LEAD_COLUMN_COUNT
);
const rowWidth =
rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) +
Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap;
const rowHeight = Math.max(...rowFootprints.map((footprint) => footprint.slotHeight));
const ownerY = rowTop + getOwnerAnchorTopOffset();
let nextLeft = -rowWidth / 2;
rowFootprints.forEach((footprint, columnIndex) => {
const ownerX = nextLeft + footprint.slotWidth / 2;
frames.push(
buildSlotFrameAtOwnerAnchor(
footprint,
{
ringIndex: Math.floor(rowStartIndex / GRID_UNDER_LEAD_COLUMN_COUNT),
sectorIndex: columnIndex,
},
ownerX,
ownerY
)
);
nextLeft += footprint.slotWidth + SLOT_GEOMETRY.slotHorizontalGap;
});
rowTop += rowHeight + GRID_UNDER_LEAD_ROW_GAP;
}
return frames;
}
function shouldUseStrictSmallTeamCardinalLayout(
ownerFootprints: readonly OwnerFootprint[],
layout?: GraphLayoutPort
@ -988,21 +1066,25 @@ function planStrictSmallTeamOwnerSlots(
return null;
}
let radius = Math.max(
...slotConfigs.map((slot) =>
resolveMinimumDirectionalRadiusForVector({
vector: slot!.vector,
footprint: slot!.footprint,
centralCollisionRects,
runtimeCentralExclusion,
})
)
const baseRadiusByAxis = resolveStrictSmallTeamRadiusByAxis(
slotConfigs.map((slot) => slot!),
centralCollisionRects,
runtimeCentralExclusion
);
for (let iteration = 0; iteration < 48; iteration += 1) {
const frames = slotConfigs.map((slot) =>
buildSlotFrameAtRadiusWithVector(slot!.footprint, slot!.assignment, radius, slot!.vector)
);
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,
@ -1013,12 +1095,45 @@ function planStrictSmallTeamOwnerSlots(
if (allValid) {
return frames;
}
radius += SMALL_TEAM_CARDINAL_RADIUS_STEP;
}
return null;
}
function resolveStrictSmallTeamRadiusByAxis(
slotConfigs: readonly {
footprint: OwnerFootprint;
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;
}
function resolveStrictSmallTeamVectorAxis(vector: {
x: number;
y: number;
}): 'horizontal' | 'vertical' {
return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical';
}
function buildPreferredAssignmentsMap(
assignments?: Record<string, GraphOwnerSlotAssignment>
): Map<string, GraphOwnerSlotAssignment> {
@ -1134,7 +1249,8 @@ function buildSlotFrameAtRadius(
assignment: GraphOwnerSlotAssignment,
radius: number
): SlotFrame {
const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0];
const vector =
SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0];
return buildSlotFrameAtRadiusWithVector(footprint, assignment, radius, vector);
}
@ -1146,8 +1262,16 @@ function buildSlotFrameAtRadiusWithVector(
): SlotFrame {
const ownerX = vector.x * radius;
const ownerY = vector.y * radius;
const slotTop =
ownerY - (SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2);
return buildSlotFrameAtOwnerAnchor(footprint, assignment, ownerX, ownerY);
}
function buildSlotFrameAtOwnerAnchor(
footprint: OwnerFootprint,
assignment: GraphOwnerSlotAssignment,
ownerX: number,
ownerY: number
): SlotFrame {
const slotTop = ownerY - getOwnerAnchorTopOffset();
const bounds = createRect(
ownerX - footprint.slotWidth / 2,
slotTop,
@ -1172,8 +1296,9 @@ function buildSlotFrameAtRadiusWithVector(
footprint.activityColumnWidth,
footprint.activityColumnHeight
);
const activityToKanbanGap = footprint.activityColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
const kanbanBandRect = createRect(
activityColumnRect.right + SLOT_GEOMETRY.boardColumnGap,
activityColumnRect.right + activityToKanbanGap,
boardBandRect.top,
footprint.kanbanBandWidth,
footprint.kanbanBandHeight
@ -1195,6 +1320,10 @@ function buildSlotFrameAtRadiusWithVector(
};
}
function getOwnerAnchorTopOffset(): number {
return SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2;
}
function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] {
const candidates: GraphOwnerSlotAssignment[] = [];
for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) {
@ -1235,10 +1364,7 @@ function computePlannerRingLimit(
(max, assignment) => Math.max(max, assignment.ringIndex),
0
);
return Math.max(
SLOT_GEOMETRY.maxGeneratedRings,
maxAssignedRing + ownerFootprints.length + 2
);
return Math.max(SLOT_GEOMETRY.maxGeneratedRings, maxAssignedRing + ownerFootprints.length + 2);
}
function ownerFootprintsSpillBudget(placedOwnerCount: number): number {
@ -1325,11 +1451,13 @@ function rankNearestSlotAssignmentResult(args: {
if (!displacedFrame) {
return null;
}
const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId);
const otherFrames = existingFrames.filter(
(existing) => existing.ownerId !== occupiedFrame.ownerId
);
if (
!isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) ||
!isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) ||
rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding)
ownerSlotFramesOverlap(frame.bounds, displacedFrame.bounds)
) {
return null;
}
@ -1655,7 +1783,8 @@ function resolveMinimumDirectionalRadius(args: {
runtimeCentralExclusion: StableRect;
}): number {
return resolveMinimumDirectionalRadiusForVector({
vector: SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0],
vector:
SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0],
footprint: args.footprint,
centralCollisionRects: args.centralCollisionRects,
runtimeCentralExclusion: args.runtimeCentralExclusion,
@ -1713,12 +1842,12 @@ function computeLegacyMinimumRingRadius(
footprint: OwnerFootprint,
centralExclusion: StableRect
): number {
const horizontalExtent =
vector.x >= 0 ? centralExclusion.right : Math.abs(centralExclusion.left);
const horizontalExtent = vector.x >= 0 ? centralExclusion.right : Math.abs(centralExclusion.left);
const verticalExtent = vector.y >= 0 ? centralExclusion.bottom : Math.abs(centralExclusion.top);
const requiredX =
Math.abs(vector.x) > 0.001
? (horizontalExtent + footprint.slotWidth / 2 + SLOT_GEOMETRY.ringPadding) / Math.abs(vector.x)
? (horizontalExtent + footprint.slotWidth / 2 + SLOT_GEOMETRY.ringPadding) /
Math.abs(vector.x)
: 0;
const requiredY =
Math.abs(vector.y) > 0.001
@ -1735,12 +1864,17 @@ function resolveTaskColumnKey(task: GraphNode): string {
return 'todo';
}
function rectsOverlapWithGap(a: StableRect, b: StableRect, gap: number): boolean {
function rectsOverlapWithAxisGap(
a: StableRect,
b: StableRect,
horizontalGap: number,
verticalGap: number
): boolean {
return (
a.left - gap < b.right &&
a.right + gap > b.left &&
a.top - gap < b.bottom &&
a.bottom + gap > b.top
a.left - horizontalGap < b.right &&
a.right + horizontalGap > b.left &&
a.top - verticalGap < b.bottom &&
a.bottom + verticalGap > b.top
);
}
@ -1748,6 +1882,10 @@ function rectsOverlap(a: StableRect, b: StableRect): boolean {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function ownerSlotFramesOverlap(a: StableRect, b: StableRect): boolean {
return rectsOverlapWithAxisGap(a, b, SLOT_GEOMETRY.slotHorizontalGap, SLOT_GEOMETRY.ringPadding);
}
function rectContainsRect(outer: StableRect, inner: StableRect): boolean {
return (
inner.left >= outer.left - GEOMETRY_EPSILON &&
@ -1788,9 +1926,7 @@ function isSlotFramePlacementValid(
if (rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)) {
return false;
}
return !existingFrames.some((existing) =>
rectsOverlapWithGap(frame.bounds, existing.bounds, SLOT_GEOMETRY.ringPadding)
);
return !existingFrames.some((existing) => ownerSlotFramesOverlap(frame.bounds, existing.bounds));
}
function buildAssignmentKey(assignment: GraphOwnerSlotAssignment): string {
@ -1801,10 +1937,7 @@ function isSameAssignment(
left: GraphOwnerSlotAssignment | undefined,
right: GraphOwnerSlotAssignment
): boolean {
return (
left?.ringIndex === right.ringIndex &&
left?.sectorIndex === right.sectorIndex
);
return left?.ringIndex === right.ringIndex && left?.sectorIndex === right.sectorIndex;
}
function createRect(left: number, top: number, width: number, height: number): StableRect {
@ -1818,12 +1951,22 @@ function createRect(left: number, top: number, width: number, height: number): S
};
}
function createCenteredRect(centerX: number, centerY: number, width: number, height: number): StableRect {
function createCenteredRect(
centerX: number,
centerY: number,
width: number,
height: number
): StableRect {
return createRect(centerX - width / 2, centerY - height / 2, width, height);
}
function padRect(rect: StableRect, padding: number): StableRect {
return createRect(rect.left - padding, rect.top - padding, rect.width + padding * 2, rect.height + padding * 2);
return createRect(
rect.left - padding,
rect.top - padding,
rect.width + padding * 2,
rect.height + padding * 2
);
}
function translateRect(rect: StableRect, dx: number, dy: number): StableRect {

View file

@ -13,5 +13,6 @@ export type {
GraphDomainRef,
GraphOwnerSlotAssignment,
GraphLayoutPort,
GraphLayoutMode,
GraphLayoutVersion,
} from './types';

View file

@ -55,6 +55,7 @@ export interface GraphActivityItem {
}
export type GraphLayoutVersion = 'stable-slots-v1';
export type GraphLayoutMode = 'radial' | 'grid-under-lead';
export interface GraphOwnerSlotAssignment {
ringIndex: number;
@ -63,6 +64,8 @@ export interface GraphOwnerSlotAssignment {
export interface GraphLayoutPort {
version: GraphLayoutVersion;
mode?: GraphLayoutMode;
showActivity?: boolean;
ownerOrder: string[];
slotAssignments: Record<string, GraphOwnerSlotAssignment>;
}

View file

@ -25,6 +25,7 @@ import {
ZoomIn,
ZoomOut,
} from 'lucide-react';
import type { GraphLayoutMode } from '../ports/types';
export interface GraphFilterState {
showActivity: boolean;
@ -50,6 +51,8 @@ export interface GraphControlsProps {
teamName: string;
teamColor?: string;
isAlive?: boolean;
layoutMode?: GraphLayoutMode;
onLayoutModeChange?: (mode: GraphLayoutMode) => void;
topToolbarContent?: React.ReactNode;
interactionLocked?: boolean;
}
@ -71,6 +74,8 @@ export function GraphControls({
onToggleSidebar,
isSidebarVisible = true,
teamColor,
layoutMode = 'radial',
onLayoutModeChange,
topToolbarContent,
interactionLocked = false,
}: GraphControlsProps): React.JSX.Element {
@ -80,8 +85,14 @@ export function GraphControls({
(key: keyof GraphFilterState) => {
onFiltersChange({ ...filters, [key]: !filters[key] });
},
[filters, onFiltersChange],
[filters, onFiltersChange]
);
const toggleLayoutMode = useCallback(() => {
if (!onLayoutModeChange) {
return;
}
onLayoutModeChange(layoutMode === 'radial' ? 'grid-under-lead' : 'radial');
}, [layoutMode, onLayoutModeChange]);
useEffect(() => {
if (!isSettingsOpen) return;
@ -174,9 +185,7 @@ export function GraphControls({
<div className="absolute left-1/2 top-0 w-[min(360px,38vw)] -translate-x-1/2 px-2">
{topToolbarContent ? (
<div className={`${chromeInteractivityClass} min-w-0`}>
{topToolbarContent}
</div>
<div className={`${chromeInteractivityClass} min-w-0`}>{topToolbarContent}</div>
) : null}
</div>
@ -190,12 +199,44 @@ export function GraphControls({
>
<ToolbarButton
onClick={() => toggle('paused')}
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
icon={
filters.paused ? (
<Play size={TOPBAR_ICON_SIZE} />
) : (
<Pause size={TOPBAR_ICON_SIZE} />
)
}
toolbar
title={filters.paused ? 'Resume animation' : 'Pause animation'}
/>
</div>
{onLayoutModeChange ? (
<div
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
}}
>
<ToolbarButton
onClick={toggleLayoutMode}
icon={
layoutMode === 'radial' ? (
<Columns3 size={TOPBAR_ICON_SIZE} />
) : (
<Users size={TOPBAR_ICON_SIZE} />
)
}
active={layoutMode === 'grid-under-lead'}
toolbar
title={
layoutMode === 'radial' ? 'Switch to rows layout' : 'Switch to radial layout'
}
/>
</div>
) : null}
<div ref={settingsRef} className={`relative ${chromeInteractivityClass}`}>
<div
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
@ -288,7 +329,7 @@ export function GraphControls({
</div>
</div>
<div className="absolute bottom-3 right-3 z-20 pointer-events-none">
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
<div
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-0.5 py-[2px] backdrop-blur-sm"
style={{
@ -349,7 +390,7 @@ function ToolbarButton({
}
: undefined
}
className={`flex items-center rounded-md font-mono transition-colors cursor-pointer ${
className={`flex cursor-pointer items-center rounded-md font-mono transition-colors ${
toolbar
? 'justify-center text-[0]'
: mini
@ -359,8 +400,8 @@ function ToolbarButton({
: 'gap-1 px-2 py-1 text-[11px]'
} ${
active
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]'
: 'text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)]'
? 'bg-[rgba(100,200,255,0.14)] text-[#aaeeff]'
: 'text-[#66ccff90] hover:bg-[rgba(100,200,255,0.1)] hover:text-[#aaeeff]'
}`}
>
{icon}
@ -379,7 +420,7 @@ function ToolbarButton({
<Tooltip.Content
side="bottom"
sideOffset={8}
className="z-[100] rounded-md border border-[rgba(100,200,255,0.14)] bg-[rgba(8,12,24,0.96)] px-2 py-1 text-[11px] font-mono text-[#dff6ff] shadow-xl backdrop-blur-sm"
className="z-[100] rounded-md border border-[rgba(100,200,255,0.14)] bg-[rgba(8,12,24,0.96)] px-2 py-1 font-mono text-[11px] text-[#dff6ff] shadow-xl backdrop-blur-sm"
>
{title}
<Tooltip.Arrow className="fill-[rgba(8,12,24,0.96)]" />
@ -405,12 +446,12 @@ function ToolbarToggle({
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-mono transition-all cursor-pointer border ${
className={`flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 font-mono text-[11px] transition-all ${
block ? 'w-full justify-start' : ''
} ${
active
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.15)] border-[rgba(100,200,255,0.25)]'
: 'text-[#66ccff50] bg-transparent border-transparent hover:text-[#66ccff90] hover:bg-[rgba(100,200,255,0.06)]'
? 'border-[rgba(100,200,255,0.25)] bg-[rgba(100,200,255,0.15)] text-[#aaeeff]'
: 'border-transparent bg-transparent text-[#66ccff50] hover:bg-[rgba(100,200,255,0.06)] hover:text-[#66ccff90]'
}`}
>
{icon}

View file

@ -15,7 +15,12 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d
import type { GraphDataPort } from '../ports/GraphDataPort';
import type { GraphEventPort } from '../ports/GraphEventPort';
import type { GraphConfigPort } from '../ports/GraphConfigPort';
import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
import type {
GraphEdge,
GraphLayoutMode,
GraphNode,
GraphOwnerSlotAssignment,
} from '../ports/types';
import type { StableRect } from '../layout/stableSlots';
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
import { GraphControls, type GraphFilterState } from './GraphControls';
@ -50,12 +55,14 @@ export interface GraphViewProps {
onToggleSidebar?: () => void;
isSidebarVisible?: boolean;
renderTopToolbarContent?: () => React.ReactNode;
onLayoutModeChange?: (mode: GraphLayoutMode) => void;
onOwnerSlotDrop?: (payload: {
nodeId: string;
assignment: GraphOwnerSlotAssignment;
displacedNodeId?: string;
displacedAssignment?: GraphOwnerSlotAssignment;
}) => void;
onOwnerGridOrderDrop?: (payload: { nodeId: string; targetNodeId: string }) => void;
/** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
renderOverlay?: (props: {
node: GraphNode;
@ -72,7 +79,7 @@ export interface GraphViewProps {
renderHud?: (props: {
filters: GraphFilterState;
getLaunchAnchorScreenPlacement: (
leadNodeId: string,
leadNodeId: string
) => { x: number; y: number; scale: number; visible: boolean } | null;
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
getTransientHandoffSnapshot: (options?: {
@ -103,7 +110,9 @@ export function GraphView({
onToggleSidebar,
isSidebarVisible = true,
renderTopToolbarContent,
onLayoutModeChange,
onOwnerSlotDrop,
onOwnerGridOrderDrop,
renderOverlay,
renderEdgeOverlay,
renderHud,
@ -120,6 +129,18 @@ export function GraphView({
paused: !(config?.animationEnabled ?? true),
});
const effectivePaused = filters.paused || suspendAnimation;
const layoutMode = data.layout?.mode ?? 'radial';
const canDragOwners = layoutMode === 'radial' || layoutMode === 'grid-under-lead';
const simulationLayout = useMemo(
() =>
data.layout
? {
...data.layout,
showActivity: filters.showActivity,
}
: data.layout,
[data.layout, filters.showActivity]
);
// Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change
const selectedNodeIdRef = useRef<string | null>(null);
@ -156,6 +177,12 @@ export function GraphView({
simulation.setNodePosition(nodeId, x, y);
},
[simulation]
),
useMemo(
() => ({
canDragNode: (node: GraphNode) => canDragOwners && node.kind === 'member',
}),
[canDragOwners]
)
);
@ -166,9 +193,9 @@ export function GraphView({
cameraRef.current = camera;
const interactionRef = useRef(interaction);
interactionRef.current = interaction;
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
null
);
const processActivePointerMoveRef = useRef<
((clientX: number, clientY: number) => boolean) | null
>(null);
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
null
);
@ -196,8 +223,8 @@ export function GraphView({
// ─── Sync data from adapter → simulation ────────────────────────────────
useEffect(() => {
simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout);
}, [data, simulation]);
simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, simulationLayout);
}, [data.edges, data.nodes, data.particles, data.teamName, simulation, simulationLayout]);
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
const focusState = useMemo(
@ -240,26 +267,29 @@ export function GraphView({
height: container?.clientHeight ?? 0,
};
}, []);
const getLaunchAnchorScreenPlacement = useCallback((leadNodeId: string) => {
const anchor = simulationRef.current.getLaunchAnchorWorldPosition(leadNodeId);
if (!anchor) {
return null;
}
const viewport = getViewportSize();
if (viewport.width <= 0 || viewport.height <= 0) {
return null;
}
const transform = cameraRef.current.transformRef.current;
return buildLaunchAnchorScreenPlacement({
anchorX: anchor.x,
anchorY: anchor.y,
cameraX: transform.x,
cameraY: transform.y,
zoom: transform.zoom,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
});
}, [getViewportSize]);
const getLaunchAnchorScreenPlacement = useCallback(
(leadNodeId: string) => {
const anchor = simulationRef.current.getLaunchAnchorWorldPosition(leadNodeId);
if (!anchor) {
return null;
}
const viewport = getViewportSize();
if (viewport.width <= 0 || viewport.height <= 0) {
return null;
}
const transform = cameraRef.current.transformRef.current;
return buildLaunchAnchorScreenPlacement({
anchorX: anchor.x,
anchorY: anchor.y,
cameraX: transform.x,
cameraY: transform.y,
zoom: transform.zoom,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
});
},
[getViewportSize]
);
const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
const getActivityWorldRect = useCallback(
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
@ -277,7 +307,9 @@ export function GraphView({
[]
);
const getNodeWorldPosition = useCallback((nodeId: string) => {
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
const node = simulationRef.current.stateRef.current.nodes.find(
(candidate) => candidate.id === nodeId
);
if (node?.x == null || node?.y == null) {
return null;
}
@ -548,11 +580,7 @@ export function GraphView({
}
const edgeMouseDown = edgeMouseDownRef.current;
if (
edgeMouseDown &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current
) {
if (edgeMouseDown && !interaction.dragNodeId.current && !interaction.isDragging.current) {
const dx = clientX - edgeMouseDown.clientX;
const dy = clientY - edgeMouseDown.clientY;
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
@ -585,16 +613,25 @@ export function GraphView({
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes));
interaction.handleMouseMove(
world.x,
world.y,
getVisibleNodes(simulation.stateRef.current.nodes)
);
const draggedNodeId = interaction.dragNodeId.current;
if (interaction.isDragging.current && draggedNodeId) {
if (typeof document !== 'undefined') {
document.getSelection()?.removeAllRanges();
}
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
const draggedNode = simulation.stateRef.current.nodes.find(
(node) => node.id === draggedNodeId
);
if (draggedNode?.kind === 'member') {
const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
const nearest =
layoutMode === 'grid-under-lead'
? simulation.resolveNearestOwnerGridTarget(draggedNodeId, world.x, world.y)
: simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
if (nearest) {
dragPreviewRef.current = {
nodeId: draggedNodeId,
@ -610,7 +647,7 @@ export function GraphView({
dragPreviewRef.current = null;
return true;
},
[camera, getVisibleNodes, interaction, simulation]
[camera, getVisibleNodes, interaction, layoutMode, simulation]
);
const completePointerInteraction = useCallback(
@ -633,8 +670,31 @@ export function GraphView({
const clickedId = interaction.handleMouseUp();
if (wasDragging && draggedNodeId) {
setInteractionGuards(false);
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
const draggedNode = simulation.stateRef.current.nodes.find(
(node) => node.id === draggedNodeId
);
if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) {
if (layoutMode === 'grid-under-lead') {
const nearest = simulation.resolveNearestOwnerGridTarget(
draggedNodeId,
draggedNode.x,
draggedNode.y
);
if (nearest) {
if (nearest.targetOwnerId !== draggedNodeId) {
onOwnerGridOrderDrop?.({
nodeId: draggedNodeId,
targetNodeId: nearest.targetOwnerId,
});
}
requestAnimationFrame(() => {
simulation.clearNodePosition(draggedNodeId);
});
dragPreviewRef.current = null;
edgeMouseDownRef.current = null;
return;
}
}
const nearest = simulation.resolveNearestOwnerSlot(
draggedNodeId,
draggedNode.x,
@ -700,7 +760,16 @@ export function GraphView({
}
dragPreviewRef.current = null;
},
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
[
camera,
events,
interaction,
layoutMode,
onOwnerGridOrderDrop,
onOwnerSlotDrop,
setInteractionGuards,
simulation,
]
);
processActivePointerMoveRef.current = processActivePointerMove;
completePointerInteractionRef.current = completePointerInteraction;
@ -965,7 +1034,7 @@ export function GraphView({
return (
<div
ref={containerRef}
className={`relative h-full w-full overflow-hidden select-none ${className ?? ''}`}
className={`relative h-full w-full select-none overflow-hidden ${className ?? ''}`}
>
<GraphCanvas
ref={canvasHandle}
@ -1011,6 +1080,8 @@ export function GraphView({
teamName={data.teamName}
teamColor={data.teamColor}
isAlive={data.isAlive}
layoutMode={layoutMode}
onLayoutModeChange={onLayoutModeChange}
topToolbarContent={renderTopToolbarContent?.()}
interactionLocked={interactionLocked}
/>

View file

@ -1,27 +1,27 @@
{
"version": "0.0.8",
"sourceRef": "v0.0.8",
"version": "0.0.9",
"sourceRef": "v0.0.9",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui",
"releaseTag": "v1.2.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.8.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.9.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.8.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.9.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.8.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.9.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.8.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.9.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -27,6 +27,11 @@ function shouldUseWindowsShell(cmd) {
return false;
}
const extension = path.extname(cmd).toLowerCase();
if (extension === '.cmd' || extension === '.bat') {
return true;
}
const commandName = path.basename(cmd).toLowerCase();
return WINDOWS_SHELL_COMMANDS.has(commandName);
}
@ -502,7 +507,8 @@ async function resolveRuntimeCli() {
runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot });
const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev');
const runtimeCliName = process.platform === 'win32' ? 'cli-dev.cmd' : 'cli-dev';
const runtimeCliPath = path.join(runtimeRepoRoot, runtimeCliName);
return {
binaryPath: runtimeCliPath,
versionText: readBinaryVersion(runtimeCliPath),

View file

@ -47,6 +47,7 @@ import {
import type {
GraphDataPort,
GraphEdge,
GraphLayoutMode,
GraphLayoutPort,
GraphNode,
GraphNodeState,
@ -109,7 +110,9 @@ export class TeamGraphAdapter {
commentReadState?: Record<string, unknown>,
provisioningProgress?: TeamProvisioningProgress | null,
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
layoutMode: GraphLayoutMode = 'radial',
gridOwnerOrder?: readonly string[]
): GraphDataPort {
if (teamData?.teamName !== teamName) {
return TeamGraphAdapter.#emptyResult(teamName);
@ -225,7 +228,13 @@ export class TeamGraphAdapter {
teamName,
teamColor: teamData.config.color ?? undefined,
isAlive: teamData.isAlive,
layout: TeamGraphAdapter.#buildLayoutPort(teamData, teamName, slotAssignments),
layout: TeamGraphAdapter.#buildLayoutPort(
teamData,
teamName,
slotAssignments,
layoutMode,
gridOwnerOrder
),
};
}
@ -258,7 +267,9 @@ export class TeamGraphAdapter {
static #buildLayoutPort(
data: TeamGraphData,
teamName: string,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
mode: GraphLayoutMode = 'radial',
gridOwnerOrder?: readonly string[]
): GraphLayoutPort {
const ownerOrder: string[] = [];
const seenOwnerNodeIds = new Set<string>();
@ -286,26 +297,44 @@ export class TeamGraphAdapter {
ownerOrder.push(nodeId);
};
for (const stableOwnerId of canonicalVisibleOwnerIds) {
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
if (!visibleMember) {
continue;
if (mode === 'grid-under-lead') {
const seenStableOwnerIds = new Set<string>();
for (const stableOwnerId of gridOwnerOrder ?? []) {
if (seenStableOwnerIds.has(stableOwnerId)) {
continue;
}
seenStableOwnerIds.add(stableOwnerId);
pushMember(visibleMemberByStableOwnerId.get(stableOwnerId));
}
if (!assignedStableOwnerIds.has(stableOwnerId)) {
continue;
}
pushMember(visibleMember);
}
for (const stableOwnerId of canonicalVisibleOwnerIds) {
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
if (!visibleMember) {
continue;
for (const stableOwnerId of canonicalVisibleOwnerIds) {
if (seenStableOwnerIds.has(stableOwnerId)) {
continue;
}
pushMember(visibleMemberByStableOwnerId.get(stableOwnerId));
}
if (assignedStableOwnerIds.has(stableOwnerId)) {
continue;
} else {
for (const stableOwnerId of canonicalVisibleOwnerIds) {
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
if (!visibleMember) {
continue;
}
if (!assignedStableOwnerIds.has(stableOwnerId)) {
continue;
}
pushMember(visibleMember);
}
for (const stableOwnerId of canonicalVisibleOwnerIds) {
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
if (!visibleMember) {
continue;
}
if (assignedStableOwnerIds.has(stableOwnerId)) {
continue;
}
pushMember(visibleMember);
}
pushMember(visibleMember);
}
const normalizedAssignments: Record<string, GraphOwnerSlotAssignment> = {};
@ -320,6 +349,7 @@ export class TeamGraphAdapter {
return {
version: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
mode,
ownerOrder,
slotAssignments: normalizedAssignments,
};

View file

@ -38,6 +38,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
toolHistory,
provisioningProgress,
memberSpawnSnapshot,
graphLayoutMode,
gridOwnerOrder,
slotAssignments,
graphLayoutSession,
ensureTeamGraphSlotAssignments,
@ -55,6 +57,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined,
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined,
graphLayoutMode: teamName ? s.graphLayoutModeByTeam[teamName] : undefined,
gridOwnerOrder: teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
@ -144,7 +148,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
commentReadState,
provisioningProgress,
memberSpawnSnapshot,
effectiveSlotAssignments
effectiveSlotAssignments,
graphLayoutMode ?? 'radial',
gridOwnerOrder
),
[
teamData,
@ -160,6 +166,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
provisioningProgress,
memberSpawnSnapshot,
effectiveSlotAssignments,
graphLayoutMode,
gridOwnerOrder,
]
);
}

View file

@ -1,18 +0,0 @@
import { useLayoutEffect } from 'react';
import { useStore } from '@renderer/store';
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
export function useTeamGraphSlotReset(teamName: string, enabled = true): void {
const resetTeamGraphSlotAssignmentsToDefaults = useStore(
(s) => s.resetTeamGraphSlotAssignmentsToDefaults
);
useLayoutEffect(() => {
if (!enabled || !isTeamGraphSlotPersistenceDisabled()) {
return;
}
resetTeamGraphSlotAssignmentsToDefaults(teamName);
}, [enabled, resetTeamGraphSlotAssignmentsToDefaults, teamName]);
}

View file

@ -5,7 +5,7 @@ import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamS
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
export function useTeamGraphSurfaceActions(teamName: string): {
openTeamPage: () => void;
@ -16,6 +16,8 @@ export function useTeamGraphSurfaceActions(teamName: string): {
displacedNodeId?: string;
displacedAssignment?: GraphOwnerSlotAssignment;
}) => void;
commitOwnerGridOrderDrop: (payload: { nodeId: string; targetNodeId: string }) => void;
setLayoutMode: (mode: GraphLayoutMode) => void;
} {
const openTeamPage = useCallback(() => {
useStore.getState().openTeamTab(teamName);
@ -43,6 +45,9 @@ export function useTeamGraphSurfaceActions(teamName: string): {
? parseGraphMemberNodeId(payload.displacedNodeId, teamName)
: null;
const store = useStore.getState();
if ((store.graphLayoutModeByTeam[teamName] ?? 'radial') !== 'radial') {
return;
}
if (displacedStableOwnerId && payload.displacedAssignment) {
store.commitTeamGraphOwnerSlotDrop(
teamName,
@ -58,9 +63,36 @@ export function useTeamGraphSurfaceActions(teamName: string): {
[teamName]
);
const commitOwnerGridOrderDrop = useCallback(
(payload: { nodeId: string; targetNodeId: string }) => {
const stableOwnerId = parseGraphMemberNodeId(payload.nodeId, teamName);
const targetStableOwnerId = parseGraphMemberNodeId(payload.targetNodeId, teamName);
if (!stableOwnerId || !targetStableOwnerId || stableOwnerId === targetStableOwnerId) {
return;
}
const store = useStore.getState();
if ((store.graphLayoutModeByTeam[teamName] ?? 'radial') !== 'grid-under-lead') {
return;
}
store.swapTeamGraphGridOwners(teamName, stableOwnerId, targetStableOwnerId);
},
[teamName]
);
const setLayoutMode = useCallback(
(mode: GraphLayoutMode) => {
useStore.getState().setTeamGraphLayoutMode(teamName, mode);
},
[teamName]
);
return {
openTeamPage,
resetOwnerSlotAssignmentsToDefaults,
commitOwnerSlotDrop,
commitOwnerGridOrderDrop,
setLayoutMode,
};
}

View file

@ -11,7 +11,6 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
import { GraphActivityHud } from './GraphActivityHud';
@ -54,15 +53,18 @@ export const TeamGraphOverlay = ({
onOpenMemberProfile,
}: TeamGraphOverlayProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
const {
openTeamPage: openTeamTab,
commitOwnerSlotDrop,
commitOwnerGridOrderDrop,
setLayoutMode,
} = useTeamGraphSurfaceActions(teamName);
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
useTeamGraphSlotReset(teamName);
// Task action dispatchers (same pattern as TeamGraphTab)
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>
@ -127,7 +129,9 @@ export const TeamGraphOverlay = ({
onToggleSidebar={handleToggleSidebar}
isSidebarVisible={effectiveSidebarVisible}
renderTopToolbarContent={() => <GraphProvisioningHud teamName={teamName} />}
onLayoutModeChange={setLayoutMode}
onOwnerSlotDrop={commitOwnerSlotDrop}
onOwnerGridOrderDrop={commitOwnerGridOrderDrop}
className="team-graph-view min-w-0 flex-1"
renderHud={(hudProps) => {
const extraHudProps = hudProps as typeof hudProps & {

View file

@ -11,7 +11,6 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
import { GraphActivityHud } from './GraphActivityHud';
@ -47,13 +46,12 @@ export const TeamGraphTab = ({
isPaneFocused = false,
}: TeamGraphTabProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } =
useTeamGraphSurfaceActions(teamName);
const [fullscreen, setFullscreen] = useState(false);
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
useTeamGraphSlotReset(teamName, isActive);
// Typed event dispatchers (DRY — used in both events + renderOverlay)
const dispatchOpenTask = useCallback(
(taskId: string) =>
@ -152,7 +150,9 @@ export const TeamGraphTab = ({
renderTopToolbarContent={() => (
<GraphProvisioningHud teamName={teamName} enabled={isActive} />
)}
onLayoutModeChange={setLayoutMode}
onOwnerSlotDrop={commitOwnerSlotDrop}
onOwnerGridOrderDrop={commitOwnerGridOrderDrop}
renderHud={(hudProps) => {
const extraHudProps = hudProps as typeof hudProps & {
getViewportSize?: () => { width: number; height: number };

View file

@ -18,8 +18,8 @@ import type { ServiceContext } from '@main/services';
const CODEX_THREAD_LIMIT = 20;
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
const CODEX_LIVE_FETCH_TIMEOUT_MS = 18_000;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 6_000;
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
CODEX_INITIALIZE_TIMEOUT_MS +
@ -61,6 +61,10 @@ function getFullFailureReason(result: CodexRecentThreadsResult): string | null {
return result.live.error;
}
if (result.archived.skipped) {
return result.live.error;
}
return `live: ${result.live.error}; archived: ${result.archived.error}`;
}
@ -227,6 +231,14 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
return;
}
if (result[segment].skipped) {
this.deps.logger.info('codex recent-projects thread list skipped', {
segment,
reason: error,
});
return;
}
if (segment === 'archived' && !result.live.error) {
this.deps.logger.info('codex recent-projects archived thread list degraded', {
error,

View file

@ -46,6 +46,7 @@ export interface CodexThreadSummary {
export interface CodexThreadSegmentResult {
threads: CodexThreadSummary[];
error?: string;
skipped?: boolean;
}
export interface CodexRecentThreadsResult {
@ -142,6 +143,17 @@ export class CodexAppServerClient {
limit: options.limit,
timeoutMs: liveRequestTimeoutMs,
});
if (live.error) {
return {
live,
archived: {
threads: [],
error: `Skipped archived thread/list after live thread/list failed: ${live.error}`,
skipped: true,
},
};
}
const archived = await this.#requestThreadListSegment(session, {
archived: true,
limit: options.limit,

View file

@ -0,0 +1,47 @@
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from './types';
export interface RuntimeProviderManagementApi {
loadView(
input: RuntimeProviderManagementLoadViewInput
): Promise<RuntimeProviderManagementViewResponse>;
loadProviderDirectory(
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse>;
loadSetupForm(
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse>;
connectProvider(
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse>;
connectWithApiKey(
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse>;
forgetCredential(
input: RuntimeProviderManagementForgetInput
): Promise<RuntimeProviderManagementProviderResponse>;
loadModels(
input: RuntimeProviderManagementLoadModelsInput
): Promise<RuntimeProviderManagementModelsResponse>;
testModel(
input: RuntimeProviderManagementTestModelInput
): Promise<RuntimeProviderManagementModelTestResponse>;
setDefaultModel(
input: RuntimeProviderManagementSetDefaultModelInput
): Promise<RuntimeProviderManagementViewResponse>;
}

View file

@ -0,0 +1,11 @@
export const RUNTIME_PROVIDER_MANAGEMENT_VIEW = 'runtimeProviderManagement:view';
export const RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY = 'runtimeProviderManagement:directory';
export const RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM = 'runtimeProviderManagement:setupForm';
export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT = 'runtimeProviderManagement:connect';
export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY =
'runtimeProviderManagement:connectApiKey';
export const RUNTIME_PROVIDER_MANAGEMENT_FORGET = 'runtimeProviderManagement:forget';
export const RUNTIME_PROVIDER_MANAGEMENT_MODELS = 'runtimeProviderManagement:models';
export const RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL = 'runtimeProviderManagement:testModel';
export const RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL =
'runtimeProviderManagement:setDefaultModel';

View file

@ -0,0 +1,3 @@
export type * from './api';
export * from './channels';
export type * from './types';

View file

@ -0,0 +1,336 @@
export type RuntimeProviderManagementRuntimeId = 'opencode';
export type RuntimeProviderStateDto = 'ready' | 'needs-auth' | 'needs-setup' | 'degraded';
export type RuntimeProviderManagedProfileStateDto = 'active' | 'missing' | 'stale';
export type RuntimeProviderLocalAuthStateDto = 'synced' | 'missing' | 'stale' | 'disabled';
export type RuntimeProviderConnectionStateDto =
| 'connected'
| 'available'
| 'not-connected'
| 'ignored'
| 'error';
export type RuntimeProviderOwnershipDto = 'managed' | 'local' | 'env' | 'project';
export type RuntimeProviderAuthMethodDto = 'api' | 'oauth' | 'wellknown';
export type RuntimeProviderSetupMethodDto = 'api' | 'oauth' | 'manual';
export type RuntimeProviderSetupPromptTypeDto = 'text' | 'select';
export interface RuntimeProviderSetupPromptOptionDto {
label: string;
value: string;
hint: string | null;
}
export interface RuntimeProviderSetupPromptConditionDto {
key: string;
op: string;
value: string;
}
export interface RuntimeProviderSetupPromptDto {
key: string;
type: RuntimeProviderSetupPromptTypeDto;
label: string;
placeholder: string | null;
required: boolean;
secret: boolean;
options: readonly RuntimeProviderSetupPromptOptionDto[];
when: RuntimeProviderSetupPromptConditionDto | null;
}
export type RuntimeProviderSetupFormSourceDto = 'opencode-auth' | 'curated' | 'oauth' | 'manual';
export interface RuntimeProviderSetupFormDto {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
displayName: string;
method: RuntimeProviderSetupMethodDto;
supported: boolean;
title: string;
description: string | null;
submitLabel: string;
disabledReason: string | null;
source: RuntimeProviderSetupFormSourceDto;
secret: {
key: 'key';
label: string;
placeholder: string | null;
required: boolean;
} | null;
prompts: readonly RuntimeProviderSetupPromptDto[];
}
export type RuntimeProviderActionIdDto =
| 'connect'
| 'use'
| 'test'
| 'set-default'
| 'forget'
| 'configure'
| 'unignore';
export type RuntimeProviderActionOwnershipScopeDto = RuntimeProviderOwnershipDto | 'runtime';
export interface RuntimeProviderActionDescriptorDto {
id: RuntimeProviderActionIdDto;
label: string;
enabled: boolean;
disabledReason: string | null;
requiresSecret: boolean;
ownershipScope: RuntimeProviderActionOwnershipScopeDto;
}
export interface RuntimeProviderManagementRuntimeDto {
state: RuntimeProviderStateDto;
cliPath: string | null;
version: string | null;
managedProfile: RuntimeProviderManagedProfileStateDto;
localAuth: RuntimeProviderLocalAuthStateDto;
}
export interface RuntimeProviderConnectionDto {
providerId: string;
displayName: string;
state: RuntimeProviderConnectionStateDto;
ownership: readonly RuntimeProviderOwnershipDto[];
recommended: boolean;
modelCount: number;
defaultModelId: string | null;
authMethods: readonly RuntimeProviderAuthMethodDto[];
actions: readonly RuntimeProviderActionDescriptorDto[];
detail: string | null;
}
export type RuntimeProviderDirectoryFilterDto =
| 'all'
| 'connected'
| 'configured'
| 'connectable'
| 'manual'
| 'has-models';
export type RuntimeProviderSetupKindDto =
| 'connected'
| 'connect-api-key'
| 'configure-manually'
| 'requires-environment'
| 'available-readonly'
| 'unsupported';
export type RuntimeProviderDirectorySourceDto =
| 'opencode-provider'
| 'config-provider'
| 'inventory'
| 'seed';
export interface RuntimeProviderDirectoryEntryDto {
providerId: string;
displayName: string;
state: RuntimeProviderConnectionStateDto;
setupKind: RuntimeProviderSetupKindDto;
ownership: readonly RuntimeProviderOwnershipDto[];
recommended: boolean;
modelCount: number | null;
authMethods: readonly RuntimeProviderAuthMethodDto[];
defaultModelId: string | null;
sources: readonly RuntimeProviderDirectorySourceDto[];
sourceLabel: string | null;
providerSource: string | null;
detail: string | null;
actions: readonly RuntimeProviderActionDescriptorDto[];
metadata: {
hasKnownModels: boolean;
requiresManualConfig: boolean;
supportedInlineAuth: boolean;
};
}
export interface RuntimeProviderDirectoryDto {
runtimeId: RuntimeProviderManagementRuntimeId;
totalCount: number;
returnedCount: number;
query: string | null;
filter: RuntimeProviderDirectoryFilterDto;
limit: number;
cursor: string | null;
nextCursor: string | null;
entries: readonly RuntimeProviderDirectoryEntryDto[];
diagnostics: readonly string[];
fetchedAt: string;
}
export interface RuntimeProviderManagementViewDto {
runtimeId: RuntimeProviderManagementRuntimeId;
title: string;
runtime: RuntimeProviderManagementRuntimeDto;
providers: readonly RuntimeProviderConnectionDto[];
defaultModel: string | null;
fallbackModel: string | null;
diagnostics: readonly string[];
}
export type RuntimeProviderManagementErrorCodeDto =
| 'unsupported-runtime'
| 'unsupported-action'
| 'runtime-missing'
| 'runtime-unhealthy'
| 'provider-missing'
| 'auth-required'
| 'auth-failed'
| 'model-missing'
| 'model-test-failed'
| 'unsupported-auth-method';
export interface RuntimeProviderManagementErrorDto {
code: RuntimeProviderManagementErrorCodeDto;
message: string;
recoverable: boolean;
}
export interface RuntimeProviderManagementViewResponse {
schemaVersion: 1;
runtimeId: RuntimeProviderManagementRuntimeId;
view?: RuntimeProviderManagementViewDto;
error?: RuntimeProviderManagementErrorDto;
}
export interface RuntimeProviderManagementDirectoryResponse {
schemaVersion: 1;
runtimeId: RuntimeProviderManagementRuntimeId;
directory?: RuntimeProviderDirectoryDto;
error?: RuntimeProviderManagementErrorDto;
}
export interface RuntimeProviderManagementProviderResponse {
schemaVersion: 1;
runtimeId: RuntimeProviderManagementRuntimeId;
provider?: RuntimeProviderConnectionDto;
error?: RuntimeProviderManagementErrorDto;
}
export interface RuntimeProviderManagementSetupFormResponse {
schemaVersion: 1;
runtimeId: RuntimeProviderManagementRuntimeId;
setupForm?: RuntimeProviderSetupFormDto;
error?: RuntimeProviderManagementErrorDto;
}
export type RuntimeProviderModelAvailabilityDto =
| 'available'
| 'unavailable'
| 'not-authenticated'
| 'unknown'
| 'untested';
export interface RuntimeProviderModelDto {
modelId: string;
providerId: string;
displayName: string;
sourceLabel: string;
free: boolean;
default: boolean;
availability: RuntimeProviderModelAvailabilityDto;
}
export interface RuntimeProviderManagementModelsDto {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
models: readonly RuntimeProviderModelDto[];
defaultModelId: string | null;
diagnostics: readonly string[];
}
export interface RuntimeProviderManagementModelsResponse {
schemaVersion: 1;
runtimeId: RuntimeProviderManagementRuntimeId;
models?: RuntimeProviderManagementModelsDto;
error?: RuntimeProviderManagementErrorDto;
}
export interface RuntimeProviderModelTestResultDto {
providerId: string;
modelId: string;
ok: boolean;
availability: RuntimeProviderModelAvailabilityDto;
message: string;
diagnostics: readonly string[];
}
export interface RuntimeProviderManagementModelTestResponse {
schemaVersion: 1;
runtimeId: RuntimeProviderManagementRuntimeId;
result?: RuntimeProviderModelTestResultDto;
error?: RuntimeProviderManagementErrorDto;
}
export interface RuntimeProviderManagementLoadViewInput {
runtimeId: RuntimeProviderManagementRuntimeId;
projectPath?: string | null;
}
export interface RuntimeProviderManagementLoadDirectoryInput {
runtimeId: RuntimeProviderManagementRuntimeId;
projectPath?: string | null;
query?: string | null;
filter?: RuntimeProviderDirectoryFilterDto | null;
limit?: number | null;
cursor?: string | null;
refresh?: boolean | null;
}
export interface RuntimeProviderManagementConnectApiKeyInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
apiKey: string;
projectPath?: string | null;
}
export interface RuntimeProviderManagementLoadSetupFormInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
projectPath?: string | null;
}
export interface RuntimeProviderManagementConnectInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
method: RuntimeProviderSetupMethodDto;
apiKey?: string | null;
metadata?: Record<string, string> | null;
projectPath?: string | null;
}
export interface RuntimeProviderManagementForgetInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
projectPath?: string | null;
}
export interface RuntimeProviderManagementLoadModelsInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
projectPath?: string | null;
query?: string | null;
limit?: number | null;
}
export interface RuntimeProviderManagementTestModelInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
modelId: string;
projectPath?: string | null;
}
export interface RuntimeProviderManagementSetDefaultModelInput {
runtimeId: RuntimeProviderManagementRuntimeId;
providerId: string;
modelId: string;
probe?: boolean;
projectPath?: string | null;
}

View file

@ -0,0 +1,3 @@
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
export type RuntimeProviderManagementPort = RuntimeProviderManagementApi;

View file

@ -0,0 +1,2 @@
export type * from './RuntimeProviderManagementPort';
export * from './runtimeProviderManagementUseCases';

View file

@ -0,0 +1,81 @@
import type { RuntimeProviderManagementPort } from './RuntimeProviderManagementPort';
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
export function loadRuntimeProviderManagementView(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementLoadViewInput
): Promise<RuntimeProviderManagementViewResponse> {
return port.loadView(input);
}
export function loadRuntimeProviderDirectory(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> {
return port.loadProviderDirectory(input);
}
export function loadRuntimeProviderSetupForm(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> {
return port.loadSetupForm(input);
}
export function connectRuntimeProvider(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> {
return port.connectProvider(input);
}
export function connectRuntimeProviderWithApiKey(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> {
return port.connectWithApiKey(input);
}
export function forgetRuntimeProviderCredential(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementForgetInput
): Promise<RuntimeProviderManagementProviderResponse> {
return port.forgetCredential(input);
}
export function loadRuntimeProviderModels(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementLoadModelsInput
): Promise<RuntimeProviderManagementModelsResponse> {
return port.loadModels(input);
}
export function testRuntimeProviderModel(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementTestModelInput
): Promise<RuntimeProviderManagementModelTestResponse> {
return port.testModel(input);
}
export function setRuntimeProviderDefaultModel(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementSetDefaultModelInput
): Promise<RuntimeProviderManagementViewResponse> {
return port.setDefaultModel(input);
}

View file

@ -0,0 +1 @@
export * from './providerManagementView';

View file

@ -0,0 +1,98 @@
import type {
RuntimeProviderActionDescriptorDto,
RuntimeProviderActionIdDto,
RuntimeProviderConnectionDto,
RuntimeProviderManagementRuntimeDto,
RuntimeProviderManagementViewDto,
} from '@features/runtime-provider-management/contracts';
const ACTION_ORDER: RuntimeProviderActionIdDto[] = [
'connect',
'use',
'test',
'set-default',
'forget',
'configure',
'unignore',
];
export function getProviderAction(
provider: RuntimeProviderConnectionDto,
actionId: RuntimeProviderActionIdDto
): RuntimeProviderActionDescriptorDto | null {
return provider.actions.find((action) => action.id === actionId) ?? null;
}
export function getPrimaryProviderAction(
provider: RuntimeProviderConnectionDto
): RuntimeProviderActionDescriptorDto | null {
for (const actionId of ACTION_ORDER) {
const action = getProviderAction(provider, actionId);
if (action) {
return action;
}
}
return provider.actions[0] ?? null;
}
export function canConnectWithApiKey(provider: RuntimeProviderConnectionDto): boolean {
const connect = getProviderAction(provider, 'connect');
return Boolean(
connect?.enabled && connect.requiresSecret && provider.authMethods.includes('api')
);
}
export function canForgetManagedCredential(provider: RuntimeProviderConnectionDto): boolean {
const forget = getProviderAction(provider, 'forget');
return Boolean(forget?.enabled);
}
export function selectInitialProviderId(
view: RuntimeProviderManagementViewDto | null
): string | null {
if (!view?.providers.length) {
return null;
}
return (
view.providers.find((provider) => provider.recommended && provider.state !== 'connected')
?.providerId ??
view.providers.find((provider) => provider.state === 'connected')?.providerId ??
view.providers[0]?.providerId ??
null
);
}
export function formatRuntimeState(runtime: RuntimeProviderManagementRuntimeDto): string {
switch (runtime.state) {
case 'ready':
return 'Ready';
case 'needs-auth':
return 'Needs auth';
case 'needs-setup':
return 'Needs setup';
case 'degraded':
return 'Degraded';
}
}
export function formatProviderState(provider: RuntimeProviderConnectionDto): string {
switch (provider.state) {
case 'connected':
return 'Connected';
case 'available':
return 'Available';
case 'not-connected':
return 'Not connected';
case 'ignored':
return 'Ignored';
case 'error':
return 'Error';
}
}
export function getProviderModelsLabel(provider: RuntimeProviderConnectionDto): string {
if (provider.modelCount <= 0) {
return 'Models not reported';
}
return `${provider.modelCount} model${provider.modelCount === 1 ? '' : 's'}`;
}

View file

@ -0,0 +1 @@
export type * from './contracts';

View file

@ -0,0 +1,264 @@
import {
RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL,
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
} from '@features/runtime-provider-management/contracts';
import { createLogger } from '@shared/utils/logger';
import type { RuntimeProviderManagementFeatureFacade } from '../../composition/createRuntimeProviderManagementFeature';
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
import type { IpcMain } from 'electron';
const logger = createLogger('Feature:RuntimeProviderManagement:IPC');
export function registerRuntimeProviderManagementIpc(
ipcMain: IpcMain,
feature: RuntimeProviderManagementFeatureFacade
): void {
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
async (
_event,
input: RuntimeProviderManagementLoadViewInput
): Promise<RuntimeProviderManagementViewResponse> => {
try {
return await feature.loadView(input);
} catch (error) {
logger.error('Failed to load runtime provider management view', error);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load providers',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
async (
_event,
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> => {
try {
return await feature.loadProviderDirectory(input);
} catch (error) {
logger.error('Failed to load runtime provider directory', error);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider directory',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
async (
_event,
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> => {
try {
return await feature.loadSetupForm(input);
} catch (error) {
logger.error('Failed to load runtime provider setup form', error);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider setup form',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
async (
_event,
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> => {
try {
return await feature.connectProvider(input);
} catch (error) {
logger.error(
'Failed to connect runtime provider',
error instanceof Error ? error.name : error
);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
async (
_event,
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> => {
try {
return await feature.connectWithApiKey(input);
} catch (error) {
logger.error(
'Failed to connect runtime provider',
error instanceof Error ? error.name : error
);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
async (
_event,
input: RuntimeProviderManagementForgetInput
): Promise<RuntimeProviderManagementProviderResponse> => {
try {
return await feature.forgetCredential(input);
} catch (error) {
logger.error('Failed to forget runtime provider credential', error);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: error instanceof Error ? error.message : 'Failed to forget provider',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
async (
_event,
input: RuntimeProviderManagementLoadModelsInput
): Promise<RuntimeProviderManagementModelsResponse> => {
try {
return await feature.loadModels(input);
} catch (error) {
logger.error('Failed to load runtime provider models', error);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider models',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL,
async (
_event,
input: RuntimeProviderManagementTestModelInput
): Promise<RuntimeProviderManagementModelTestResponse> => {
try {
return await feature.testModel(input);
} catch (error) {
logger.error('Failed to test runtime provider model', error);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to test model',
recoverable: true,
},
};
}
}
);
ipcMain.handle(
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
async (
_event,
input: RuntimeProviderManagementSetDefaultModelInput
): Promise<RuntimeProviderManagementViewResponse> => {
try {
return await feature.setDefaultModel(input);
} catch (error) {
logger.error('Failed to set runtime provider default model', error);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to set default model',
recoverable: true,
},
};
}
}
);
}
export function removeRuntimeProviderManagementIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_VIEW);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_FORGET);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_MODELS);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL);
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL);
}

View file

@ -0,0 +1,61 @@
import { AgentTeamsRuntimeProviderManagementCliClient } from '../infrastructure/AgentTeamsRuntimeProviderManagementCliClient';
import type { RuntimeProviderManagementPort } from '../../core/application';
import type {
RuntimeProviderManagementApi,
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
export type RuntimeProviderManagementFeatureFacade = RuntimeProviderManagementApi;
export function createRuntimeProviderManagementFeature(
deps: {
port?: RuntimeProviderManagementPort;
} = {}
): RuntimeProviderManagementFeatureFacade {
const port = deps.port ?? new AgentTeamsRuntimeProviderManagementCliClient();
return {
loadView: (
input: RuntimeProviderManagementLoadViewInput
): Promise<RuntimeProviderManagementViewResponse> => port.loadView(input),
loadProviderDirectory: (
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> => port.loadProviderDirectory(input),
loadSetupForm: (
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> => port.loadSetupForm(input),
connectProvider: (
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> => port.connectProvider(input),
connectWithApiKey: (
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> => port.connectWithApiKey(input),
forgetCredential: (
input: RuntimeProviderManagementForgetInput
): Promise<RuntimeProviderManagementProviderResponse> => port.forgetCredential(input),
loadModels: (
input: RuntimeProviderManagementLoadModelsInput
): Promise<RuntimeProviderManagementModelsResponse> => port.loadModels(input),
testModel: (
input: RuntimeProviderManagementTestModelInput
): Promise<RuntimeProviderManagementModelTestResponse> => port.testModel(input),
setDefaultModel: (
input: RuntimeProviderManagementSetDefaultModelInput
): Promise<RuntimeProviderManagementViewResponse> => port.setDefaultModel(input),
};
}

View file

@ -0,0 +1,8 @@
export {
registerRuntimeProviderManagementIpc,
removeRuntimeProviderManagementIpc,
} from './adapters/input/registerRuntimeProviderManagementIpc';
export {
createRuntimeProviderManagementFeature,
type RuntimeProviderManagementFeatureFacade,
} from './composition/createRuntimeProviderManagementFeature';

View file

@ -0,0 +1,662 @@
import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import type {
RuntimeProviderManagementApi,
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementErrorDto,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementRuntimeId,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
import type { ChildProcessWithoutNullStreams } from 'child_process';
const COMMAND_TIMEOUT_MS = 45_000;
const PROBE_COMMAND_TIMEOUT_MS = 90_000;
const COMMAND_ERROR_DETAIL_LIMIT = 1_600;
type RuntimeProviderManagementErrorResponse =
| RuntimeProviderManagementViewResponse
| RuntimeProviderManagementDirectoryResponse
| RuntimeProviderManagementProviderResponse
| RuntimeProviderManagementSetupFormResponse
| RuntimeProviderManagementModelsResponse
| RuntimeProviderManagementModelTestResponse;
function errorResponse<T extends RuntimeProviderManagementErrorResponse>(
runtimeId: RuntimeProviderManagementRuntimeId,
message: string,
code: RuntimeProviderManagementErrorDto['code'] = 'runtime-unhealthy'
): T {
return {
schemaVersion: 1,
runtimeId,
error: {
code,
message,
recoverable: true,
},
} as T;
}
function extractJsonObject<T>(raw: string): T {
const start = raw.indexOf('{');
const end = raw.lastIndexOf('}');
if (start < 0 || end < start) {
throw new Error('CLI did not return a JSON object');
}
return JSON.parse(raw.slice(start, end + 1)) as T;
}
function tryExtractJsonObject<T>(raw: string | null): T | null {
if (!raw) {
return null;
}
try {
return extractJsonObject<T>(raw);
} catch {
return null;
}
}
function readErrorTextProperty(error: unknown, propertyName: 'stderr' | 'stdout'): string | null {
if (!error || typeof error !== 'object' || !(propertyName in error)) {
return null;
}
const value = (error as Record<string, unknown>)[propertyName];
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
}
function extractJsonObjectFromError<T>(error: unknown): T | null {
return (
tryExtractJsonObject<T>(readErrorTextProperty(error, 'stdout')) ??
tryExtractJsonObject<T>(readErrorTextProperty(error, 'stderr'))
);
}
function truncateCommandErrorDetail(message: string): string {
if (message.length <= COMMAND_ERROR_DETAIL_LIMIT) {
return message;
}
return `${message.slice(0, COMMAND_ERROR_DETAIL_LIMIT).trimEnd()}...`;
}
function normalizeCommandFailure(error: unknown): string {
const stderr = readErrorTextProperty(error, 'stderr');
if (stderr) {
return truncateCommandErrorDetail(stderr);
}
const stdout = readErrorTextProperty(error, 'stdout');
if (stdout) {
return truncateCommandErrorDetail(stdout);
}
if (error instanceof Error && error.message.trim()) {
return truncateCommandErrorDetail(error.message);
}
return 'Runtime provider management command failed';
}
function normalizeProjectPath(projectPath: string | null | undefined): string | null {
const normalized = projectPath?.trim();
return normalized ? normalized : null;
}
function appendProjectPathArgs(args: string[], projectPath: string | null): string[] {
return projectPath ? [...args, '--project-path', projectPath] : args;
}
function appendOptionalArg(args: string[], name: string, value: string | null | undefined): void {
const normalized = value?.trim();
if (normalized) {
args.push(name, normalized);
}
}
function runtimeProviderCommandOptions<T extends { env: NodeJS.ProcessEnv }>(
options: T,
projectPath: string | null
): T & { cwd?: string } {
return projectPath ? { ...options, cwd: projectPath } : options;
}
async function resolveCliEnv(): Promise<{
binaryPath: string | null;
env: NodeJS.ProcessEnv;
}> {
const shellEnv = await resolveInteractiveShellEnv();
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
return {
binaryPath: null,
env: {
...process.env,
...shellEnv,
},
};
}
const providerAware = await buildProviderAwareCliEnv({
binaryPath,
providerId: 'opencode',
shellEnv,
connectionMode: 'augment',
});
return {
binaryPath,
env: providerAware.env,
};
}
function collectSpawnOutput(
child: ChildProcessWithoutNullStreams,
stdinValue: string
): Promise<{ stdout: string; stderr: string; code: number | null }> {
return new Promise((resolve, reject) => {
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let settled = false;
const timeout = setTimeout(() => {
if (settled) {
return;
}
settled = true;
killProcessTree(child, 'SIGKILL');
reject(new Error('Runtime provider management command timed out'));
}, COMMAND_TIMEOUT_MS);
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
child.once('error', (error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
reject(error);
});
child.once('close', (code) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve({
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
code,
});
});
child.stdin.write(stdinValue);
child.stdin.end();
});
}
export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProviderManagementApi {
async loadView(
input: RuntimeProviderManagementLoadViewInput
): Promise<RuntimeProviderManagementViewResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementViewResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const { stdout } = await execCli(
binaryPath,
appendProjectPathArgs(
['runtime', 'providers', 'view', '--runtime', input.runtimeId, '--json', '--compact'],
projectPath
),
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
);
return extractJsonObject<RuntimeProviderManagementViewResponse>(stdout);
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementViewResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementViewResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async loadProviderDirectory(
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementDirectoryResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
const args = ['runtime', 'providers', 'directory', '--runtime', input.runtimeId, '--json'];
appendOptionalArg(args, '--project-path', projectPath);
appendOptionalArg(args, '--query', input.query ?? null);
appendOptionalArg(args, '--filter', input.filter ?? null);
if (typeof input.limit === 'number' && Number.isFinite(input.limit) && input.limit > 0) {
args.push('--limit', String(Math.floor(input.limit)));
}
appendOptionalArg(args, '--cursor', input.cursor ?? null);
if (input.refresh) {
args.push('--refresh');
}
try {
const { stdout } = await execCli(
binaryPath,
args,
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
);
return extractJsonObject<RuntimeProviderManagementDirectoryResponse>(stdout);
} catch (error) {
const response =
extractJsonObjectFromError<RuntimeProviderManagementDirectoryResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementDirectoryResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async loadSetupForm(
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementSetupFormResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const { stdout } = await execCli(
binaryPath,
appendProjectPathArgs(
[
'runtime',
'providers',
'setup-form',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--json',
],
projectPath
),
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
);
return extractJsonObject<RuntimeProviderManagementSetupFormResponse>(stdout);
} catch (error) {
const response =
extractJsonObjectFromError<RuntimeProviderManagementSetupFormResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementSetupFormResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async connectProvider(
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const child = spawnCli(
binaryPath,
appendProjectPathArgs(
[
'runtime',
'providers',
'connect',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--stdin-json',
'--json',
],
projectPath
),
runtimeProviderCommandOptions(
{
env,
stdio: 'pipe' as const,
},
projectPath
)
) as ChildProcessWithoutNullStreams;
const result = await collectSpawnOutput(
child,
JSON.stringify({
method: input.method,
apiKey: input.apiKey ?? null,
metadata: input.metadata ?? {},
})
);
if (result.code === 0) {
return extractJsonObject<RuntimeProviderManagementProviderResponse>(result.stdout);
}
try {
return extractJsonObject<RuntimeProviderManagementProviderResponse>(result.stdout);
} catch {
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
`Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.`
);
}
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementProviderResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async connectWithApiKey(
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const child = spawnCli(
binaryPath,
appendProjectPathArgs(
[
'runtime',
'providers',
'connect-api-key',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--stdin-key',
'--json',
],
projectPath
),
runtimeProviderCommandOptions(
{
env,
stdio: 'pipe' as const,
},
projectPath
)
) as ChildProcessWithoutNullStreams;
const result = await collectSpawnOutput(child, input.apiKey);
if (result.code === 0) {
return extractJsonObject<RuntimeProviderManagementProviderResponse>(result.stdout);
}
try {
return extractJsonObject<RuntimeProviderManagementProviderResponse>(result.stdout);
} catch {
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
`Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.`
);
}
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementProviderResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async forgetCredential(
input: RuntimeProviderManagementForgetInput
): Promise<RuntimeProviderManagementProviderResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const { stdout } = await execCli(
binaryPath,
appendProjectPathArgs(
[
'runtime',
'providers',
'forget',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--json',
],
projectPath
),
runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath)
);
return extractJsonObject<RuntimeProviderManagementProviderResponse>(stdout);
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementProviderResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async loadModels(
input: RuntimeProviderManagementLoadModelsInput
): Promise<RuntimeProviderManagementModelsResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementModelsResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
let args = [
'runtime',
'providers',
'models',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--json',
];
if (input.query?.trim()) {
args.push('--query', input.query.trim());
}
if (typeof input.limit === 'number' && Number.isFinite(input.limit) && input.limit > 0) {
args.push('--limit', String(Math.floor(input.limit)));
}
args = appendProjectPathArgs(args, projectPath);
try {
const { stdout } = await execCli(binaryPath, args, {
...runtimeProviderCommandOptions({ env }, projectPath),
timeout: COMMAND_TIMEOUT_MS,
});
return extractJsonObject<RuntimeProviderManagementModelsResponse>(stdout);
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementModelsResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementModelsResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async testModel(
input: RuntimeProviderManagementTestModelInput
): Promise<RuntimeProviderManagementModelTestResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementModelTestResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const { stdout } = await execCli(
binaryPath,
appendProjectPathArgs(
[
'runtime',
'providers',
'test-model',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--model',
input.modelId,
'--json',
],
projectPath
),
runtimeProviderCommandOptions({ env, timeout: PROBE_COMMAND_TIMEOUT_MS }, projectPath)
);
return extractJsonObject<RuntimeProviderManagementModelTestResponse>(stdout);
} catch (error) {
const response =
extractJsonObjectFromError<RuntimeProviderManagementModelTestResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementModelTestResponse>(
input.runtimeId,
normalizeCommandFailure(error),
'model-test-failed'
);
}
}
async setDefaultModel(
input: RuntimeProviderManagementSetDefaultModelInput
): Promise<RuntimeProviderManagementViewResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementViewResponse>(
input.runtimeId,
'Multimodel runtime binary was not found.',
'runtime-missing'
);
}
const projectPath = normalizeProjectPath(input.projectPath);
try {
const { stdout } = await execCli(
binaryPath,
appendProjectPathArgs(
[
'runtime',
'providers',
'set-default',
'--runtime',
input.runtimeId,
'--provider',
input.providerId,
'--model',
input.modelId,
'--probe',
'--compact',
'--json',
],
projectPath
),
runtimeProviderCommandOptions({ env, timeout: PROBE_COMMAND_TIMEOUT_MS }, projectPath)
);
return extractJsonObject<RuntimeProviderManagementViewResponse>(stdout);
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementViewResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementViewResponse>(
input.runtimeId,
normalizeCommandFailure(error),
'model-test-failed'
);
}
}
}

View file

@ -0,0 +1,74 @@
import {
RUNTIME_PROVIDER_MANAGEMENT_CONNECT,
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY,
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM,
RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL,
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
type RuntimeProviderManagementApi,
} from '@features/runtime-provider-management/contracts';
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadSetupFormInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementModelsResponse,
RuntimeProviderManagementProviderResponse,
RuntimeProviderManagementSetDefaultModelInput,
RuntimeProviderManagementSetupFormResponse,
RuntimeProviderManagementTestModelInput,
RuntimeProviderManagementViewResponse,
} from '@features/runtime-provider-management/contracts';
import type { IpcRenderer } from 'electron';
export function createRuntimeProviderManagementBridge(
ipcRenderer: IpcRenderer
): RuntimeProviderManagementApi {
return {
loadView: (
input: RuntimeProviderManagementLoadViewInput
): Promise<RuntimeProviderManagementViewResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_VIEW, input),
loadProviderDirectory: (
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, input),
loadSetupForm: (
input: RuntimeProviderManagementLoadSetupFormInput
): Promise<RuntimeProviderManagementSetupFormResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_SETUP_FORM, input),
connectProvider: (
input: RuntimeProviderManagementConnectInput
): Promise<RuntimeProviderManagementProviderResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_CONNECT, input),
connectWithApiKey: (
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, input),
forgetCredential: (
input: RuntimeProviderManagementForgetInput
): Promise<RuntimeProviderManagementProviderResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_FORGET, input),
loadModels: (
input: RuntimeProviderManagementLoadModelsInput
): Promise<RuntimeProviderManagementModelsResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_MODELS, input),
testModel: (
input: RuntimeProviderManagementTestModelInput
): Promise<RuntimeProviderManagementModelTestResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL, input),
setDefaultModel: (
input: RuntimeProviderManagementSetDefaultModelInput
): Promise<RuntimeProviderManagementViewResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL, input),
};
}

View file

@ -0,0 +1 @@
export { createRuntimeProviderManagementBridge } from './createRuntimeProviderManagementBridge';

View file

@ -0,0 +1,37 @@
import { useRuntimeProviderManagement } from './hooks/useRuntimeProviderManagement';
import { RuntimeProviderManagementPanelView } from './ui/RuntimeProviderManagementPanelView';
import type { RuntimeProviderManagementRuntimeId } from '@features/runtime-provider-management/contracts';
import type { JSX } from 'react';
interface RuntimeProviderManagementPanelProps {
readonly runtimeId: RuntimeProviderManagementRuntimeId;
readonly open: boolean;
readonly projectPath?: string | null;
readonly disabled?: boolean;
readonly onProviderChanged?: () => Promise<void> | void;
}
export function RuntimeProviderManagementPanel({
runtimeId,
open,
projectPath = null,
disabled = false,
onProviderChanged,
}: RuntimeProviderManagementPanelProps): JSX.Element {
const [state, actions] = useRuntimeProviderManagement({
runtimeId,
enabled: open,
projectPath,
onProviderChanged,
});
return (
<RuntimeProviderManagementPanelView
state={state}
actions={actions}
disabled={disabled}
projectPath={projectPath}
/>
);
}

View file

@ -0,0 +1,15 @@
import {
getStoredCreateTeamModel,
setStoredCreateTeamModel,
setStoredCreateTeamProvider,
} from '@renderer/services/createTeamPreferences';
export function getOpenCodeModelForNewTeams(): string | null {
const modelId = getStoredCreateTeamModel('opencode').trim();
return modelId.length > 0 ? modelId : null;
}
export function saveOpenCodeModelForNewTeams(modelId: string): void {
setStoredCreateTeamProvider('opencode');
setStoredCreateTeamModel('opencode', modelId);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

View file

@ -0,0 +1 @@
export { RuntimeProviderManagementPanel } from './RuntimeProviderManagementPanel';

View file

@ -0,0 +1,677 @@
import { useEffect, useState } from 'react';
import type { CSSProperties, JSX } from 'react';
type ProviderBrand = {
providerId: string;
displayName: string;
};
interface SvgPath {
d: string;
fill?: string;
}
type BrandIconDescriptor =
| {
kind: 'svg';
viewBox: string;
paths: readonly SvgPath[];
background: string;
border: string;
color: string;
}
| {
kind: 'image';
src: string;
background: string;
border: string;
}
| {
kind: 'letters';
label: string;
background: string;
border: string;
color: string;
};
const OPENAI_PATH =
'M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z';
const GOOGLE_PATH =
'M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z';
const GOOGLE_CLOUD_PATH =
'M12.19 2.38a9.344 9.344 0 0 0-9.234 6.893c.053-.02-.055.013 0 0-3.875 2.551-3.922 8.11-.247 10.941l.006-.007-.007.03a6.717 6.717 0 0 0 4.077 1.356h5.173l.03.03h5.192c6.687.053 9.376-8.605 3.835-12.35a9.365 9.365 0 0 0-2.821-4.552l-.043.043.006-.05A9.344 9.344 0 0 0 12.19 2.38zm-.358 4.146c1.244-.04 2.518.368 3.486 1.15a5.186 5.186 0 0 1 1.862 4.078v.518c3.53-.07 3.53 5.262 0 5.193h-5.193l-.008.009v-.04H6.785a2.59 2.59 0 0 1-1.067-.23h.001a2.597 2.597 0 1 1 3.437-3.437l3.013-3.012A6.747 6.747 0 0 0 8.11 8.24c.018-.01.04-.026.054-.023a5.186 5.186 0 0 1 3.67-1.69z';
const GITHUB_PATH =
'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12';
const MISTRAL_PATH =
'M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z';
const MINIMAX_PATH =
'M11.43 3.92a.86.86 0 1 0-1.718 0v14.236a1.999 1.999 0 0 1-3.997 0V9.022a.86.86 0 1 0-1.718 0v3.87a1.999 1.999 0 0 1-3.997 0V11.49a.57.57 0 0 1 1.139 0v1.404a.86.86 0 0 0 1.719 0V9.022a1.999 1.999 0 0 1 3.997 0v9.134a.86.86 0 0 0 1.719 0V3.92a1.998 1.998 0 1 1 3.996 0v11.788a.57.57 0 1 1-1.139 0zm10.572 3.105a2 2 0 0 0-1.999 1.997v7.63a.86.86 0 0 1-1.718 0V3.923a1.999 1.999 0 0 0-3.997 0v16.16a.86.86 0 0 1-1.719 0V18.08a.57.57 0 1 0-1.138 0v2a1.998 1.998 0 0 0 3.996 0V3.92a.86.86 0 0 1 1.719 0v12.73a1.999 1.999 0 0 0 3.996 0V9.023a.86.86 0 1 1 1.72 0v6.686a.57.57 0 0 0 1.138 0V9.022a2 2 0 0 0-1.998-1.997';
const NVIDIA_PATH =
'M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z';
const OPENCODE_PATH =
'M8.40005 17.4H19.2001V21H4.80005V13.8H8.40005V17.4ZM15.6001 10.2V13.8H8.40005V10.2H15.6001ZM19.2001 10.2H15.6001V6.6H4.80005V3H19.2001V10.2Z';
const PERPLEXITY_PATH =
'M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z';
const VERCEL_PATH = 'm12 1.608 12 20.784H0Z';
// Brand marks are sourced from provider-owned assets where available, or from Simple Icons
// 16.17.0 entries whose source URLs point to the provider's official site/press kit.
// Providers without a verified compact mark use branded initials instead of approximate logos.
const BRAND_ICONS: Record<string, BrandIconDescriptor> = {
'github-models': {
kind: 'svg',
viewBox: '0 0 24 24',
background: '#f8fafc',
border: 'rgba(248, 250, 252, 0.5)',
color: '#181717',
paths: [{ d: GITHUB_PATH }],
},
'github-copilot': {
kind: 'svg',
viewBox: '0 0 24 24',
background: '#f8fafc',
border: 'rgba(248, 250, 252, 0.5)',
color: '#181717',
paths: [{ d: GITHUB_PATH }],
},
'google-cloud': {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(66, 133, 244, 0.14)',
border: 'rgba(66, 133, 244, 0.42)',
color: '#4285F4',
paths: [{ d: GOOGLE_CLOUD_PATH }],
},
'google-vertex': {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(66, 133, 244, 0.14)',
border: 'rgba(66, 133, 244, 0.42)',
color: '#4285F4',
paths: [{ d: GOOGLE_CLOUD_PATH }],
},
anthropic: {
kind: 'svg',
viewBox: '0 0 24 24',
background: '#CC9B7A',
border: 'rgba(204, 155, 122, 0.5)',
color: '#1F1F1E',
paths: [
{
d: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
},
],
},
'cloudflare-ai-gateway': {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(243, 128, 32, 0.14)',
border: 'rgba(243, 128, 32, 0.4)',
color: '#F38020',
paths: [
{
d: 'M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727',
},
],
},
'cloudflare-workers-ai': {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(243, 128, 32, 0.14)',
border: 'rgba(243, 128, 32, 0.4)',
color: '#F38020',
paths: [
{
d: 'm8.213.063 8.879 12.136-8.67 11.739h2.476l8.665-11.735-8.89-12.14Zm4.728 0 9.02 11.992-9.018 11.883h2.496L24 12.656v-1.199L15.434.063ZM7.178 2.02.01 11.398l-.01 1.2 7.203 9.644 1.238-1.676-6.396-8.556 6.361-8.313Z',
},
],
},
cloudflare: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(243, 128, 32, 0.14)',
border: 'rgba(243, 128, 32, 0.4)',
color: '#F38020',
paths: [
{
d: 'M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727',
},
],
},
github: {
kind: 'svg',
viewBox: '0 0 24 24',
background: '#f8fafc',
border: 'rgba(248, 250, 252, 0.5)',
color: '#181717',
paths: [{ d: GITHUB_PATH }],
},
'gitlab-duo': {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(252, 109, 38, 0.14)',
border: 'rgba(252, 109, 38, 0.42)',
color: '#FC6D26',
paths: [
{
d: 'm23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z',
},
],
},
gitlab: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(252, 109, 38, 0.14)',
border: 'rgba(252, 109, 38, 0.42)',
color: '#FC6D26',
paths: [
{
d: 'm23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z',
},
],
},
google: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(66, 133, 244, 0.13)',
border: 'rgba(66, 133, 244, 0.42)',
color: '#4285F4',
paths: [{ d: GOOGLE_PATH }],
},
'hugging-face': {
kind: 'letters',
label: 'HF',
background: 'rgba(255, 210, 30, 0.18)',
border: 'rgba(255, 210, 30, 0.42)',
color: '#FFD21E',
},
huggingface: {
kind: 'letters',
label: 'HF',
background: 'rgba(255, 210, 30, 0.18)',
border: 'rgba(255, 210, 30, 0.42)',
color: '#FFD21E',
},
minimax: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(231, 53, 98, 0.14)',
border: 'rgba(231, 53, 98, 0.42)',
color: '#E73562',
paths: [{ d: MINIMAX_PATH }],
},
mistral: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(250, 82, 15, 0.14)',
border: 'rgba(250, 82, 15, 0.42)',
color: '#FA520F',
paths: [{ d: MISTRAL_PATH }],
},
'mistral-ai': {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(250, 82, 15, 0.14)',
border: 'rgba(250, 82, 15, 0.42)',
color: '#FA520F',
paths: [{ d: MISTRAL_PATH }],
},
nvidia: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(118, 185, 0, 0.14)',
border: 'rgba(118, 185, 0, 0.42)',
color: '#76B900',
paths: [{ d: NVIDIA_PATH }],
},
opencode: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(148, 163, 184, 0.12)',
border: 'rgba(148, 163, 184, 0.32)',
color: '#94A3B8',
paths: [{ d: OPENCODE_PATH }],
},
openai: {
kind: 'svg',
viewBox: '0 0 256 260',
background: '#f8fafc',
border: 'rgba(248, 250, 252, 0.5)',
color: '#111827',
paths: [{ d: OPENAI_PATH }],
},
openrouter: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(148, 163, 184, 0.13)',
border: 'rgba(148, 163, 184, 0.38)',
color: '#94A3B8',
paths: [
{
d: 'M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z',
},
],
},
perplexity: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(31, 184, 205, 0.14)',
border: 'rgba(31, 184, 205, 0.42)',
color: '#1FB8CD',
paths: [{ d: PERPLEXITY_PATH }],
},
'perplexity-agent': {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(31, 184, 205, 0.14)',
border: 'rgba(31, 184, 205, 0.42)',
color: '#1FB8CD',
paths: [{ d: PERPLEXITY_PATH }],
},
poe: {
kind: 'svg',
viewBox: '0 0 24 24',
background: 'rgba(93, 92, 222, 0.16)',
border: 'rgba(93, 92, 222, 0.44)',
color: '#8f8df2',
paths: [
{
d: 'M24 12.513V8.36c0-.888-.717-1.608-1.603-1.615h-.013c-.498-.009-1.194-.123-1.688-.619-.44-.439-.584-1.172-.622-1.783l-.001.003c-.002-.014-.002-.03-.003-.044l-.001-.03a1.616 1.616 0 0 0-1.607-1.45H5.54a1.59 1.59 0 0 0-.164.008l-.055.009c-.034.004-.068.008-.102.015l-.069.017c-.028.008-.056.013-.083.022-.024.007-.045.015-.07.024-.026.01-.053.018-.08.03-.021.008-.042.02-.063.029-.027.013-.054.024-.08.038l-.059.034c-.025.015-.052.03-.077.047a.967.967 0 0 0-.061.045c-.021.015-.044.03-.065.05a1.21 1.21 0 0 0-.099.09c-.006.005-.013.01-.018.016l-.014.016a1.59 1.59 0 0 0-.094.102c-.017.02-.03.042-.046.062-.016.021-.033.042-.047.063l-.045.074-.037.062-.036.076a.682.682 0 0 0-.058.143l-.027.075-.02.074a.773.773 0 0 0-.018.078c-.006.03-.009.058-.013.088-.003.022-.008.045-.01.069-.003.022-.003.045-.004.068l-.002-.002c-.036.61-.182 1.345-.62 1.784-.496.495-1.191.61-1.69.618h-.012c-.05 0-.1.003-.147.007a1.27 1.27 0 0 0-.072.012c-.029.004-.057.007-.084.012l-.082.02-.072.018c-.026.009-.052.019-.079.027-.024.009-.048.016-.07.026-.024.01-.048.022-.072.034a.767.767 0 0 0-.072.033l-.068.04-.068.041a1.228 1.228 0 0 0-.072.054c-.018.014-.037.026-.053.04a1.627 1.627 0 0 0-.226.227c-.015.016-.027.036-.041.053a1.398 1.398 0 0 0-.054.074c-.016.022-.028.045-.041.067L.19 7.6c-.012.023-.022.047-.033.07l-.034.073c-.01.024-.017.046-.026.07-.01.027-.02.053-.027.08-.007.023-.012.047-.018.071l-.02.082-.012.084c-.003.024-.009.048-.01.072-.007.052-.01.106-.01.16v4.152c0 .888.717 1.609 1.603 1.616h.01c.5.008 1.196.123 1.69.618.43.43.577 1.143.618 1.746v4.13c0 .524.66.754.986.346l2.333-2.92h11.22c.861 0 1.563-.675 1.611-1.524l.001.003c.037-.61.183-1.344.622-1.783.495-.496 1.19-.61 1.689-.619h.012c.044 0 .088-.003.132-.007l.022-.001A1.613 1.613 0 0 0 24 12.513zm-3.85 1.69c-.502.503-1.215.613-1.717.619H5.566c-.501-.006-1.215-.114-1.717-.618-.408-.409-.565-1.117-.618-1.744V8.415c.052-.627.209-1.337.618-1.745.503-.503 1.216-.613 1.717-.619h12.867c.502.006 1.216.115 1.718.619.409.41.564 1.117.618 1.744v4.041c-.052.63-.209 1.339-.618 1.749zM8.424 7.99c-.892 0-1.615.723-1.615 1.615v1.616a1.615 1.615 0 1 0 3.23 0V9.604c0-.892-.723-1.615-1.615-1.615Zm7.154 0c-.893 0-1.616.723-1.616 1.615v1.616a1.615 1.615 0 1 0 3.231 0V9.604c0-.892-.723-1.615-1.615-1.615z',
},
],
},
vercel: {
kind: 'svg',
viewBox: '0 0 24 24',
background: '#f8fafc',
border: 'rgba(248, 250, 252, 0.5)',
color: '#000000',
paths: [{ d: VERCEL_PATH }],
},
'vercel-ai-gateway': {
kind: 'svg',
viewBox: '0 0 24 24',
background: '#f8fafc',
border: 'rgba(248, 250, 252, 0.5)',
color: '#000000',
paths: [{ d: VERCEL_PATH }],
},
};
const LETTER_BRANDS: Record<string, BrandIconDescriptor> = {
'amazon-bedrock': {
kind: 'letters',
label: 'AWS',
background: 'rgba(255, 153, 0, 0.14)',
border: 'rgba(255, 153, 0, 0.42)',
color: '#FF9900',
},
azure: {
kind: 'letters',
label: 'AZ',
background: 'rgba(0, 120, 212, 0.14)',
border: 'rgba(0, 120, 212, 0.42)',
color: '#60a5fa',
},
cohere: {
kind: 'letters',
label: 'CO',
background: 'rgba(57, 210, 192, 0.14)',
border: 'rgba(57, 210, 192, 0.42)',
color: '#39D2C0',
},
deepinfra: {
kind: 'letters',
label: 'DI',
background: 'rgba(125, 92, 255, 0.14)',
border: 'rgba(125, 92, 255, 0.42)',
color: '#a78bfa',
},
deepseek: {
kind: 'letters',
label: 'DS',
background: 'rgba(77, 132, 255, 0.14)',
border: 'rgba(77, 132, 255, 0.42)',
color: '#93c5fd',
},
'fireworks-ai': {
kind: 'letters',
label: 'FW',
background: 'rgba(255, 112, 67, 0.14)',
border: 'rgba(255, 112, 67, 0.42)',
color: '#fb923c',
},
groq: {
kind: 'letters',
label: 'G',
background: 'rgba(255, 93, 56, 0.14)',
border: 'rgba(255, 93, 56, 0.42)',
color: '#ff8a65',
},
'ollama-cloud': {
kind: 'letters',
label: 'OL',
background: 'rgba(248, 250, 252, 0.12)',
border: 'rgba(248, 250, 252, 0.36)',
color: '#f8fafc',
},
togetherai: {
kind: 'letters',
label: 'TA',
background: 'rgba(32, 201, 151, 0.14)',
border: 'rgba(32, 201, 151, 0.42)',
color: '#5eead4',
},
xai: {
kind: 'letters',
label: 'xAI',
background: 'rgba(248, 250, 252, 0.12)',
border: 'rgba(248, 250, 252, 0.36)',
color: '#f8fafc',
},
};
const BRAND_ALIASES: Record<string, string> = {
'amazon-bedrock': 'amazon-bedrock',
'aws-bedrock': 'amazon-bedrock',
'cloudflare-ai-gateway': 'cloudflare-ai-gateway',
'cloudflare-workers-ai': 'cloudflare-workers-ai',
'deep-infra': 'deepinfra',
fireworks: 'fireworks-ai',
'github-copilot': 'github-copilot',
'github-models': 'github-models',
'gitlab-duo': 'gitlab-duo',
'google-vertex': 'google-vertex',
'hugging-face': 'huggingface',
'mistral-ai': 'mistral',
'ollama-cloud': 'ollama-cloud',
'opencode-zen': 'opencode',
'perplexity-agent': 'perplexity',
'together-ai': 'togetherai',
'vercel-ai-gateway': 'vercel',
vertex: 'google-vertex',
};
// Verified against https://models.dev/logos/{provider}.svg by comparing each
// current provider logo to the Models.dev default fallback SVG.
const MODELS_DEV_LOGO_PROVIDER_IDS = new Set([
'302ai',
'abacus',
'aihubmix',
'alibaba',
'alibaba-cn',
'alibaba-coding-plan',
'alibaba-coding-plan-cn',
'amazon-bedrock',
'anthropic',
'azure',
'bailing',
'baseten',
'berget',
'cerebras',
'cloudferro-sherlock',
'cloudflare-ai-gateway',
'cloudflare-workers-ai',
'cohere',
'deepinfra',
'deepseek',
'digitalocean',
'dinference',
'drun',
'evroc',
'fastrouter',
'fireworks-ai',
'firmware',
'friendli',
'github-copilot',
'github-models',
'gitlab',
'google',
'google-vertex',
'groq',
'helicone',
'hpc-ai',
'huggingface',
'iflowcn',
'inception',
'inference',
'io-net',
'jiekou',
'kilo',
'kimi-for-coding',
'kuae-cloud-coding-plan',
'llama',
'llmgateway',
'lucidquery',
'meganova',
'minimax',
'minimax-cn',
'mistral',
'mixlayer',
'moark',
'modelscope',
'moonshotai',
'moonshotai-cn',
'nano-gpt',
'nebius',
'nova',
'novita-ai',
'nvidia',
'ollama-cloud',
'openai',
'opencode',
'opencode-go',
'openrouter',
'ovhcloud',
'perplexity',
'perplexity-agent',
'poe',
'privatemode-ai',
'qihang-ai',
'qiniu-ai',
'regolo-ai',
'scaleway',
'siliconflow',
'siliconflow-cn',
'stackit',
'submodel',
'tencent-coding-plan',
'tencent-tokenhub',
'the-grid-ai',
'togetherai',
'v0',
'venice',
'vercel',
'vivgrid',
'vultr',
'wafer.ai',
'xai',
'xiaomi',
'xiaomi-token-plan-ams',
'xiaomi-token-plan-cn',
'xiaomi-token-plan-sgp',
'zai',
'zai-coding-plan',
'zenmux',
'zhipuai',
'zhipuai-coding-plan',
]);
function normalizeProviderKey(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(?:^-)|(?:-$)/g, '');
}
function normalizeModelsDevProviderId(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/(?:^-)|(?:-$)/g, '');
}
function hasLocalGraphicIcon(key: string): boolean {
const descriptor = BRAND_ICONS[key];
return Boolean(descriptor && descriptor.kind !== 'letters');
}
function hasLetterIcon(key: string): boolean {
return Boolean(BRAND_ICONS[key]?.kind === 'letters' || LETTER_BRANDS[key]);
}
function getLocalBrandIconKey(provider: ProviderBrand): string | null {
const providerId = normalizeProviderKey(provider.providerId);
const displayName = normalizeProviderKey(provider.displayName);
const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId;
const aliasedDisplayName = BRAND_ALIASES[displayName] ?? displayName;
const direct = hasLocalGraphicIcon(aliasedProviderId)
? aliasedProviderId
: hasLocalGraphicIcon(aliasedDisplayName)
? aliasedDisplayName
: null;
if (direct) {
return direct;
}
for (const [needle, iconKey] of Object.entries(BRAND_ALIASES)) {
if (
(displayName.includes(needle) || providerId.includes(needle)) &&
hasLocalGraphicIcon(iconKey)
) {
return iconKey;
}
}
return null;
}
function getModelsDevLogoKey(provider: ProviderBrand): string | null {
const providerId = normalizeProviderKey(provider.providerId);
const displayName = normalizeProviderKey(provider.displayName);
const candidates = [
normalizeModelsDevProviderId(provider.providerId),
BRAND_ALIASES[providerId],
providerId,
normalizeModelsDevProviderId(provider.displayName),
BRAND_ALIASES[displayName],
displayName,
].filter((candidate): candidate is string => Boolean(candidate));
return candidates.find((candidate) => MODELS_DEV_LOGO_PROVIDER_IDS.has(candidate)) ?? null;
}
function getLetterBrandIconKey(provider: ProviderBrand): string | null {
const providerId = normalizeProviderKey(provider.providerId);
const displayName = normalizeProviderKey(provider.displayName);
const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId;
const aliasedDisplayName = BRAND_ALIASES[displayName] ?? displayName;
const direct = hasLetterIcon(aliasedProviderId)
? aliasedProviderId
: hasLetterIcon(aliasedDisplayName)
? aliasedDisplayName
: null;
if (direct) {
return direct;
}
for (const [needle, iconKey] of Object.entries(BRAND_ALIASES)) {
if ((displayName.includes(needle) || providerId.includes(needle)) && hasLetterIcon(iconKey)) {
return iconKey;
}
}
return null;
}
function fallbackDescriptor(provider: ProviderBrand): BrandIconDescriptor {
const displayName = provider.displayName.trim();
return {
kind: 'letters',
label: displayName.slice(0, 2).toUpperCase() || provider.providerId.slice(0, 2).toUpperCase(),
background: 'rgba(148, 163, 184, 0.12)',
border: 'rgba(148, 163, 184, 0.26)',
color: '#cbd5e1',
};
}
function descriptorFor(provider: ProviderBrand): BrandIconDescriptor {
const localKey = getLocalBrandIconKey(provider);
if (localKey) {
return BRAND_ICONS[localKey] ?? fallbackDescriptor(provider);
}
const modelsDevKey = getModelsDevLogoKey(provider);
if (modelsDevKey) {
return {
kind: 'image',
src: `https://models.dev/logos/${encodeURIComponent(modelsDevKey)}.svg`,
background: 'rgba(148, 163, 184, 0.12)',
border: 'rgba(148, 163, 184, 0.28)',
};
}
const letterKey = getLetterBrandIconKey(provider);
if (letterKey) {
return LETTER_BRANDS[letterKey] ?? fallbackDescriptor(provider);
}
return fallbackDescriptor(provider);
}
function shellStyle(descriptor: BrandIconDescriptor): CSSProperties {
const style: CSSProperties & Record<string, string | undefined> = {};
style['--runtime-provider-brand-fallback-background'] = descriptor.background;
style['--runtime-provider-brand-fallback-border'] = descriptor.border;
if (descriptor.kind !== 'image') {
style['--runtime-provider-brand-fallback-color'] = descriptor.color;
}
return style;
}
export function ProviderBrandIcon({ provider }: { readonly provider: ProviderBrand }): JSX.Element {
const descriptor = descriptorFor(provider);
const [imageFailed, setImageFailed] = useState(false);
const imageSrc = descriptor.kind === 'image' ? descriptor.src : null;
useEffect(() => {
setImageFailed(false);
}, [imageSrc]);
const renderedDescriptor =
descriptor.kind === 'image' && imageFailed ? fallbackDescriptor(provider) : descriptor;
return (
<span
data-testid={`runtime-provider-logo-${provider.providerId}`}
aria-hidden="true"
className="runtime-provider-brand-icon inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md border"
style={shellStyle(renderedDescriptor)}
>
{renderedDescriptor.kind === 'image' ? (
<img
src={renderedDescriptor.src}
alt=""
className="size-5 object-contain"
draggable={false}
onError={() => setImageFailed(true)}
/>
) : null}
{renderedDescriptor.kind === 'svg' ? (
<svg viewBox={renderedDescriptor.viewBox} className="h-[18px] w-[18px]" focusable="false">
{renderedDescriptor.paths.map((path) => (
<path key={path.d} d={path.d} fill={path.fill ?? 'currentColor'} />
))}
</svg>
) : null}
{renderedDescriptor.kind === 'letters' ? (
<span className="text-[10px] font-semibold leading-none">{renderedDescriptor.label}</span>
) : null}
</span>
);
}

View file

@ -34,6 +34,11 @@ interface WindowsOptionalFeatureProbe {
hasConfiguredWslFeature: boolean;
}
interface WslDistroGroups {
userDistros: string[];
serviceDistros: string[];
}
type ExecFileCallback = (
error: Error | null,
stdout: string | Buffer,
@ -64,6 +69,13 @@ const POWERSHELL_FEATURE_QUERY = [
'$features = Get-WindowsOptionalFeature -Online -FeatureName "Microsoft-Windows-Subsystem-Linux","VirtualMachinePlatform"',
'$features | Select-Object FeatureName, State, RestartRequired | ConvertTo-Json -Compress',
].join('; ');
const SERVICE_WSL_DISTRO_EXACT_NAMES = new Set([
'docker-desktop',
'docker-desktop-data',
'rancher-desktop',
'rancher-desktop-data',
]);
const SERVICE_WSL_DISTRO_PREFIXES = ['podman-machine-'];
export class TmuxWslService {
readonly #execFile: ExecFileLike;
@ -139,10 +151,35 @@ export class TmuxWslService {
};
}
const distroGroups = this.#groupWslDistros(distros);
if (distroGroups.userDistros.length === 0) {
if (persistedPreferredDistro) {
await this.#preferenceStore.clearPreferredDistro();
}
return {
preference: null,
status: {
wslInstalled: true,
rebootRequired,
distroName: null,
distroVersion: null,
distroBootstrapped: false,
innerPackageManager: null,
tmuxAvailableInsideWsl: false,
tmuxVersion: null,
tmuxBinaryPath: null,
statusDetail: this.#buildNoUserDistroDetail({
rebootRequired,
serviceDistros: distroGroups.serviceDistros,
}),
},
};
}
const verboseProbe = await this.#run(['--list', '--verbose'], 4_000);
const verboseEntries = this.#parseVerboseDistroEntries(verboseProbe.stdout, distros);
const preferredDistro = this.#resolvePreferredDistro({
distros,
userDistros: distroGroups.userDistros,
verboseEntries,
persistedPreferredDistro,
});
@ -158,7 +195,7 @@ export class TmuxWslService {
return {
preference: {
preferredDistroName: null,
source: usingPersistedPreference ? 'persisted' : null,
source: null,
},
status: {
wslInstalled: true,
@ -171,18 +208,19 @@ export class TmuxWslService {
tmuxVersion: null,
tmuxBinaryPath: null,
statusDetail:
distros.length > 1
? 'WSL has multiple Linux distributions, but no default or saved distro target is configured yet.'
distroGroups.userDistros.length > 1
? 'WSL has multiple user Linux distributions, but no default or saved distro target is configured yet.'
: 'WSL is available, but the app could not determine which Linux distribution to target.',
},
};
}
const preferredEntry = verboseEntries.find((entry) => entry.name === preferredDistro);
const preference: TmuxWslPreference = {
preferredDistroName: preferredDistro,
source: usingPersistedPreference
? 'persisted'
: verboseEntries.some((entry) => entry.isDefault)
: preferredEntry?.isDefault
? 'default'
: 'manual',
};
@ -452,25 +490,93 @@ export class TmuxWslService {
return entries;
}
#groupWslDistros(distros: string[]): WslDistroGroups {
const userDistros: string[] = [];
const serviceDistros: string[] = [];
for (const distro of distros) {
if (this.#isServiceWslDistro(distro)) {
serviceDistros.push(distro);
} else {
userDistros.push(distro);
}
}
return { userDistros, serviceDistros };
}
#isServiceWslDistro(distro: string): boolean {
const normalized = distro.trim().toLowerCase();
return (
SERVICE_WSL_DISTRO_EXACT_NAMES.has(normalized) ||
SERVICE_WSL_DISTRO_PREFIXES.some((prefix) => normalized.startsWith(prefix))
);
}
#resolvePreferredDistro(input: {
distros: string[];
userDistros: string[];
verboseEntries: WslVerboseDistroEntry[];
persistedPreferredDistro: string | null;
}): string | null {
if (input.persistedPreferredDistro && input.distros.includes(input.persistedPreferredDistro)) {
if (
input.persistedPreferredDistro &&
input.userDistros.includes(input.persistedPreferredDistro)
) {
return input.persistedPreferredDistro;
}
const defaultDistro = input.verboseEntries.find((entry) => entry.isDefault)?.name ?? null;
const defaultDistro =
input.verboseEntries.find(
(entry) => entry.isDefault && input.userDistros.includes(entry.name)
)?.name ?? null;
if (defaultDistro) {
return defaultDistro;
}
if (input.distros.length === 1) {
return input.distros[0] ?? null;
if (input.userDistros.length === 1) {
return input.userDistros[0] ?? null;
}
return null;
return this.#findRecommendedUserDistro(input.userDistros);
}
#findRecommendedUserDistro(userDistros: string[]): string | null {
const exactPriority = ['ubuntu', 'ubuntu-24.04', 'ubuntu-22.04', 'debian'];
for (const preferredName of exactPriority) {
const matched = userDistros.find((distro) => distro.toLowerCase() === preferredName);
if (matched) {
return matched;
}
}
return userDistros.find((distro) => this.#looksLikeVersionedUbuntuDistro(distro)) ?? null;
}
#looksLikeVersionedUbuntuDistro(distro: string): boolean {
const normalized = distro.toLowerCase();
if (!normalized.startsWith('ubuntu-')) {
return false;
}
const versionSuffix = normalized.slice('ubuntu-'.length);
return (
versionSuffix.length > 0 &&
[...versionSuffix].every((character) => {
return (character >= '0' && character <= '9') || character === '.';
})
);
}
#buildNoUserDistroDetail(input: { rebootRequired: boolean; serviceDistros: string[] }): string {
if (input.rebootRequired) {
return 'WSL was installed, but Windows still needs a restart before a Linux distro can be configured.';
}
if (input.serviceDistros.length > 0) {
return `WSL is available, but only service distributions are installed (${input.serviceDistros.join(', ')}). Install Ubuntu or another user Linux distro before setting up tmux.`;
}
return 'WSL is available, but no Linux distribution is installed yet.';
}
#looksLikeRestartRequired(output: string): boolean {

View file

@ -108,6 +108,90 @@ describe('TmuxWslService', () => {
expect(result.status.tmuxBinaryPath).toBe('/usr/bin/tmux');
});
it('does not target Docker Desktop when it is the only WSL distribution', async () => {
const service = new TmuxWslService(
createExecFileMock({
'--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' },
'--list --quiet': { stdout: 'docker-desktop\n' },
}),
createPreferenceStore() as never
);
const result = await service.probe();
expect(result.preference).toBeNull();
expect(result.status.wslInstalled).toBe(true);
expect(result.status.distroName).toBeNull();
expect(result.status.distroBootstrapped).toBe(false);
expect(result.status.innerPackageManager).toBeNull();
expect(result.status.tmuxAvailableInsideWsl).toBe(false);
expect(result.status.statusDetail).toContain('only service distributions');
expect(result.status.statusDetail).toContain('docker-desktop');
});
it('ignores a service default distro and targets the only user distro', async () => {
const service = new TmuxWslService(
createExecFileMock({
'--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' },
'--list --quiet': { stdout: 'docker-desktop\nUbuntu\n' },
'--list --verbose': {
stdout: '* docker-desktop Running 2\n Ubuntu Stopped 2\n',
},
'-d Ubuntu -- sh -lc printf ready': { stdout: 'ready' },
'-d Ubuntu -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': {
stdout: 'ubuntu',
},
'-d Ubuntu -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }':
{
stdout: '',
stderr: '',
error: Object.assign(new Error('tmux missing'), { code: 'EFAIL' }),
},
}),
createPreferenceStore() as never
);
const result = await service.probe();
expect(result.preference?.preferredDistroName).toBe('Ubuntu');
expect(result.preference?.source).toBe('manual');
expect(result.status.distroName).toBe('Ubuntu');
expect(result.status.innerPackageManager).toBe('apt');
expect(result.status.tmuxAvailableInsideWsl).toBe(false);
expect(result.status.statusDetail).toBe(
'tmux is not installed inside the Ubuntu WSL distro yet.'
);
});
it('uses a recommended Ubuntu target when a service distro is default among multiple user distros', async () => {
const service = new TmuxWslService(
createExecFileMock({
'--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' },
'--list --quiet': { stdout: 'docker-desktop\nDebian\nUbuntu-24.04\nFedora\n' },
'--list --verbose': {
stdout:
'* docker-desktop Running 2\n Debian Stopped 2\n Ubuntu-24.04 Stopped 2\n Fedora Stopped 2\n',
},
'-d Ubuntu-24.04 -- sh -lc printf ready': { stdout: 'ready' },
'-d Ubuntu-24.04 -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': {
stdout: 'ubuntu',
},
'-d Ubuntu-24.04 -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }':
{
stdout: 'tmux 3.4\n/usr/bin/tmux\n',
},
}),
createPreferenceStore() as never
);
const result = await service.probe();
expect(result.preference?.preferredDistroName).toBe('Ubuntu-24.04');
expect(result.preference?.source).toBe('manual');
expect(result.status.distroName).toBe('Ubuntu-24.04');
expect(result.status.tmuxAvailableInsideWsl).toBe(true);
});
it('prefers the persisted distro over the default WSL marker', async () => {
const service = new TmuxWslService(
createExecFileMock({
@ -133,6 +217,35 @@ describe('TmuxWslService', () => {
expect(result.status.distroName).toBe('Ubuntu');
});
it('prefers the persisted user distro over a service default distro', async () => {
const service = new TmuxWslService(
createExecFileMock({
'--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' },
'--list --quiet': { stdout: 'docker-desktop\nUbuntu\nDebian\n' },
'--list --verbose': {
stdout:
'* docker-desktop Running 2\n Ubuntu Stopped 2\n Debian Stopped 2\n',
},
'-d Debian -- sh -lc printf ready': { stdout: 'ready' },
'-d Debian -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': {
stdout: 'debian',
},
'-d Debian -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }':
{
stdout: 'tmux 3.4\n/usr/bin/tmux\n',
},
}),
createPreferenceStore('Debian') as never
);
const result = await service.probe();
expect(result.preference?.preferredDistroName).toBe('Debian');
expect(result.preference?.source).toBe('persisted');
expect(result.status.distroName).toBe('Debian');
expect(result.status.tmuxAvailableInsideWsl).toBe(true);
});
it('clears a stale preferred distro when WSL has no installed distributions', async () => {
const preferenceStore = createPreferenceStore('Ubuntu');
const service = new TmuxWslService(
@ -205,7 +318,7 @@ describe('TmuxWslService', () => {
encoding: 'buffer';
},
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void
) => {
): void => {
if (command === 'powershell.exe') {
callback(
null,

View file

@ -74,6 +74,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.manualHintsCollapsible).toBe(false);
expect(result.body).toContain('persistent teammate reliability');
expect(result.benefitsBody).toContain('Optional, but recommended');
expect(result.benefitsBody).toContain('multi-agent teams that mix providers');
expect(result.installButtonPrimary).toBe(true);
expect(result.showRefreshButton).toBe(true);
});

View file

@ -77,10 +77,11 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
const manualHintsVisible =
viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded);
const primaryGuideUrl = viewModel.primaryGuideUrl;
const bannerPaddingClass = expanded ? `py-3 ${BANNER_MIN_H}` : 'py-2.5';
return (
<div
className={`mb-6 rounded-lg border-l-4 px-3 ${expanded ? `py-3 ${BANNER_MIN_H}` : 'py-2.5'}`}
className={`mb-6 rounded-lg border-l-4 px-3 ${bannerPaddingClass}`}
style={{
borderLeftColor: viewModel.error ? '#ef4444' : '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
@ -93,19 +94,29 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
onClick={() => setExpanded((current) => !current)}
className="group flex w-full items-center justify-between gap-3 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-white/[0.03]"
>
<span className="flex min-w-0 items-center gap-2.5">
<span className="inline-flex shrink-0 items-center justify-center">
<span className="flex min-w-0 flex-1 items-start gap-2.5">
<span className="inline-flex shrink-0 items-center justify-center pt-[3px]">
{viewModel.error ? (
<AlertTriangle className="size-3.5 text-red-300" />
) : (
<Wrench className="size-3.5 text-amber-300" />
)}
</span>
<span
className="truncate text-xs font-medium leading-5"
style={{ color: 'var(--color-text)' }}
>
{SUMMARY_TITLE}
<span className="min-w-0">
<span
className="block truncate text-xs font-medium leading-5"
style={{ color: 'var(--color-text)' }}
>
{SUMMARY_TITLE}
</span>
{!expanded && viewModel.benefitsBody && (
<span
className="mt-0.5 block max-w-4xl text-[11px] leading-4"
style={{ color: 'var(--color-text-muted)' }}
>
{viewModel.benefitsBody}
</span>
)}
</span>
</span>
<span

View file

@ -21,7 +21,7 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
title: 'tmux is not installed',
body: 'WSL is available, but no Linux distribution is installed yet.',
benefitsBody:
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable.',
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable. Without tmux, creating multi-agent teams that mix providers may be blocked.',
error: null,
platformLabel: 'Windows',
locationLabel: null,
@ -94,6 +94,8 @@ describe('TmuxInstallerBannerView', () => {
const { host, root } = renderBanner(baseViewModel);
expect(host.textContent).toContain('tmux is not installed');
expect(host.textContent).toContain('Optional, but recommended');
expect(host.textContent).toContain('multi-agent teams that mix providers');
expect(host.textContent).not.toContain(
'WSL is available, but no Linux distribution is installed yet.'
);

View file

@ -67,9 +67,12 @@ export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): strin
return null;
}
const mixedProviderLimit =
'Without tmux, creating multi-agent teams that mix providers may be blocked.';
if (platform === 'win32') {
return 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.';
return `Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
}
return 'Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.';
return `Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
}

View file

@ -20,11 +20,20 @@ const logger = createLogger('HTTP:validation');
* Prevents path traversal attacks.
*/
function isPathContained(fullPath: string, basePath: string): boolean {
const normalizedFull = path.normalize(fullPath);
const normalizedBase = path.normalize(basePath);
const normalizedFull = normalizeForContainment(fullPath);
const normalizedBase = normalizeForContainment(basePath);
return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep);
}
function normalizeForContainment(value: string): string {
const resolved = path.resolve(value);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function resolveProjectPath(projectPath: string, requestedPath: string): string {
return path.isAbsolute(requestedPath) ? requestedPath : path.join(projectPath, requestedPath);
}
export function registerValidationRoutes(app: FastifyInstance): void {
// Validate path
app.post<{ Body: { relativePath: string; projectPath: string } }>(
@ -32,7 +41,7 @@ export function registerValidationRoutes(app: FastifyInstance): void {
async (request) => {
try {
const { relativePath, projectPath } = request.body;
const fullPath = path.join(projectPath, relativePath);
const fullPath = resolveProjectPath(projectPath, relativePath);
if (!isPathContained(fullPath, projectPath)) {
logger.warn('validate-path blocked path traversal attempt:', relativePath);
@ -57,7 +66,7 @@ export function registerValidationRoutes(app: FastifyInstance): void {
// Validate all mentions in parallel with async I/O
const entries = await Promise.all(
mentions.map(async (mention) => {
const fullPath = path.join(projectPath, mention.value);
const fullPath = resolveProjectPath(projectPath, mention.value);
if (!isPathContained(fullPath, projectPath)) {
return [`@${mention.value}`, false] as const;
}

View file

@ -36,6 +36,12 @@ import {
registerRecentProjectsIpc,
removeRecentProjectsIpc,
} from '@features/recent-projects/main';
import {
createRuntimeProviderManagementFeature,
registerRuntimeProviderManagementIpc,
removeRuntimeProviderManagementIpc,
type RuntimeProviderManagementFeatureFacade,
} from '@features/runtime-provider-management/main';
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
@ -550,6 +556,7 @@ let sshConnectionManager: SshConnectionManager;
let codexAccountFeature: CodexAccountFeatureFacade | null = null;
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
let recentProjectsFeature: RecentProjectsFeatureFacade;
let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService;
let cliInstallerService: CliInstallerService;
@ -1166,6 +1173,18 @@ async function initializeServices(): Promise<void> {
teamLogSourceTracker.onLogSourceChange((teamName) => {
teammateToolTracker?.handleLogSourceChange(teamName);
});
void teamDataService
.listTeams()
.then(async (teams) => {
await Promise.all(
teams.map((team) =>
teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(team.teamName)
)
);
})
.catch((error: unknown) =>
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
);
teamTaskStallMonitor.start();
// Allow SchedulerService to push schedule events to renderer
@ -1187,6 +1206,7 @@ async function initializeServices(): Promise<void> {
getLocalContext: () => contextRegistry.get('local'),
logger: createLogger('Feature:RecentProjects'),
});
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
codexAccountFeature = createCodexAccountFeature({
logger: createLogger('Feature:CodexAccount'),
configManager,
@ -1253,6 +1273,7 @@ async function initializeServices(): Promise<void> {
);
registerCodexAccountIpc(ipcMain, codexAccountFeature);
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
// Forward SSH state changes to renderer and HTTP SSE clients
sshConnectionManager.on('state-change', (status: unknown) => {
@ -1436,6 +1457,7 @@ async function shutdownServices(): Promise<void> {
removeIpcHandlers();
removeCodexAccountIpc(ipcMain);
removeRecentProjectsIpc(ipcMain);
removeRuntimeProviderManagementIpc(ipcMain);
});
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());

View file

@ -18,10 +18,11 @@
*/
import { syncTelemetryFlag } from '@main/sentry';
import { quoteWindowsCmdArg } from '@main/utils/childProcess';
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process';
import { execFile, execFileSync, spawn } from 'child_process';
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
@ -57,6 +58,72 @@ let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> |
null;
let onAgentLanguageUpdated: ((newLangCode: string) => Promise<void> | void) | null = null;
function isPathLikeCommand(command: string): boolean {
return /[\\/]/.test(command) || /^[A-Za-z]:/.test(command);
}
function resolveWindowsEditorCommand(editor: string): string {
if (process.platform !== 'win32' || isPathLikeCommand(editor)) {
return editor;
}
try {
const whereExe = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'where.exe');
const output = execFileSync(whereExe, [editor], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
return output.trim().split(/\r?\n/)[0] || editor;
} catch {
return editor;
}
}
function needsWindowsShell(command: string): boolean {
if (process.platform !== 'win32') return false;
const extension = path.extname(command).toLowerCase();
return extension === '.cmd' || extension === '.bat';
}
function launchExternalEditor(editor: string, configPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const resolvedEditor = resolveWindowsEditorCommand(editor);
const launchOptions = {
detached: true,
stdio: 'ignore' as const,
windowsHide: true,
};
let child: ReturnType<typeof spawn>;
if (needsWindowsShell(resolvedEditor)) {
const command = [resolvedEditor, configPath].map(quoteWindowsCmdArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- Windows .cmd launchers require cmd.exe; editor path is resolved via where.exe and args are cmd-escaped.
child = spawn(command, {
...launchOptions,
shell: true,
});
} else {
child = spawn(resolvedEditor, [configPath], launchOptions);
}
let settled = false;
function settle(fn: () => void): void {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
}
const timer = setTimeout(() => {
child.unref();
settle(() => resolve());
}, 500);
child.on('error', (err) => {
settle(() => reject(err));
});
});
}
/**
* Initializes config handlers with callbacks that require app-level services.
*/
@ -607,16 +674,7 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult
for (const editor of editors) {
try {
await new Promise<void>((resolve, reject) => {
const child = execFile(editor, [configPath], { timeout: 5000 });
// If the process spawns successfully, resolve after a short delay
// (editors typically fork and the parent exits quickly)
const timer = setTimeout(() => resolve(), 500);
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
await launchExternalEditor(editor, configPath);
return { success: true };
} catch {
// Editor not found, try next

View file

@ -2622,9 +2622,13 @@ async function handleSendMessage(
delivered: 0,
failed: 1,
lastDelivery: {
delivered: false,
reason: 'opencode_runtime_delivery_timeout',
diagnostics: ['opencode_runtime_delivery_timeout'],
delivered: true,
accepted: false,
responsePending: true,
acceptanceUnknown: true,
responseState: 'not_observed',
reason: 'opencode_runtime_delivery_ui_timeout_pending',
diagnostics: ['opencode_runtime_delivery_ui_timeout_pending'],
},
}
);
@ -2637,10 +2641,20 @@ async function handleSendMessage(
providerId: 'opencode',
attempted: true,
delivered: delivery.delivered,
responsePending: delivery.responsePending,
acceptanceUnknown: delivery.acceptanceUnknown,
responseState: delivery.responseState,
ledgerStatus: delivery.ledgerStatus,
visibleReplyMessageId: delivery.visibleReplyMessageId,
visibleReplyCorrelation: delivery.visibleReplyCorrelation,
reason: delivery.reason,
diagnostics: delivery.diagnostics,
};
if (!delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') {
if (
!delivery.delivered &&
delivery.reason !== 'recipient_is_not_opencode' &&
delivery.reason !== 'opencode_runtime_delivery_ui_timeout_pending'
) {
logger.warn(
`OpenCode runtime delivery after sendMessage failed for teammate "${memberName}": ${
delivery.reason ?? 'unknown error'

View file

@ -501,7 +501,7 @@ export class CliInstallerService {
},
{
providerId: 'opencode',
displayName: 'OpenCode',
displayName: 'OpenCode (75+ LLM providers)',
},
] as const
).map((provider) => ({
@ -768,18 +768,32 @@ export class CliInstallerService {
return null;
}
const providerStatus =
providerId === 'opencode'
? await this.multimodelBridgeService.verifyProviderStatus(binaryPath, providerId)
: await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
const nextProviderStatus =
providerId === 'opencode'
? await this.multimodelBridgeService.verifyOpenCodeModels(binaryPath, providerStatus)
: this.applyProviderModelAvailabilityToProvider(
binaryPath,
versionProbe.version,
providerStatus
);
if (providerId === 'opencode') {
const providerStatus = await this.multimodelBridgeService.verifyProviderStatus(
binaryPath,
providerId
);
const nextProviderStatus = {
...providerStatus,
modelVerificationState: 'idle' as const,
modelAvailability: [],
};
this.updateLatestProviderStatus(nextProviderStatus);
if (this.latestStatusSnapshot) {
this.publishStatusSnapshot(this.latestStatusSnapshot);
}
return nextProviderStatus;
}
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
binaryPath,
providerId
);
const nextProviderStatus = this.applyProviderModelAvailabilityToProvider(
binaryPath,
versionProbe.version,
providerStatus
);
this.updateLatestProviderStatus(nextProviderStatus);
if (this.latestStatusSnapshot) {
this.publishStatusSnapshot(this.latestStatusSnapshot);

View file

@ -40,13 +40,32 @@ function expandWindowsExtensions(candidate: string): string[] {
return [candidate];
}
return [candidate, ...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`)];
return [...pathext.map((ext) => `${candidate}${ext.toLowerCase()}`), candidate];
}
function isPathLikeCandidate(candidate: string): boolean {
if (process.platform === 'win32') {
return path.win32.isAbsolute(candidate) || candidate.includes('\\') || candidate.includes('/');
}
return path.isAbsolute(candidate) || candidate.includes(path.sep);
}
function getPathEntries(): string[] {
const delimiter = process.platform === 'win32' ? ';' : path.delimiter;
return (process.env.PATH ?? '').split(delimiter).filter(Boolean);
}
function resolvePathEntryCandidate(pathEntry: string, candidate: string): string {
if (process.platform === 'win32') {
return path.win32.join(pathEntry, candidate);
}
return path.join(pathEntry, candidate);
}
async function verifyBinary(candidate: string): Promise<string | null> {
const expandedCandidates = expandWindowsExtensions(candidate);
if (path.isAbsolute(candidate) || candidate.includes(path.sep)) {
if (isPathLikeCandidate(candidate)) {
for (const expandedCandidate of expandedCandidates) {
if (await fileExists(expandedCandidate)) {
return expandedCandidate;
@ -55,10 +74,10 @@ async function verifyBinary(candidate: string): Promise<string | null> {
return null;
}
const pathEntries = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
const pathEntries = getPathEntries();
for (const pathEntry of pathEntries) {
for (const expandedCandidate of expandedCandidates) {
const resolvedCandidate = path.join(pathEntry, expandedCandidate);
const resolvedCandidate = resolvePathEntryCandidate(pathEntry, expandedCandidate);
if (await fileExists(resolvedCandidate)) {
return resolvedCandidate;
}

View file

@ -0,0 +1,80 @@
// @vitest-environment node
import { constants as fsConstants } from 'node:fs';
import type { PathLike } from 'node:fs';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
vi.mock('node:fs/promises', () => ({
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
}));
const originalPlatform = process.platform;
const originalPath = process.env.PATH;
const originalPathExt = process.env.PATHEXT;
const originalCodexCliPath = process.env.CODEX_CLI_PATH;
function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
value,
configurable: true,
writable: true,
});
}
describe('CodexBinaryResolver', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
setPlatform('win32');
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
delete process.env.CODEX_CLI_PATH;
});
afterEach(() => {
setPlatform(originalPlatform);
process.env.PATH = originalPath;
process.env.PATHEXT = originalPathExt;
process.env.CODEX_CLI_PATH = originalCodexCliPath;
});
it('prefers the Windows command shim over the extensionless POSIX shim on PATH', async () => {
const binDir = 'C:\\Program Files\\nodejs';
const extensionless = path.win32.join(binDir, 'codex');
const cmdShim = path.win32.join(binDir, 'codex.cmd');
process.env.PATH = binDir;
accessMock.mockImplementation((filePath, mode) => {
expect(mode).toBe(fsConstants.X_OK);
if (filePath === extensionless || filePath === cmdShim) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
CodexBinaryResolver.clearCache();
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
});
it('expands an explicit extensionless override to the Windows command shim first', async () => {
const extensionless = 'C:\\Program Files\\nodejs\\codex';
const cmdShim = 'C:\\Program Files\\nodejs\\codex.cmd';
process.env.CODEX_CLI_PATH = extensionless;
accessMock.mockImplementation((filePath) => {
if (filePath === extensionless || filePath === cmdShim) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
CodexBinaryResolver.clearCache();
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
});
});

View file

@ -5,7 +5,6 @@ import {
createDefaultCliExtensionCapabilities,
createLegacyRuntimeFallbackCliExtensionCapabilities,
} from '@shared/utils/providerExtensionCapabilities';
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
import { mkdtemp, readFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
@ -14,18 +13,12 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
import { providerConnectionService } from './ProviderConnectionService';
import type {
CliProviderId,
CliProviderModelAvailability,
CliProviderReasoningEffort,
CliProviderStatus,
} from '@shared/types';
import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types';
const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
const OPENCODE_MODEL_VERIFY_TIMEOUT_MS = 60_000;
interface RuntimeExtensionCapabilityResponse {
status?: 'supported' | 'read-only' | 'unsupported';
@ -301,16 +294,6 @@ export interface OpenCodeRuntimeTranscriptLogMessage {
level?: string;
}
interface OpenCodeRuntimeVerifyModelResponse {
schemaVersion?: number;
providerId?: 'opencode';
result?: {
modelId?: string;
outcome?: 'available' | 'unavailable' | 'unknown';
reason?: string | null;
} | null;
}
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'];
function getProviderDisplayName(providerId: CliProviderId): string {
@ -322,7 +305,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode';
return 'OpenCode (75+ LLM providers)';
}
}
@ -371,18 +354,28 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
}
function mapRuntimeExtensionCapabilities(
providerId: CliProviderId,
capabilities?: RuntimeExtensionCapabilitiesResponse
): CliProviderStatus['capabilities']['extensions'] {
const defaults = capabilities
? createDefaultCliExtensionCapabilities()
: createLegacyRuntimeFallbackCliExtensionCapabilities();
const pluginStatus =
providerId === 'opencode'
? 'unsupported'
: (capabilities?.plugins?.status ?? defaults.plugins.status);
const pluginReason =
providerId === 'opencode'
? (capabilities?.plugins?.reason ??
'OpenCode does not support plugin management from Agent Teams.')
: (capabilities?.plugins?.reason ?? defaults.plugins.reason);
return {
plugins: {
...defaults.plugins,
status: capabilities?.plugins?.status ?? defaults.plugins.status,
status: pluginStatus,
ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership,
reason: capabilities?.plugins?.reason ?? defaults.plugins.reason,
reason: pluginReason,
},
mcp: {
...defaults.mcp,
@ -578,7 +571,10 @@ export class ClaudeMultimodelBridgeService {
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
extensions: mapRuntimeExtensionCapabilities(
providerId,
runtimeStatus.capabilities?.extensions
),
},
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
@ -868,72 +864,14 @@ export class ClaudeMultimodelBridgeService {
}
}
private async verifyOpenCodeModel(
binaryPath: string,
modelId: string
): Promise<CliProviderModelAvailability> {
const { env } = await this.buildCliEnv(binaryPath);
try {
const { stdout } = await execCli(
binaryPath,
['runtime', 'verify-model', '--json', '--provider', 'opencode', '--model', modelId],
{
timeout: OPENCODE_MODEL_VERIFY_TIMEOUT_MS,
env,
}
);
const parsed = extractJsonObject<OpenCodeRuntimeVerifyModelResponse>(stdout);
const outcome = parsed.providerId === 'opencode' ? parsed.result?.outcome : undefined;
const reason = parsed.providerId === 'opencode' ? (parsed.result?.reason ?? null) : null;
return {
modelId,
status:
outcome === 'available'
? 'available'
: outcome === 'unavailable'
? 'unavailable'
: 'unknown',
reason,
checkedAt: new Date().toISOString(),
};
} catch (error) {
return {
modelId,
status: 'unknown',
reason: error instanceof Error ? error.message : String(error),
checkedAt: new Date().toISOString(),
};
}
}
async verifyOpenCodeModels(
binaryPath: string,
_binaryPath: string,
provider: CliProviderStatus
): Promise<CliProviderStatus> {
const visibleModels = filterVisibleProviderRuntimeModels(provider.providerId, provider.models);
if (
provider.providerId !== 'opencode' ||
provider.supported !== true ||
provider.authenticated !== true ||
visibleModels.length === 0
) {
return {
...provider,
modelVerificationState: 'idle',
modelAvailability: [],
};
}
const modelAvailability: CliProviderModelAvailability[] = [];
for (const modelId of visibleModels) {
modelAvailability.push(await this.verifyOpenCodeModel(binaryPath, modelId));
}
return {
...provider,
modelVerificationState: 'verified',
modelAvailability,
modelVerificationState: 'idle',
modelAvailability: [],
};
}
@ -1063,7 +1001,10 @@ export class ClaudeMultimodelBridgeService {
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
extensions: mapRuntimeExtensionCapabilities(
providerId,
runtimeStatus.capabilities?.extensions
),
},
backend: runtimeStatus.backend?.kind
? {

View file

@ -71,6 +71,8 @@ const TEAM_ROOT_FILES = [
// Subdirs under ~/.claude/teams/{teamName}/
const TEAM_SUBDIRS = ['inboxes', 'review-decisions'];
const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime'];
const ATOMIC_WRITE_TEMP_FILE_PREFIX = '.tmp.';
const QUARANTINED_OPENCODE_LANE_INDEX_RE = /^lanes\.invalid\.\d+\.json$/;
// Subdirs under getAppDataPath() (our own storage, not in ~/.claude/)
const APP_DATA_SUBDIRS = ['attachments'];
const APP_DATA_DEEP_SUBDIRS = ['task-attachments'];
@ -105,6 +107,18 @@ function isValidConfig(content: string): boolean {
}
}
function shouldCollectRecursiveBackupFile(relPath: string): boolean {
const fileName = path.basename(relPath);
if (fileName.startsWith(ATOMIC_WRITE_TEMP_FILE_PREFIX)) {
return false;
}
// Runtime quarantine files are diagnostic snapshots of invalid JSON.
if (QUARANTINED_OPENCODE_LANE_INDEX_RE.test(fileName)) {
return false;
}
return true;
}
async function collectRecursiveFiles(
rootDir: string,
relPrefix: string
@ -120,6 +134,9 @@ async function collectRecursiveFiles(
continue;
}
if (entry.isFile()) {
if (!shouldCollectRecursiveBackupFile(relPath)) {
continue;
}
files.push({
sourcePath,
relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath,
@ -144,6 +161,9 @@ function collectRecursiveFilesSync(rootDir: string, relPrefix: string): BackupFi
continue;
}
if (entry.isFile()) {
if (!shouldCollectRecursiveBackupFile(relPath)) {
continue;
}
files.push({
sourcePath,
relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath,

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ export type OpenCodeBridgeCommandName =
| 'opencode.reconcileTeam'
| 'opencode.stopTeam'
| 'opencode.sendMessage'
| 'opencode.observeMessageDelivery'
| 'opencode.answerPermission'
| 'opencode.listRuntimePermissions'
| 'opencode.getRuntimeTranscript'
@ -156,15 +157,73 @@ export interface OpenCodeSendMessageCommandBody {
memberName: string;
text: string;
messageId?: string;
actionMode?: 'do' | 'ask' | 'delegate';
taskRefs?: { taskId: string; displayId: string; teamName: string }[];
agent?: string;
noReply?: boolean;
}
export type OpenCodeDeliveryResponseState =
| 'not_observed'
| 'pending'
| 'prompt_not_indexed'
| 'responded_tool_call'
| 'responded_visible_message'
| 'responded_non_visible_tool'
| 'responded_plain_text'
| 'permission_blocked'
| 'tool_error'
| 'empty_assistant_turn'
| 'session_stale'
| 'session_error'
| 'reconcile_failed';
export type OpenCodeDeliveryVisibleReplyCorrelation =
| 'relayOfMessageId'
| 'direct_child_message_send'
| 'plain_assistant_text';
export interface OpenCodeDeliveryResponseObservation {
state: OpenCodeDeliveryResponseState;
deliveredUserMessageId: string | null;
assistantMessageId: string | null;
toolCallNames: string[];
visibleMessageToolCallId: string | null;
visibleReplyMessageId: string | null;
visibleReplyCorrelation: OpenCodeDeliveryVisibleReplyCorrelation | null;
visibleReplyMissingRelayOfMessageId?: boolean;
latestAssistantPreview: string | null;
needsFullHistory?: boolean;
reason: string | null;
}
export interface OpenCodeSendMessageCommandData {
accepted: boolean;
sessionId?: string;
memberName: string;
runtimePid?: number;
prePromptCursor?: string | null;
responseObservation?: OpenCodeDeliveryResponseObservation;
diagnostics: OpenCodeTeamBridgeDiagnostic[];
}
export interface OpenCodeObserveMessageDeliveryCommandBody {
runId?: string;
laneId: string;
teamId: string;
teamName: string;
projectPath: string;
memberName: string;
messageId: string;
prePromptCursor?: string | null;
}
export interface OpenCodeObserveMessageDeliveryCommandData {
observed: boolean;
sessionId?: string;
memberName: string;
runtimePid?: number;
responseObservation: OpenCodeDeliveryResponseObservation;
diagnostics: OpenCodeTeamBridgeDiagnostic[];
}
@ -310,6 +369,7 @@ const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = new Set([
'opencode.reconcileTeam',
'opencode.stopTeam',
'opencode.sendMessage',
'opencode.observeMessageDelivery',
'opencode.answerPermission',
'opencode.listRuntimePermissions',
'opencode.getRuntimeTranscript',

View file

@ -13,6 +13,8 @@ import type {
OpenCodeCleanupHostsCommandData,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody,
OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData,
@ -40,6 +42,7 @@ export interface OpenCodeReadinessBridgeOptions {
launchTimeoutMs?: number;
reconcileTimeoutMs?: number;
sendTimeoutMs?: number;
observeTimeoutMs?: number;
stopTimeoutMs?: number;
cleanupTimeoutMs?: number;
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
@ -55,6 +58,7 @@ const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
const DEFAULT_SEND_TIMEOUT_MS = 30_000;
const DEFAULT_OBSERVE_TIMEOUT_MS = 8_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
@ -228,6 +232,48 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
};
}
async observeOpenCodeTeamMessageDelivery(
input: OpenCodeObserveMessageDeliveryCommandBody
): Promise<OpenCodeObserveMessageDeliveryCommandData> {
const result = await this.bridge.execute<
OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData
>('opencode.observeMessageDelivery', input, {
cwd: input.projectPath,
timeoutMs: this.options.observeTimeoutMs ?? DEFAULT_OBSERVE_TIMEOUT_MS,
});
if (result.ok) {
return result.data;
}
return {
observed: false,
memberName: input.memberName,
responseObservation: {
state: 'reconcile_failed',
deliveredUserMessageId: null,
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: result.error.message,
},
diagnostics: [
{
code: result.error.kind,
severity: 'error',
message: `OpenCode message delivery observe bridge failed: ${result.error.message}`,
},
...result.diagnostics.map((event) => ({
code: event.type,
severity: event.severity,
message: event.message,
})),
],
};
}
private async executeStateChangingCommand<TBody, TData>(
command: OpenCodeStateChangingTeamCommandName,
body: TBody,

View file

@ -0,0 +1,830 @@
import { stableHash } from '../bridge/OpenCodeBridgeCommandContract';
import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore';
import type { AgentActionMode, TaskRef } from '@shared/types/team';
import type {
OpenCodeDeliveryResponseObservation,
OpenCodeDeliveryResponseState,
OpenCodeDeliveryVisibleReplyCorrelation,
} from '../bridge/OpenCodeBridgeCommandContract';
export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1;
export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export const OPENCODE_PROMPT_DELIVERY_FAILED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export type OpenCodePromptDeliveryStatus =
| 'pending'
| 'accepted'
| 'responded'
| 'unanswered'
| 'retry_scheduled'
| 'retried'
| 'failed_retryable'
| 'failed_terminal';
export interface OpenCodePromptDeliveryLedgerRecord {
id: string;
teamName: string;
memberName: string;
laneId: string;
runId: string | null;
runtimeSessionId: string | null;
inboxMessageId: string;
inboxTimestamp: string;
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog';
replyRecipient: string;
actionMode: AgentActionMode | null;
taskRefs: TaskRef[];
payloadHash: string;
status: OpenCodePromptDeliveryStatus;
responseState: OpenCodeDeliveryResponseState;
attempts: number;
maxAttempts: number;
acceptanceUnknown: boolean;
nextAttemptAt: string | null;
lastAttemptAt: string | null;
lastObservedAt: string | null;
acceptedAt: string | null;
respondedAt: string | null;
failedAt: string | null;
inboxReadCommittedAt: string | null;
inboxReadCommitError: string | null;
prePromptCursor: string | null;
postPromptCursor: string | null;
deliveredUserMessageId: string | null;
observedAssistantMessageId: string | null;
observedAssistantPreview: string | null;
observedToolCallNames: string[];
observedVisibleMessageId: string | null;
visibleReplyMessageId: string | null;
visibleReplyInbox: string | null;
visibleReplyCorrelation: OpenCodeDeliveryVisibleReplyCorrelation | null;
lastReason: string | null;
diagnostics: string[];
createdAt: string;
updatedAt: string;
}
const OPENCODE_PROMPT_DELIVERY_STATUSES = new Set<OpenCodePromptDeliveryStatus>([
'pending',
'accepted',
'responded',
'unanswered',
'retry_scheduled',
'retried',
'failed_retryable',
'failed_terminal',
]);
const OPENCODE_DELIVERY_RESPONSE_STATES = new Set<OpenCodeDeliveryResponseState>([
'not_observed',
'pending',
'prompt_not_indexed',
'responded_tool_call',
'responded_visible_message',
'responded_non_visible_tool',
'responded_plain_text',
'permission_blocked',
'tool_error',
'empty_assistant_turn',
'session_stale',
'session_error',
'reconcile_failed',
]);
const OPENCODE_PROMPT_DELIVERY_SOURCES = new Set<OpenCodePromptDeliveryLedgerRecord['source']>([
'watcher',
'ui-send',
'manual',
'watchdog',
]);
const OPENCODE_DELIVERY_VISIBLE_REPLY_CORRELATIONS =
new Set<OpenCodeDeliveryVisibleReplyCorrelation>([
'relayOfMessageId',
'direct_child_message_send',
'plain_assistant_text',
]);
const AGENT_ACTION_MODES = new Set<AgentActionMode>(['do', 'ask', 'delegate']);
export interface EnsureOpenCodePromptDeliveryInput {
teamName: string;
memberName: string;
laneId: string;
runId?: string | null;
inboxMessageId: string;
inboxTimestamp: string;
source: OpenCodePromptDeliveryLedgerRecord['source'];
replyRecipient: string;
actionMode?: AgentActionMode | null;
taskRefs?: TaskRef[];
payloadHash: string;
maxAttempts?: number;
now: string;
}
export interface ApplyOpenCodePromptDeliveryResultInput {
id: string;
accepted: boolean;
attempted?: boolean;
responseObservation?: OpenCodeDeliveryResponseObservation;
sessionId?: string | null;
runtimePid?: number;
prePromptCursor?: string | null;
diagnostics?: string[];
reason?: string | null;
now: string;
}
export interface ApplyOpenCodePromptDestinationProofInput {
id: string;
visibleReplyInbox: string;
visibleReplyMessageId: string;
visibleReplyCorrelation: 'relayOfMessageId';
semanticallySufficient: boolean;
diagnostics?: string[];
observedAt: string;
}
export class OpenCodePromptDeliveryLedgerStore {
constructor(private readonly store: VersionedJsonStore<OpenCodePromptDeliveryLedgerRecord[]>) {}
async ensurePending(
input: EnsureOpenCodePromptDeliveryInput
): Promise<OpenCodePromptDeliveryLedgerRecord> {
const id = buildOpenCodePromptDeliveryRecordId(input);
let result: OpenCodePromptDeliveryLedgerRecord | null = null;
await this.store.updateLocked((records) => {
const existing = records.find((record) => record.id === id);
if (existing) {
if (existing.payloadHash !== input.payloadHash) {
const reason = 'opencode_prompt_delivery_payload_mismatch';
const updated: OpenCodePromptDeliveryLedgerRecord = {
...existing,
status: 'failed_terminal',
failedAt: input.now,
nextAttemptAt: null,
lastReason: reason,
diagnostics: mergeDiagnostics(existing.diagnostics, [
`${reason}: existing payload hash does not match current inbox row payload`,
]),
updatedAt: input.now,
};
result = updated;
return records.map((record) => (record.id === existing.id ? updated : record));
}
result = existing;
return records;
}
const created: OpenCodePromptDeliveryLedgerRecord = {
id,
teamName: input.teamName,
memberName: input.memberName,
laneId: input.laneId,
runId: input.runId ?? null,
runtimeSessionId: null,
inboxMessageId: input.inboxMessageId,
inboxTimestamp: input.inboxTimestamp,
source: input.source,
replyRecipient: input.replyRecipient,
actionMode: input.actionMode ?? null,
taskRefs: input.taskRefs ?? [],
payloadHash: input.payloadHash,
status: 'pending',
responseState: 'not_observed',
attempts: 0,
maxAttempts: input.maxAttempts ?? 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: null,
lastObservedAt: null,
acceptedAt: null,
respondedAt: null,
failedAt: null,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: null,
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: null,
diagnostics: [],
createdAt: input.now,
updatedAt: input.now,
};
result = created;
return [...records, created];
});
if (!result) {
throw new Error('OpenCode prompt delivery ensurePending failed');
}
return result;
}
async getByInboxMessage(input: {
teamName: string;
memberName: string;
laneId: string;
inboxMessageId: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord | null> {
const records = await this.readRequired();
return (
records.find(
(record) =>
record.teamName === input.teamName &&
record.memberName.toLowerCase() === input.memberName.toLowerCase() &&
record.laneId === input.laneId &&
record.inboxMessageId === input.inboxMessageId
) ?? null
);
}
async getActiveForMember(input: {
teamName: string;
memberName: string;
laneId: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord | null> {
const records = await this.readRequired();
return (
records
.filter(
(record) =>
record.teamName === input.teamName &&
record.memberName.toLowerCase() === input.memberName.toLowerCase() &&
record.laneId === input.laneId &&
!isTerminalForAutomaticSelection(record)
)
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))[0] ?? null
);
}
async applyDeliveryResult(
input: ApplyOpenCodePromptDeliveryResultInput
): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => {
const observation = input.responseObservation;
const responseState =
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
const responded = isOpenCodePromptResponseStateResponded(responseState);
const unanswered = responseState === 'empty_assistant_turn';
return {
...record,
status: input.accepted
? responded
? 'responded'
: unanswered
? 'unanswered'
: 'accepted'
: 'failed_retryable',
responseState,
attempts:
input.accepted || input.attempted === true ? record.attempts + 1 : record.attempts,
runtimeSessionId: input.sessionId ?? record.runtimeSessionId,
acceptanceUnknown: input.accepted ? false : record.acceptanceUnknown,
lastAttemptAt: input.now,
lastObservedAt: observation ? input.now : record.lastObservedAt,
acceptedAt: input.accepted ? (record.acceptedAt ?? input.now) : record.acceptedAt,
respondedAt: responded ? (record.respondedAt ?? input.now) : record.respondedAt,
prePromptCursor: input.prePromptCursor ?? record.prePromptCursor,
deliveredUserMessageId:
observation?.deliveredUserMessageId ?? record.deliveredUserMessageId,
observedAssistantMessageId:
observation?.assistantMessageId ?? record.observedAssistantMessageId,
observedAssistantPreview:
observation?.latestAssistantPreview ?? record.observedAssistantPreview,
observedToolCallNames: observation?.toolCallNames ?? record.observedToolCallNames,
observedVisibleMessageId:
observation?.visibleMessageToolCallId ?? record.observedVisibleMessageId,
visibleReplyMessageId: observation?.visibleReplyMessageId ?? record.visibleReplyMessageId,
visibleReplyCorrelation:
observation?.visibleReplyCorrelation ?? record.visibleReplyCorrelation,
lastReason: input.reason ?? observation?.reason ?? record.lastReason,
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
updatedAt: input.now,
};
});
}
async applyObservation(input: {
id: string;
responseObservation: OpenCodeDeliveryResponseObservation;
diagnostics?: string[];
observedAt: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => {
const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state);
const unanswered = input.responseObservation.state === 'empty_assistant_turn';
return {
...record,
status: responded
? 'responded'
: unanswered
? 'unanswered'
: record.status === 'pending'
? 'accepted'
: record.status,
responseState: input.responseObservation.state,
lastObservedAt: input.observedAt,
respondedAt: responded ? (record.respondedAt ?? input.observedAt) : record.respondedAt,
deliveredUserMessageId:
input.responseObservation.deliveredUserMessageId ?? record.deliveredUserMessageId,
observedAssistantMessageId:
input.responseObservation.assistantMessageId ?? record.observedAssistantMessageId,
observedAssistantPreview:
input.responseObservation.latestAssistantPreview ?? record.observedAssistantPreview,
observedToolCallNames: input.responseObservation.toolCallNames,
observedVisibleMessageId:
input.responseObservation.visibleMessageToolCallId ?? record.observedVisibleMessageId,
visibleReplyMessageId:
input.responseObservation.visibleReplyMessageId ?? record.visibleReplyMessageId,
visibleReplyCorrelation:
input.responseObservation.visibleReplyCorrelation ?? record.visibleReplyCorrelation,
lastReason: input.responseObservation.reason ?? record.lastReason,
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
updatedAt: input.observedAt,
};
});
}
async applyDestinationProof(
input: ApplyOpenCodePromptDestinationProofInput
): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => ({
...record,
status: input.semanticallySufficient ? 'responded' : record.status,
responseState: 'responded_visible_message',
lastObservedAt: input.observedAt,
respondedAt: input.semanticallySufficient
? (record.respondedAt ?? input.observedAt)
: record.respondedAt,
visibleReplyInbox: input.visibleReplyInbox,
visibleReplyMessageId: input.visibleReplyMessageId,
visibleReplyCorrelation: input.visibleReplyCorrelation,
lastReason: input.semanticallySufficient
? record.lastReason
: 'visible_reply_ack_only_still_requires_answer',
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
updatedAt: input.observedAt,
}));
}
async markAcceptanceUnknown(input: {
id: string;
reason: string;
nextAttemptAt: string;
diagnostics?: string[];
markedAt: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => ({
...record,
status: 'failed_retryable',
responseState: 'not_observed',
acceptanceUnknown: true,
nextAttemptAt: input.nextAttemptAt,
lastReason: input.reason,
diagnostics: mergeDiagnostics(record.diagnostics, [
input.reason,
...(input.diagnostics ?? []),
]),
updatedAt: input.markedAt,
}));
}
async markNextAttemptScheduled(input: {
id: string;
status: Extract<OpenCodePromptDeliveryStatus, 'accepted' | 'retry_scheduled'>;
nextAttemptAt: string;
reason: string;
scheduledAt: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => ({
...record,
status: input.status,
nextAttemptAt: input.nextAttemptAt,
lastReason: input.reason,
updatedAt: input.scheduledAt,
}));
}
async markRetryAttempted(input: {
id: string;
attemptedAt: string;
reason?: string | null;
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => ({
...record,
status: 'retried',
attempts: record.attempts + 1,
lastAttemptAt: input.attemptedAt,
nextAttemptAt: null,
lastReason: input.reason ?? record.lastReason,
updatedAt: input.attemptedAt,
}));
}
async markFailedTerminal(input: {
id: string;
reason: string;
diagnostics?: string[];
failedAt: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => ({
...record,
status: 'failed_terminal',
failedAt: input.failedAt,
nextAttemptAt: null,
lastReason: input.reason,
diagnostics: mergeDiagnostics(record.diagnostics, [
input.reason,
...(input.diagnostics ?? []),
]),
updatedAt: input.failedAt,
}));
}
async markInboxReadCommitted(input: {
id: string;
committedAt: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => ({
...record,
inboxReadCommittedAt: input.committedAt,
inboxReadCommitError: null,
updatedAt: input.committedAt,
}));
}
async markInboxReadCommitFailed(input: {
id: string;
error: string;
failedAt: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => ({
...record,
inboxReadCommitError: input.error,
diagnostics: mergeDiagnostics(record.diagnostics, [input.error]),
updatedAt: input.failedAt,
}));
}
async list(): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
return await this.readRequired();
}
async listDue(input: {
teamName?: string;
now: Date;
limit: number;
}): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
const nowMs = input.now.getTime();
const limit = Math.max(0, input.limit);
if (limit === 0) {
return [];
}
const teamName = input.teamName?.trim().toLowerCase() ?? null;
const records = await this.readRequired();
return records
.filter((record) => {
if (teamName && record.teamName.trim().toLowerCase() !== teamName) {
return false;
}
if (isTerminalForAutomaticSelection(record)) {
return false;
}
return isOpenCodePromptDeliveryAttemptDue(record, nowMs);
})
.sort(compareOpenCodePromptDeliveryDueOrder)
.slice(0, limit);
}
async pruneTerminalRecords(input: {
now: Date;
respondedRetentionMs?: number;
failedRetentionMs?: number;
}): Promise<{ pruned: number; remaining: number }> {
const nowMs = input.now.getTime();
const respondedRetentionMs =
input.respondedRetentionMs ?? OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS;
const failedRetentionMs =
input.failedRetentionMs ?? OPENCODE_PROMPT_DELIVERY_FAILED_RETENTION_MS;
let pruned = 0;
let remaining = 0;
await this.store.updateLocked((records) => {
const kept = records.filter((record) => {
if (
shouldPruneOpenCodePromptDeliveryRecord(
record,
nowMs,
respondedRetentionMs,
failedRetentionMs
)
) {
pruned += 1;
return false;
}
return true;
});
remaining = kept.length;
return kept;
});
return { pruned, remaining };
}
private async updateExisting(
id: string,
updater: (record: OpenCodePromptDeliveryLedgerRecord) => OpenCodePromptDeliveryLedgerRecord
): Promise<OpenCodePromptDeliveryLedgerRecord> {
let updated: OpenCodePromptDeliveryLedgerRecord | null = null;
await this.store.updateLocked((records) =>
records.map((record) => {
if (record.id !== id) {
return record;
}
updated = updater(record);
return updated;
})
);
if (!updated) {
throw new Error(`OpenCode prompt delivery record not found: ${id}`);
}
return updated;
}
private async readRequired(): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
const result = await this.store.read();
if (!result.ok) {
throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath);
}
return result.data;
}
}
export function createOpenCodePromptDeliveryLedgerStore(options: {
filePath: string;
clock?: () => Date;
}): OpenCodePromptDeliveryLedgerStore {
const clock = options.clock ?? (() => new Date());
return new OpenCodePromptDeliveryLedgerStore(
new VersionedJsonStore<OpenCodePromptDeliveryLedgerRecord[]>({
filePath: options.filePath,
schemaVersion: OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION,
defaultData: () => [],
validate: validateOpenCodePromptDeliveryLedgerRecords,
clock,
})
);
}
export function buildOpenCodePromptDeliveryRecordId(input: {
teamName: string;
memberName: string;
laneId: string;
inboxMessageId: string;
}): string {
return `opencode-prompt:${stableHash({
version: 1,
teamName: input.teamName,
memberName: input.memberName.toLowerCase(),
laneId: input.laneId,
inboxMessageId: input.inboxMessageId,
})}`;
}
export function hashOpenCodePromptDeliveryPayload(input: {
text: string;
replyRecipient: string;
actionMode?: AgentActionMode | null;
taskRefs?: TaskRef[];
attachments?: Array<{ id?: string; filename?: string; mimeType?: string; size?: number }>;
source?: string;
}): string {
return `sha256:${stableHash({
text: input.text,
replyRecipient: input.replyRecipient,
actionMode: input.actionMode ?? null,
taskRefs: input.taskRefs ?? [],
attachments:
input.attachments?.map((attachment) => ({
id: attachment.id ?? null,
filename: attachment.filename ?? null,
mimeType: attachment.mimeType ?? null,
size: attachment.size ?? null,
})) ?? [],
source: input.source ?? null,
})}`;
}
export function isOpenCodePromptResponseStateResponded(
state: OpenCodeDeliveryResponseState
): boolean {
return (
state === 'responded_visible_message' ||
state === 'responded_non_visible_tool' ||
state === 'responded_tool_call' ||
state === 'responded_plain_text'
);
}
export function isOpenCodePromptDeliveryAttemptDue(
record: OpenCodePromptDeliveryLedgerRecord,
nowMs: number = Date.now()
): boolean {
if (!record.nextAttemptAt) {
return true;
}
const dueMs = Date.parse(record.nextAttemptAt);
return !Number.isFinite(dueMs) || dueMs <= nowMs;
}
export function validateOpenCodePromptDeliveryLedgerRecords(
value: unknown
): OpenCodePromptDeliveryLedgerRecord[] {
if (!Array.isArray(value)) {
throw new Error('OpenCode prompt delivery ledger must be an array');
}
const seen = new Set<string>();
return value.map((record, index) => {
if (!isOpenCodePromptDeliveryLedgerRecord(record)) {
throw new Error(`Invalid OpenCode prompt delivery ledger record at index ${index}`);
}
if (seen.has(record.id)) {
throw new Error(`Duplicate OpenCode prompt delivery ledger id: ${record.id}`);
}
seen.add(record.id);
return record;
});
}
function isOpenCodePromptDeliveryLedgerRecord(
value: unknown
): value is OpenCodePromptDeliveryLedgerRecord {
const record = value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
return Boolean(
record &&
typeof record.id === 'string' &&
typeof record.teamName === 'string' &&
typeof record.memberName === 'string' &&
typeof record.laneId === 'string' &&
isOptionalNullableString(record.runId) &&
isOptionalNullableString(record.runtimeSessionId) &&
typeof record.inboxMessageId === 'string' &&
typeof record.inboxTimestamp === 'string' &&
isOpenCodePromptDeliverySource(record.source) &&
typeof record.replyRecipient === 'string' &&
isOptionalNullableActionMode(record.actionMode) &&
isTaskRefArray(record.taskRefs) &&
typeof record.payloadHash === 'string' &&
isOpenCodePromptDeliveryStatus(record.status) &&
isOpenCodeDeliveryResponseState(record.responseState) &&
isNonNegativeInteger(record.attempts) &&
isNonNegativeInteger(record.maxAttempts) &&
typeof record.acceptanceUnknown === 'boolean' &&
isOptionalNullableString(record.nextAttemptAt) &&
isOptionalNullableString(record.lastAttemptAt) &&
isOptionalNullableString(record.lastObservedAt) &&
isOptionalNullableString(record.acceptedAt) &&
isOptionalNullableString(record.respondedAt) &&
isOptionalNullableString(record.failedAt) &&
isOptionalNullableString(record.inboxReadCommittedAt) &&
isOptionalNullableString(record.inboxReadCommitError) &&
isOptionalNullableString(record.prePromptCursor) &&
isOptionalNullableString(record.postPromptCursor) &&
isOptionalNullableString(record.deliveredUserMessageId) &&
isOptionalNullableString(record.observedAssistantMessageId) &&
isOptionalNullableString(record.observedAssistantPreview) &&
isStringArray(record.observedToolCallNames) &&
isOptionalNullableString(record.observedVisibleMessageId) &&
isOptionalNullableString(record.visibleReplyMessageId) &&
isOptionalNullableString(record.visibleReplyInbox) &&
isOptionalNullableVisibleReplyCorrelation(record.visibleReplyCorrelation) &&
isOptionalNullableString(record.lastReason) &&
isStringArray(record.diagnostics) &&
typeof record.createdAt === 'string' &&
typeof record.updatedAt === 'string'
);
}
function isOpenCodePromptDeliveryStatus(value: unknown): value is OpenCodePromptDeliveryStatus {
return (
typeof value === 'string' &&
OPENCODE_PROMPT_DELIVERY_STATUSES.has(value as OpenCodePromptDeliveryStatus)
);
}
function isOpenCodeDeliveryResponseState(value: unknown): value is OpenCodeDeliveryResponseState {
return (
typeof value === 'string' &&
OPENCODE_DELIVERY_RESPONSE_STATES.has(value as OpenCodeDeliveryResponseState)
);
}
function isOpenCodePromptDeliverySource(
value: unknown
): value is OpenCodePromptDeliveryLedgerRecord['source'] {
return (
typeof value === 'string' &&
OPENCODE_PROMPT_DELIVERY_SOURCES.has(value as OpenCodePromptDeliveryLedgerRecord['source'])
);
}
function isOptionalNullableVisibleReplyCorrelation(
value: unknown
): value is OpenCodeDeliveryVisibleReplyCorrelation | null | undefined {
return (
value === undefined ||
value === null ||
(typeof value === 'string' &&
OPENCODE_DELIVERY_VISIBLE_REPLY_CORRELATIONS.has(
value as OpenCodeDeliveryVisibleReplyCorrelation
))
);
}
function isOptionalNullableActionMode(value: unknown): value is AgentActionMode | null | undefined {
return (
value === undefined ||
value === null ||
(typeof value === 'string' && AGENT_ACTION_MODES.has(value as AgentActionMode))
);
}
function isOptionalNullableString(value: unknown): value is string | null | undefined {
return value === undefined || value === null || typeof value === 'string';
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function isNonNegativeInteger(value: unknown): value is number {
return Number.isInteger(value) && (value as number) >= 0;
}
function isTaskRefArray(value: unknown): value is TaskRef[] {
return (
Array.isArray(value) &&
value.every((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return false;
}
const taskRef = item as Record<string, unknown>;
return (
typeof taskRef.taskId === 'string' &&
typeof taskRef.displayId === 'string' &&
typeof taskRef.teamName === 'string'
);
})
);
}
function isTerminalForAutomaticSelection(record: OpenCodePromptDeliveryLedgerRecord): boolean {
return record.status === 'failed_terminal' || record.status === 'responded';
}
function compareOpenCodePromptDeliveryDueOrder(
left: OpenCodePromptDeliveryLedgerRecord,
right: OpenCodePromptDeliveryLedgerRecord
): number {
const leftDue = left.nextAttemptAt ? Date.parse(left.nextAttemptAt) : Date.parse(left.createdAt);
const rightDue = right.nextAttemptAt
? Date.parse(right.nextAttemptAt)
: Date.parse(right.createdAt);
const dueDelta = safeSortableTime(leftDue) - safeSortableTime(rightDue);
if (dueDelta !== 0) {
return dueDelta;
}
return Date.parse(left.createdAt) - Date.parse(right.createdAt);
}
function safeSortableTime(value: number): number {
return Number.isFinite(value) ? value : 0;
}
function shouldPruneOpenCodePromptDeliveryRecord(
record: OpenCodePromptDeliveryLedgerRecord,
nowMs: number,
respondedRetentionMs: number,
failedRetentionMs: number
): boolean {
if (record.status === 'responded' && record.inboxReadCommittedAt) {
const committedMs = Date.parse(record.inboxReadCommittedAt);
return Number.isFinite(committedMs) && nowMs - committedMs >= respondedRetentionMs;
}
if (record.status === 'failed_terminal') {
const failedMs = Date.parse(record.failedAt ?? record.updatedAt);
return Number.isFinite(failedMs) && nowMs - failedMs >= failedRetentionMs;
}
return false;
}
function mergeDiagnostics(existing: string[], next: string[]): string[] {
return [...new Set([...existing, ...next].filter((item) => item.trim()))];
}

View file

@ -0,0 +1,138 @@
import type { AgentActionMode, InboxMessage, TaskRef } from '@shared/types/team';
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
export const OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS = 3_000;
export const OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS = 15_000;
export const OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY = 2;
export const OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY = 1;
const ACK_ONLY_PHRASES = new Set([
'понял',
'поняла',
'ок',
'окей',
'принял',
'приняла',
'сделаю',
'разберусь',
'understood',
'got it',
'ok',
'okay',
'will do',
]);
const ACK_ONLY_PREFIXES = [
"i'll check",
'i will check',
"i'll take a look",
'i will take a look',
"i'll do it",
'i will do it',
'я проверю',
'я посмотрю',
];
export interface OpenCodeVisibleReplyProof {
inboxName: string;
message: InboxMessage & { messageId: string };
missingRuntimeDeliverySource?: boolean;
}
export interface OpenCodeVisibleReplySemanticResult {
sufficient: boolean;
reason?: 'ack_only' | 'concrete_reply';
}
export function isOpenCodeVisibleReplySemanticallySufficient(input: {
actionMode?: AgentActionMode | null;
taskRefs?: TaskRef[];
text: string;
summary?: string | null;
}): OpenCodeVisibleReplySemanticResult {
const combined = [input.summary, input.text]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n')
.trim();
if (!combined) {
return { sufficient: false, reason: 'ack_only' };
}
if (!looksLikeNarrowAckOnly(combined)) {
return { sufficient: true, reason: 'concrete_reply' };
}
return { sufficient: false, reason: 'ack_only' };
}
export function isOpenCodeVisibleReplyReadCommitAllowed(input: {
actionMode?: AgentActionMode | null;
taskRefs?: TaskRef[];
visibleReply?: OpenCodeVisibleReplyProof | null;
transcriptOnlyVisibleReply?: boolean;
}): boolean {
if (input.visibleReply) {
return isOpenCodeVisibleReplySemanticallySufficient({
actionMode: input.actionMode,
taskRefs: input.taskRefs,
text: input.visibleReply.message.text,
summary: input.visibleReply.message.summary,
}).sufficient;
}
// Transcript-only message_send proves OpenCode attempted a visible reply, but not
// whether the destination store committed it yet. Keep it pending for the watchdog.
return input.transcriptOnlyVisibleReply !== true;
}
export function isOpenCodePromptDeliveryRetryableResponseState(
state: OpenCodeDeliveryResponseState | undefined
): boolean {
return (
state === 'empty_assistant_turn' ||
state === 'tool_error' ||
state === 'reconcile_failed' ||
state === 'not_observed'
);
}
export function isOpenCodePromptDeliveryObserveLaterResponseState(
state: OpenCodeDeliveryResponseState | undefined
): boolean {
return (
state === 'pending' ||
state === 'prompt_not_indexed' ||
state === 'permission_blocked' ||
state === 'session_stale'
);
}
function looksLikeNarrowAckOnly(text: string): boolean {
const normalized = text
.trim()
.toLowerCase()
.replace(/[.!?,;:()[\]{}"'`«»]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!normalized || normalized.length > 120) {
return false;
}
if (/[#/@\\]|\d|```|`/.test(text)) {
return false;
}
if (/[?]/.test(text)) {
return false;
}
const sentenceLikeParts = text
.split(/[.!?。!?]+/)
.map((part) => part.trim())
.filter(Boolean);
if (sentenceLikeParts.length > 1) {
return false;
}
if (ACK_ONLY_PHRASES.has(normalized)) {
return true;
}
return ACK_ONLY_PREFIXES.some(
(prefix) => normalized === prefix || normalized.startsWith(`${prefix} `)
);
}

View file

@ -13,6 +13,7 @@ export type RuntimeStoreSchemaName =
| 'opencode.sessionStore'
| 'opencode.launchTransaction'
| 'opencode.deliveryJournal'
| 'opencode.promptDeliveryLedger'
| 'opencode.permissionRequests'
| 'opencode.hostLeases'
| 'opencode.compatibilitySnapshot'
@ -205,6 +206,14 @@ export const OPENCODE_RUNTIME_STORE_DESCRIPTORS: RuntimeStoreDescriptor[] = [
owner: 'delivery',
rebuildStrategy: 'verify_canonical_destinations',
},
{
schemaName: 'opencode.promptDeliveryLedger',
schemaVersion: 1,
relativePath: 'opencode-prompt-delivery-ledger.json',
criticality: 'rebuildable_from_canonical_destination',
owner: 'delivery',
rebuildStrategy: 'verify_canonical_destinations',
},
{
schemaName: 'opencode.permissionRequests',
schemaVersion: 1,
@ -1087,6 +1096,7 @@ function isRuntimeStoreSchemaName(value: unknown): value is RuntimeStoreSchemaNa
value === 'opencode.sessionStore' ||
value === 'opencode.launchTransaction' ||
value === 'opencode.deliveryJournal' ||
value === 'opencode.promptDeliveryLedger' ||
value === 'opencode.permissionRequests' ||
value === 'opencode.hostLeases' ||
value === 'opencode.compatibilitySnapshot' ||

View file

@ -4,6 +4,8 @@ import type {
OpenCodeBridgeRuntimeSnapshot,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody,
OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData,
@ -41,6 +43,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
sendOpenCodeTeamMessage?(
input: OpenCodeSendMessageCommandBody
): Promise<OpenCodeSendMessageCommandData>;
observeOpenCodeTeamMessageDelivery?(
input: OpenCodeObserveMessageDeliveryCommandBody
): Promise<OpenCodeObserveMessageDeliveryCommandData>;
}
export interface OpenCodeTeamRuntimeMessageInput {
@ -62,6 +67,8 @@ export interface OpenCodeTeamRuntimeMessageResult {
memberName: string;
sessionId?: string;
runtimePid?: number;
prePromptCursor?: string | null;
responseObservation?: OpenCodeSendMessageCommandData['responseObservation'];
diagnostics: string[];
}
@ -285,6 +292,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
memberName: input.memberName,
text: buildOpenCodeRuntimeMessageText(input),
messageId: input.messageId,
actionMode: input.actionMode,
taskRefs: input.taskRefs,
agent: 'teammate',
});
@ -294,6 +303,50 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
memberName: input.memberName,
sessionId: data.sessionId,
runtimePid: data.runtimePid,
prePromptCursor: data.prePromptCursor,
responseObservation: data.responseObservation,
diagnostics: data.diagnostics.map((diagnostic) => diagnostic.message),
};
}
async observeMessageDelivery(
input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null }
): Promise<OpenCodeTeamRuntimeMessageResult> {
if (!this.bridge.observeOpenCodeTeamMessageDelivery) {
return {
ok: false,
providerId: this.providerId,
memberName: input.memberName,
diagnostics: ['OpenCode message delivery observe bridge is not registered.'],
};
}
if (!input.messageId?.trim()) {
return {
ok: false,
providerId: this.providerId,
memberName: input.memberName,
diagnostics: ['OpenCode message delivery observe requires messageId.'],
};
}
const data = await this.bridge.observeOpenCodeTeamMessageDelivery({
runId: input.runId,
laneId: input.laneId,
teamId: input.teamName,
teamName: input.teamName,
projectPath: input.cwd,
memberName: input.memberName,
messageId: input.messageId,
prePromptCursor: input.prePromptCursor ?? null,
});
return {
ok: data.observed,
providerId: this.providerId,
memberName: input.memberName,
sessionId: data.sessionId,
runtimePid: data.runtimePid,
responseObservation: data.responseObservation,
diagnostics: data.diagnostics.map((diagnostic) => diagnostic.message),
};
}
@ -564,6 +617,10 @@ function buildMemberBootstrapPrompt(
'After runtime identity check-in, if you have not already done so, call MCP tool agent-teams_member_briefing (or mcp__agent-teams__member_briefing if that is the exposed name) with:',
`{ "teamName": "${input.teamName}", "memberName": "${member.name}", "runtimeProvider": "opencode" }`,
'If that tool is not available, stay idle and wait for app-delivered instructions. Do not improvise a replacement workflow.',
'Launch bootstrap is a silent attach, not a user/team conversation turn.',
'After runtime_bootstrap_checkin and member_briefing both succeed, stop this turn immediately and wait for app-delivered messages or actionable task assignments.',
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
'If the briefing says there are no actionable tasks, stay idle silently.',
'',
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
@ -582,17 +639,24 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
'You are running in OpenCode, not Claude Code or Codex native.',
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).',
`Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
'Include source="runtime_delivery" in that message_send call.',
input.messageId
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
: null,
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
'Do not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.',
'The inbound app message follows. Treat it as the actual instruction to process now, not as background context.',
'If the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.',
input.actionMode ? `Action mode for this message: ${input.actionMode}.` : null,
taskRefs ? `If your reply is about these tasks, include taskRefs exactly: ${taskRefs}` : null,
input.messageId
? `The inbound app messageId is "${input.messageId}"; keep it only as context unless a tool explicitly asks for provenance.`
: null,
input.messageId ? `The inbound app messageId is "${input.messageId}".` : null,
'</opencode_app_message_delivery>',
'',
'<opencode_inbound_app_message>',
input.text,
'</opencode_inbound_app_message>',
]
.filter((line): line is string => line !== null)
.join('\n');

View file

@ -7,6 +7,7 @@ import {
spawn,
type SpawnOptions,
} from 'child_process';
import { existsSync, readFileSync } from 'fs';
import path from 'path';
/**
@ -28,11 +29,15 @@ function execFileAsync(
child = execFile(cmd, args, options, (err, stdout, stderr) => {
settled = true;
cleanup();
if (err)
reject(
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
);
else resolve({ stdout: String(stdout), stderr: String(stderr) });
if (err) {
const normalizedError =
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
Object.assign(normalizedError, {
stdout: String(stdout),
stderr: String(stderr),
});
reject(normalizedError);
} else resolve({ stdout: String(stdout), stderr: String(stderr) });
});
if (!settled) {
trackCliProcess(child);
@ -79,14 +84,96 @@ function containsNonAscii(str: string): boolean {
}
/**
* On Windows, creating a process whose *path* contains non-ASCII
* characters will often fail with `spawn EINVAL`. Detect that case so
* callers can automatically fall back to launching via a shell.
* On Windows, batch launchers need cmd.exe, and creating a process whose
* path contains non-ASCII characters will often fail with `spawn EINVAL`.
* Detect both cases so callers can launch through a shell when needed.
*/
function needsShell(binaryPath: string): boolean {
if (process.platform !== 'win32') return false;
if (!binaryPath) return false;
return containsNonAscii(binaryPath);
const extension = path.extname(binaryPath).toLowerCase();
return extension === '.cmd' || extension === '.bat' || containsNonAscii(binaryPath);
}
interface DirectWindowsLauncher {
command: string;
argsPrefix: string[];
}
function isWindowsBatchLauncher(binaryPath: string): boolean {
const extension = path.extname(binaryPath).toLowerCase();
return extension === '.cmd' || extension === '.bat';
}
function resolveCmdPathTemplate(template: string, launcherDir: string): string {
const dirWithSep = launcherDir.endsWith(path.sep) ? launcherDir : `${launcherDir}${path.sep}`;
return path.resolve(
template
.replace(/%SCRIPT_DIR%/gi, dirWithSep)
.replace(/%~dp0/gi, dirWithSep)
.replace(/%dp0%/gi, dirWithSep)
.replace(/\\/g, path.sep)
);
}
function resolveGeneratedBunLauncher(
content: string,
launcherDir: string
): DirectWindowsLauncher | null {
if (!/\bbun\s+"%TARGET%"\s+%\*/i.test(content)) {
return null;
}
const targetMatch = /set\s+"TARGET=([^"]+)"/i.exec(content);
const targetTemplate = targetMatch?.[1];
if (!targetTemplate) {
return null;
}
const target = resolveCmdPathTemplate(targetTemplate, launcherDir);
if (!existsSync(target)) {
return null;
}
return { command: 'bun', argsPrefix: [target] };
}
function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
const scriptMatch = /"%_prog%"\s+"([^"]+\.(?:cjs|mjs|js))"\s+%\*/i.exec(content);
const scriptTemplate = scriptMatch?.[1];
if (!scriptTemplate) {
return null;
}
const scriptPath = resolveCmdPathTemplate(scriptTemplate, launcherDir);
if (!existsSync(scriptPath)) {
return null;
}
const localNode = path.join(launcherDir, 'node.exe');
return {
command: existsSync(localNode) ? localNode : 'node',
argsPrefix: [scriptPath],
};
}
/**
* Some Windows launchers are thin wrappers around a real JS entrypoint.
* Running that entrypoint directly with an argv array avoids cmd.exe's
* percent expansion, which cannot safely represent args like `%PATH%`.
*/
function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher | null {
if (process.platform !== 'win32' || !isWindowsBatchLauncher(binaryPath)) {
return null;
}
try {
const content = readFileSync(binaryPath, 'utf8');
const launcherDir = path.dirname(binaryPath);
return (
resolveGeneratedBunLauncher(content, launcherDir) ?? resolveNpmNodeShim(content, launcherDir)
);
} catch {
return null;
}
}
/**
@ -94,21 +181,33 @@ function needsShell(binaryPath: string): boolean {
*
* cmd.exe rules:
* - Double-quote args containing spaces or special characters
* - Inside double quotes, escape literal `"` as `""`
* - `%` is expanded as env var even inside double quotes escape as `%%`
* - Inside double quotes, escape literal `"` as `\"` for the target argv parser
* - Double trailing backslashes so they do not escape the closing quote
* - `%` is expanded as env var even inside double quotes. Keep it outside
* quoted chunks and escape it as `^%`.
* - `^`, `&`, `|`, `<`, `>` are safe inside double quotes
*
* Our callers only pass controlled strings (binary paths, CLI flags),
* NOT arbitrary user input.
*/
function quoteArg(arg: string): string {
function quoteCmdChunk(chunk: string): string {
const escaped = chunk
.replace(/(\\*)"/g, (_match, backslashes: string) => `${backslashes}${backslashes}\\"`)
.replace(/(\\+)$/g, '$1$1');
return `"${escaped}"`;
}
export function quoteWindowsCmdArg(arg: string): string {
if (/[^A-Za-z0-9_\-/.]/.test(arg)) {
const escaped = arg.replace(/%/g, '%%').replace(/"/g, '""');
return `"${escaped}"`;
return arg.split('%').map(quoteCmdChunk).join('^%');
}
return arg;
}
function quoteArg(arg: string): string {
return quoteWindowsCmdArg(arg);
}
/** Env vars injected into every spawned Claude CLI process. */
const CLI_ENV_DEFAULTS: Record<string, string> = {
CLAUDE_HOOK_JUDGE_MODE: 'true',
@ -172,6 +271,15 @@ export async function execCli(
}
const target = binaryPath;
const opts = withCliEnv(options);
const directLauncher = resolveDirectWindowsLauncher(target);
if (directLauncher) {
const result = await execFileAsync(
directLauncher.command,
[...directLauncher.argsPrefix, ...args],
opts
);
return { stdout: String(result.stdout), stderr: String(result.stderr) };
}
// attempt the normal execFile path first
if (!needsShell(target)) {
@ -209,6 +317,14 @@ export function spawnCli(
options: SpawnOptions = {}
): ReturnType<typeof spawn> {
const opts = withCliEnv(options);
const directLauncher = resolveDirectWindowsLauncher(binaryPath);
if (directLauncher) {
const directOpts = { ...opts };
delete directOpts.shell;
return trackCliProcess(
spawn(directLauncher.command, [...directLauncher.argsPrefix, ...args], directOpts)
);
}
if (process.platform === 'win32' && needsShell(binaryPath)) {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');

View file

@ -38,6 +38,9 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
if (cachedEnv?.PATH) {
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
extraDirs.push(vendorBinDir);
if (process.platform !== 'win32') {
extraDirs.push(pathPosix.join(home, '.bun', 'bin'));
}
} else if (process.platform === 'win32') {
extraDirs.push(
vendorBinDir,
@ -53,6 +56,7 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
} else {
extraDirs.push(
vendorBinDir,
pathPosix.join(home, '.bun', 'bin'),
pathPosix.join(home, '.local', 'bin'),
pathPosix.join(home, '.npm-global', 'bin'),
pathPosix.join(home, '.npm', 'bin'),

View file

@ -1,5 +1,6 @@
import { createCodexAccountBridge } from '@features/codex-account/preload';
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload';
import { createTmuxInstallerBridge } from '@features/tmux-installer/preload';
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
import { contextBridge, ipcRenderer, webUtils } from 'electron';
@ -465,6 +466,7 @@ const electronAPI: ElectronAPI = {
ipcRenderer,
}),
...createRecentProjectsBridge(),
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getProjects: () => ipcRenderer.invoke('get-projects'),
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),

View file

@ -8,6 +8,7 @@
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
import type {
AppConfig,
AttachmentFileData,
@ -1187,6 +1188,90 @@ export class HttpAPIClient implements ElectronAPI {
},
};
runtimeProviderManagement: RuntimeProviderManagementApi = {
loadView: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
loadProviderDirectory: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
loadSetupForm: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
connectProvider: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
connectWithApiKey: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
forgetCredential: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
loadModels: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
testModel: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
setDefaultModel: async (input) => ({
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: 'Runtime provider management is not available in browser mode.',
recoverable: true,
},
}),
};
tmux: TmuxAPI = {
getStatus: async (): Promise<TmuxStatus> => ({
platform: 'unknown',

View file

@ -8,6 +8,7 @@ import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { extractFileReferenceTokens } from '@renderer/utils/groupTransformer';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
@ -33,9 +34,6 @@ import type { UserGroup } from '@renderer/types/groups';
const logger = createLogger('Component:UserChatGroup');
// Pattern for @paths only (file references)
const PATH_PATTERN = /@([^\s,)}\]]+)/g;
interface UserChatGroupProps {
userGroup: UserGroup;
}
@ -46,24 +44,22 @@ interface UserChatGroupProps {
*/
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
function highlightTextNode(text: string, validatedPaths: Record<string, boolean>): React.ReactNode {
const pathPattern = /@[^\s,)}\]]+/g;
const pathReferences = extractFileReferenceTokens(text);
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
pathPattern.lastIndex = 0;
while ((match = pathPattern.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
for (const reference of pathReferences) {
if (reference.startIndex > lastIndex) {
parts.push(text.slice(lastIndex, reference.startIndex));
}
const fullMatch = match[0];
const fullMatch = reference.raw;
const isValid = validatedPaths[fullMatch] === true;
if (isValid) {
parts.push(
<span
key={match.index}
key={reference.startIndex}
style={{
backgroundColor: 'var(--chat-user-tag-bg)',
color: 'var(--chat-user-tag-text)',
@ -81,7 +77,7 @@ function highlightTextNode(text: string, validatedPaths: Record<string, boolean>
parts.push(fullMatch);
}
lastIndex = match.index + fullMatch.length;
lastIndex = reference.endIndex;
}
if (lastIndex < text.length) {
@ -456,13 +452,10 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
// Extract @path mentions from text
const pathMentions = useMemo(() => {
if (!textContent) return [];
const result: { value: string; raw: string }[] = [];
const pathPattern = new RegExp(PATH_PATTERN.source, PATH_PATTERN.flags);
let match;
while ((match = pathPattern.exec(textContent)) !== null) {
result.push({ value: match[1], raw: match[0] });
}
return result;
return extractFileReferenceTokens(textContent).map((reference) => ({
value: reference.path,
raw: reference.raw,
}));
}, [textContent]);
// Validate @path mentions via IPC
@ -475,7 +468,11 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value }));
const results = await api.validateMentions(toValidate, projectPath);
if (isCurrent) {
setValidatedPaths(results);
setValidatedPaths(
Object.fromEntries(
pathMentions.map((mention) => [mention.raw, results[`@${mention.value}`] === true])
)
);
}
} catch (err) {
logger.error('Path validation failed:', err);

View file

@ -44,6 +44,7 @@ import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInst
import { formatBytes } from '@renderer/utils/formatters';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility';
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
import {
@ -79,6 +80,8 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' },
};
const OPENCODE_DOWNLOAD_URL = 'https://opencode.ai/download';
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
const BANNER_MIN_H = 'min-h-[4.25rem]';
@ -285,7 +288,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode';
return 'OpenCode (75+ LLM providers)';
}
}
@ -560,6 +563,19 @@ function hasVisibleAuthenticatedMultimodelProvider(
return visibleProviders.some((provider) => provider.authenticated);
}
function shouldShowOpenCodeDownloadAction(
provider: CliProviderStatus,
showSkeleton: boolean
): boolean {
return (
provider.providerId === 'opencode' &&
!showSkeleton &&
!provider.supported &&
!provider.authenticated &&
provider.backend == null
);
}
const InstalledBanner = ({
cliStatus,
sourceProviderMap,
@ -758,7 +774,9 @@ const InstalledBanner = ({
className="text-xs font-medium"
style={{ color: 'var(--color-text)' }}
>
{provider.displayName}
{provider.providerId === 'opencode'
? getProviderLabel(provider.providerId)
: provider.displayName}
</span>
{provider.providerId === 'opencode' ? <OpenCodeBetaBadge /> : null}
</span>
@ -802,6 +820,7 @@ const InstalledBanner = ({
models={provider.models}
modelAvailability={provider.modelAvailability}
providerStatus={provider}
collapseAfter={15}
/>
{codexDashboardRateLimits!.map((item) => (
<div
@ -898,6 +917,21 @@ const InstalledBanner = ({
) : null}
</div>
<div className="flex shrink-0 items-start gap-2">
{shouldShowOpenCodeDownloadAction(provider, showSkeleton) ? (
<button
type="button"
onClick={() => void api.openExternal(OPENCODE_DOWNLOAD_URL)}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'rgba(14, 165, 233, 0.36)',
color: '#7dd3fc',
}}
title="Download OpenCode CLI"
>
<Download className="size-3" />
Download
</button>
) : null}
<button
onClick={() => onProviderManage(provider.providerId)}
disabled={actionDisabled}
@ -960,6 +994,7 @@ const InstalledBanner = ({
models={provider.models}
modelAvailability={provider.modelAvailability}
providerStatus={provider}
collapseAfter={15}
/>
</div>
)}
@ -979,6 +1014,9 @@ const InstalledBanner = ({
export const CliStatusBanner = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const selectedProjectId = useStore((s) => s.selectedProjectId);
const projects = useStore((s) => s.projects);
const repositoryGroups = useStore((s) => s.repositoryGroups);
const updateConfig = useStore((s) => s.updateConfig);
const {
cliStatus,
@ -1014,6 +1052,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
loadDashboardCliStatusBannerCollapsed()
);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const selectedProjectPath = useMemo(
() => resolveProjectPathById(selectedProjectId, projects, repositoryGroups)?.path ?? null,
[projects, repositoryGroups, selectedProjectId]
);
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
@ -1177,9 +1219,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const handleProviderRefresh = useCallback(
(providerId: CliProviderId) => {
void fetchCliProviderStatus(providerId, {
verifyModels: providerId === 'opencode',
});
void fetchCliProviderStatus(providerId);
},
[fetchCliProviderStatus]
);
@ -1257,6 +1297,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
open={manageDialogOpen}
onOpenChange={setManageDialogOpen}
providers={visibleCliProviders}
projectPath={selectedProjectPath}
initialProviderId={
visibleCliProviders.some((provider) => provider.providerId === manageProviderId)
? manageProviderId
@ -1265,11 +1306,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
providerStatusLoading={cliProviderStatusLoading}
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
onSelectBackend={handleProviderBackendChange}
onRefreshProvider={(providerId) =>
fetchCliProviderStatus(providerId, {
verifyModels: providerId === 'opencode',
})
}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
/>
{providerTerminal && renderCliStatus.binaryPath && (

View file

@ -1,8 +1,11 @@
import { useState } from 'react';
import { cn } from '@renderer/lib/utils';
import {
getTeamModelBadgeLabel,
getVisibleTeamProviderModels,
} from '@renderer/utils/teamModelCatalog';
import { ChevronDown, ChevronUp } from 'lucide-react';
import type {
CliProviderId,
@ -48,50 +51,92 @@ export const ProviderModelBadges = ({
models,
modelAvailability,
providerStatus,
collapseAfter,
expandedMaxHeightPx = 200,
}: {
readonly providerId: CliProviderId;
readonly models: string[];
readonly modelAvailability?: CliProviderModelAvailability[];
readonly providerStatus?: Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'> | null;
readonly collapseAfter?: number;
readonly expandedMaxHeightPx?: number;
}): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus);
const displayModelAvailability = providerId === 'opencode' ? undefined : modelAvailability;
const shouldCollapse =
typeof collapseAfter === 'number' && collapseAfter > 0 && visibleModels.length > collapseAfter;
const displayedModels =
shouldCollapse && !expanded ? visibleModels.slice(0, collapseAfter) : visibleModels;
const hiddenCount = shouldCollapse ? visibleModels.length - collapseAfter : 0;
const badgeClassName =
'inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4';
const badgeStyle = {
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
};
const buttonClassName =
'inline-flex items-center gap-1 rounded-full border border-[rgba(59,130,246,0.35)] bg-[rgba(59,130,246,0.12)] px-2 py-px text-[10px] font-medium leading-4 text-[rgb(147,197,253)] transition-colors hover:border-[rgba(59,130,246,0.55)] hover:bg-[rgba(59,130,246,0.18)] hover:text-[rgb(191,219,254)]';
const listClassName = cn('flex flex-wrap gap-1.5', expanded && shouldCollapse ? 'pr-1' : null);
const listStyle =
expanded && shouldCollapse
? ({ maxHeight: expandedMaxHeightPx, overflowY: 'auto' } as const)
: undefined;
const renderModelBadge = (model: string, index: number): React.JSX.Element => {
const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability);
const availabilityReason = getAvailabilityReason(model, displayModelAvailability);
const availabilityChip = getAvailabilityChip(availabilityStatus);
return (
<span
key={`${model}-${index}`}
className={badgeClassName}
style={badgeStyle}
title={availabilityReason ?? availabilityChip ?? undefined}
>
<span>{formatModelBadgeLabel(providerId, model)}</span>
{availabilityChip ? (
<span
className={cn(
'rounded px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em]',
availabilityStatus === 'checking'
? 'bg-[rgba(59,130,246,0.12)] text-[var(--color-text-secondary)]'
: availabilityStatus === 'unavailable'
? 'bg-[rgba(239,68,68,0.12)] text-[rgb(248,113,113)]'
: 'bg-[rgba(245,158,11,0.12)] text-[rgb(251,191,36)]'
)}
>
{availabilityChip}
</span>
) : null}
</span>
);
};
if (!shouldCollapse) {
return <div className="flex flex-wrap gap-1.5">{displayedModels.map(renderModelBadge)}</div>;
}
return (
<div className="flex flex-wrap gap-1.5">
{visibleModels.map((model) => {
const availabilityStatus = getAvailabilityStatus(model, modelAvailability);
const availabilityReason = getAvailabilityReason(model, modelAvailability);
const availabilityChip = getAvailabilityChip(availabilityStatus);
return (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
}}
title={availabilityReason ?? availabilityChip ?? undefined}
>
<span>{formatModelBadgeLabel(providerId, model)}</span>
{availabilityChip ? (
<span
className={cn(
'rounded px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em]',
availabilityStatus === 'checking'
? 'bg-[rgba(59,130,246,0.12)] text-[var(--color-text-secondary)]'
: availabilityStatus === 'unavailable'
? 'bg-[rgba(239,68,68,0.12)] text-[rgb(248,113,113)]'
: 'bg-[rgba(245,158,11,0.12)] text-[rgb(251,191,36)]'
)}
>
{availabilityChip}
</span>
) : null}
</span>
);
})}
<div className="flex flex-col items-start gap-1.5">
<div className={listClassName} style={listStyle}>
{displayedModels.map(renderModelBadge)}
{shouldCollapse && !expanded ? (
<button type="button" className={buttonClassName} onClick={() => setExpanded(true)}>
<ChevronDown className="size-3" />
<span>+{hiddenCount} more</span>
</button>
) : null}
</div>
{shouldCollapse && expanded ? (
<button type="button" className={buttonClassName} onClick={() => setExpanded(false)}>
<ChevronUp className="size-3" />
<span>Hide</span>
</button>
) : null}
</div>
);
};

View file

@ -32,6 +32,7 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { formatBytes } from '@renderer/utils/formatters';
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
import {
@ -122,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode';
return 'OpenCode (75+ LLM providers)';
}
}
@ -185,6 +186,9 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
export const CliStatusSection = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const selectedProjectId = useStore((s) => s.selectedProjectId);
const projects = useStore((s) => s.projects);
const repositoryGroups = useStore((s) => s.repositoryGroups);
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
const updateConfig = useStore((s) => s.updateConfig);
const {
@ -211,6 +215,10 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
const [manageDialogOpen, setManageDialogOpen] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const selectedProjectPath = useMemo(
() => resolveProjectPathById(selectedProjectId, projects, repositoryGroups)?.path ?? null,
[projects, repositoryGroups, selectedProjectId]
);
const loadingCliStatus =
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
@ -645,15 +653,12 @@ export const CliStatusSection = (): React.JSX.Element | null => {
open={manageDialogOpen}
onOpenChange={setManageDialogOpen}
providers={effectiveCliStatus.providers}
projectPath={selectedProjectPath}
initialProviderId={manageProviderId}
providerStatusLoading={cliProviderStatusLoading}
disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading}
onSelectBackend={handleRuntimeBackendChange}
onRefreshProvider={(providerId) =>
fetchCliProviderStatus(providerId, {
verifyModels: providerId === 'opencode',
})
}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
onRequestLogin={(providerId) =>
setProviderTerminal({ providerId, action: 'login' })
}

View file

@ -39,6 +39,13 @@ interface TeamNode {
robots: RobotNode[];
}
interface MessageFlightState {
progress: number;
motionSpeed: number;
bubbleScale: number;
bubbleAlpha: number;
}
interface DepthParticle {
x: number;
y: number;
@ -52,7 +59,6 @@ interface Palette {
isLight: boolean;
centerGlow: string;
teamColors: string[];
teamLineAlpha: number;
robotBody: string;
robotShade: string;
robotEye: string;
@ -63,6 +69,7 @@ interface Palette {
const TAU = Math.PI * 2;
const TEAM_MEMBER_COUNTS = [4, 3, 5] as const;
const TEAM_MEMBER_OFFSETS = [0, 4, 7] as const;
const TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'] as const;
const MAX_DPR = 2;
const avatarCache = new Map<string, HTMLImageElement>();
const avatarLoading = new Map<string, Promise<HTMLImageElement | null>>();
@ -190,15 +197,15 @@ function drawScene(
drawMessages(ctx, teams, sceneTime, palette, mobile);
for (const team of teams) {
drawTeamLinks(ctx, team, palette);
}
for (const team of teams) {
for (const robot of team.robots) {
drawRobot(ctx, robot, sceneTime, palette);
}
}
for (const team of teams) {
drawTeamLabel(ctx, team, palette, mobile);
}
}
function resolvePalette(): Palette {
@ -208,7 +215,6 @@ function resolvePalette(): Palette {
isLight,
centerGlow: '#4f46e5',
teamColors: ['#0369a1', '#047857', '#b45309'],
teamLineAlpha: 0.26,
robotBody: '#eef2ff',
robotShade: '#dbe4ff',
robotEye: '#ffffff',
@ -219,7 +225,6 @@ function resolvePalette(): Palette {
isLight,
centerGlow: '#7c83f7',
teamColors: ['#24a8d8', '#23b488', '#d58a19'],
teamLineAlpha: 0.28,
robotBody: '#0f1724',
robotShade: '#1a2438',
robotEye: '#d8f3ff',
@ -412,22 +417,6 @@ function drawTeamHalo(
ctx.setLineDash([]);
}
function drawTeamLinks(ctx: CanvasRenderingContext2D, team: TeamNode, palette: Palette): void {
const pairs = getTeamConnectionPairs(team.robots.length);
for (const [fromIndex, toIndex] of pairs) {
const from = team.robots[fromIndex];
const to = team.robots[toIndex];
if (!from || !to) continue;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.strokeStyle = withAlpha(team.color, palette.teamLineAlpha);
ctx.lineWidth = 1;
ctx.stroke();
}
}
function drawMessages(
ctx: CanvasRenderingContext2D,
teams: TeamNode[],
@ -459,10 +448,10 @@ function drawLocalMessages(
if (!from || !to) continue;
const raw = positiveModulo(time + team.index * 0.7 + pairIndex * 0.36, period) / period;
applyReceivePulse(to, getReceivePulse(raw, activeWindow));
if (raw > activeWindow) continue;
const progress = easeInOutCubic(raw / activeWindow);
const flightState = getMessageFlightState(raw, activeWindow, 0.12);
if (!flightState) continue;
const curve = makeLocalCurve(from, to, team.center, team.radius * 0.42);
drawMessageFlight(ctx, curve, progress, team.color, time, mobile ? 4.6 : 5.8, palette);
drawMessageFlight(ctx, curve, flightState, team.color, mobile ? 4.6 : 5.8, palette);
}
}
@ -491,15 +480,14 @@ function drawCrossTeamMessages(
const to = toTeam.robots[route.toRobot % toTeam.robots.length];
if (!from || !to) continue;
applyReceivePulse(to, getReceivePulse(raw, activeWindow) * 0.88);
if (raw > activeWindow) continue;
const progress = easeInOutCubic(raw / activeWindow);
const curve = makeStraightCurve(fromTeam.center, toTeam.center);
const flightState = getMessageFlightState(raw, activeWindow, 0.1);
if (!flightState) continue;
const curve = makeStraightCurve(from, to);
drawMessageFlight(
ctx,
curve,
progress,
flightState,
route.accent ? palette.messageAccent : fromTeam.color,
time,
mobile ? 5.2 : 6.8,
palette,
true
@ -510,58 +498,144 @@ function drawCrossTeamMessages(
function drawMessageFlight(
ctx: CanvasRenderingContext2D,
curve: [Point, Point, Point, Point],
progress: number,
state: MessageFlightState,
color: string,
time: number,
size: number,
palette: Palette,
crossTeam = false
): void {
const [p0, p1, p2, p3] = curve;
ctx.save();
if (!crossTeam) {
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
ctx.strokeStyle = withAlpha(color, 0.12);
ctx.lineWidth = 0.85;
ctx.setLineDash([4, 8]);
ctx.lineDashOffset = -time * 34;
ctx.stroke();
ctx.setLineDash([]);
}
for (let i = 7; i >= 1; i--) {
const t = progress - i * 0.036;
if (t <= 0) continue;
const point = cubicPoint(p0, p1, p2, p3, t);
const alpha = (1 - i / 8) * (palette.isLight ? 0.14 : 0.2);
ctx.fillStyle = withAlpha(color, alpha);
ctx.beginPath();
ctx.arc(point.x, point.y, size * (0.18 + i * 0.025), 0, TAU);
ctx.fill();
const progress = state.progress;
const speed = clamp(state.motionSpeed, 0, 1);
if (speed > 0.045) {
drawSpeedTrail(ctx, curve, progress, speed, color, size, palette, crossTeam);
}
const position = cubicPoint(p0, p1, p2, p3, progress);
const tangent = cubicTangent(p0, p1, p2, p3, progress);
const angle = Math.atan2(tangent.y, tangent.x);
drawMessageBubble(ctx, position, angle, size, color, palette, crossTeam);
drawMessageBubble(
ctx,
position,
angle,
size,
color,
palette,
crossTeam,
state.bubbleScale,
state.bubbleAlpha
);
ctx.restore();
}
function drawSpeedTrail(
ctx: CanvasRenderingContext2D,
curve: [Point, Point, Point, Point],
progress: number,
speed: number,
color: string,
size: number,
palette: Palette,
crossTeam: boolean
): void {
const [p0, p1, p2, p3] = curve;
const trailLength = (crossTeam ? 0.26 : 0.21) * (0.24 + speed * 1.08);
const segmentCount = Math.round(9 + speed * 10);
const alphaBase = (palette.isLight ? 0.22 : 0.32) * speed;
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowColor = withAlpha(color, alphaBase * 0.58);
ctx.shadowBlur = size * (0.78 + speed * 1.28);
for (let segment = 0; segment < segmentCount; segment++) {
const startRatio = segment / segmentCount;
const endRatio = (segment + 1) / segmentCount;
const t0 = progress - trailLength * (1 - startRatio);
const t1 = progress - trailLength * (1 - endRatio);
if (t1 <= 0) continue;
const from = cubicPoint(p0, p1, p2, p3, Math.max(0, t0));
const to = cubicPoint(p0, p1, p2, p3, Math.max(0, t1));
const headWeight = endRatio * endRatio;
const width = size * (0.12 + headWeight * 0.48) * (0.9 + speed * 0.45);
const alpha = alphaBase * headWeight;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.strokeStyle = withAlpha(color, alpha * 0.34);
ctx.lineWidth = width * 2.35;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.strokeStyle = withAlpha(color, alpha);
ctx.lineWidth = width;
ctx.stroke();
}
ctx.restore();
}
function getMessageFlightState(
raw: number,
activeWindow: number,
settleWindow: number
): MessageFlightState | null {
if (raw > activeWindow + settleWindow) return null;
if (raw <= activeWindow) {
const phase = raw / activeWindow;
return {
progress: easeInOutCubic(phase),
motionSpeed: getEasedMotionSpeed(phase),
bubbleScale: 1,
bubbleAlpha: 1,
};
}
const settlePhase = (raw - activeWindow) / settleWindow;
const eased = easeOutCubic(settlePhase);
return {
progress: 1,
motionSpeed: 0,
bubbleScale: Math.max(0.12, 1 - eased * 0.88),
bubbleAlpha: Math.max(0, 1 - eased),
};
}
function applyReceivePulse(robot: RobotNode, pulse: number): void {
robot.receivePulse = Math.max(robot.receivePulse, pulse);
}
function getReceivePulse(raw: number, activeWindow: number): number {
const start = activeWindow * 0.78;
const end = Math.min(0.96, activeWindow + 0.11);
const previousStart = activeWindow * 0.78;
const previousEnd = Math.min(0.96, activeWindow + 0.11);
const duration = (previousEnd - previousStart) / 3;
const start = activeWindow - duration * 0.62;
const end = activeWindow + duration * 0.38;
if (raw < start || raw > end) return 0;
const phase = (raw - start) / (end - start);
return Math.sin(phase * Math.PI) * (1 - phase * 0.28);
}
function getEasedMotionSpeed(value: number): number {
const t = clamp(value, 0, 1);
const derivative = t < 0.5 ? 12 * t * t : 12 * (1 - t) * (1 - t);
return clamp(derivative / 3, 0, 1);
}
function easeOutCubic(value: number): number {
const t = clamp(value, 0, 1);
return 1 - Math.pow(1 - t, 3);
}
function drawMessageBubble(
ctx: CanvasRenderingContext2D,
position: Point,
@ -569,13 +643,19 @@ function drawMessageBubble(
size: number,
color: string,
palette: Palette,
crossTeam: boolean
crossTeam: boolean,
scale = 1,
alpha = 1
): void {
if (scale <= 0.02 || alpha <= 0.01) return;
ctx.save();
ctx.translate(position.x, position.y);
ctx.rotate(angle * 0.08);
ctx.shadowColor = withAlpha(color, palette.isLight ? 0.16 : 0.3);
ctx.shadowBlur = crossTeam ? 12 : 8;
ctx.scale(scale, scale);
ctx.globalAlpha = alpha;
ctx.shadowColor = withAlpha(color, (palette.isLight ? 0.16 : 0.3) * alpha);
ctx.shadowBlur = (crossTeam ? 12 : 8) * (0.5 + scale * 0.5);
const width = size * (crossTeam ? 2.28 : 2.06);
const height = size * 1.42;
@ -600,6 +680,44 @@ function drawMessageBubble(
ctx.restore();
}
function drawTeamLabel(
ctx: CanvasRenderingContext2D,
team: TeamNode,
palette: Palette,
mobile: boolean
): void {
const label = TEAM_LABELS[team.index] ?? '';
if (!label) return;
const fontSize = mobile ? 7.5 : 8.5;
const y = team.center.y + team.radius * (mobile ? 1.65 : 1.58);
ctx.save();
ctx.font = `600 ${fontSize}px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const metrics = ctx.measureText(label);
const paddingX = mobile ? 4 : 5;
const paddingY = mobile ? 2 : 2.5;
const width = metrics.width + paddingX * 2;
const height = fontSize + paddingY * 2;
const x = team.center.x - width / 2;
const rectY = y - height / 2;
roundRectPath(ctx, x, rectY, width, height, height / 2);
ctx.fillStyle = withAlpha(palette.isLight ? '#ffffff' : '#090a14', palette.isLight ? 0.36 : 0.24);
ctx.fill();
ctx.strokeStyle = withAlpha(team.color, palette.isLight ? 0.18 : 0.24);
ctx.lineWidth = 0.75;
ctx.stroke();
ctx.shadowColor = withAlpha(team.color, palette.isLight ? 0.12 : 0.22);
ctx.shadowBlur = mobile ? 4 : 6;
ctx.fillStyle = withAlpha(palette.isLight ? '#3f3f46' : '#e4e4e7', palette.isLight ? 0.58 : 0.66);
ctx.fillText(label, team.center.x, y + 0.2);
ctx.restore();
}
function drawRobot(
ctx: CanvasRenderingContext2D,
robot: RobotNode,
@ -705,24 +823,6 @@ function drawAvatarFallback(
ctx.fill();
}
function getTeamConnectionPairs(memberCount: number): [number, number][] {
if (memberCount <= 3) {
return [
[0, 1],
[1, 2],
[2, 0],
];
}
const pairs: [number, number][] = [];
for (let index = 0; index < memberCount; index++) {
pairs.push([index, (index + 1) % memberCount]);
}
if (memberCount >= 4) pairs.push([0, 2]);
if (memberCount >= 5) pairs.push([1, 4]);
return pairs;
}
function getLocalMessagePairs(teamIndex: number, memberCount: number): [number, number][] {
const routeMap: [number, number][][] = [
[

View file

@ -1231,6 +1231,7 @@ export const TeamDetailView = ({
sendingMessage,
sendMessageError,
sendMessageWarning,
sendMessageDebugDetails,
lastSendMessageResult,
reviewActionError,
addMember,
@ -1282,6 +1283,7 @@ export const TeamDetailView = ({
sendingMessage: s.sendingMessage,
sendMessageError: s.sendMessageError,
sendMessageWarning: s.sendMessageWarning,
sendMessageDebugDetails: s.sendMessageDebugDetails,
lastSendMessageResult: s.lastSendMessageResult,
reviewActionError: s.reviewActionError,
addMember: s.addMember,
@ -2947,6 +2949,7 @@ export const TeamDetailView = ({
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={sendMessageWarning}
sendDebugDetails={sendMessageDebugDetails}
lastResult={lastSendMessageResult}
onSend={async (member, text, summary, attachments, actionMode, taskRefs) => {
const sentAtMs = Date.now();

View file

@ -124,6 +124,11 @@ import {
updateProviderCheck,
} from './ProvisioningProviderStatusList';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
import {
analyzeTeammateRuntimeCompatibility,
useTmuxRuntimeReadiness,
} from './teammateRuntimeCompatibility';
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
import { computeEffectiveTeamModel } from './TeamModelSelector';
import { getNextSuggestedTeamName } from './teamNameSets';
@ -341,6 +346,7 @@ export const CreateTeamDialog = ({
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const openDashboard = useStore((s) => s.openDashboard);
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
@ -543,6 +549,7 @@ export const CreateTeamDialog = ({
() => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
[members, syncModelsWithLead]
);
const tmuxRuntime = useTmuxRuntimeReadiness(open && canCreate);
const selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
if (!multimodelEnabled) {
@ -582,6 +589,14 @@ export const CreateTeamDialog = ({
),
[effectiveCliStatus?.providers]
);
const selectedProviderBackendId = useMemo(
() =>
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
),
[runtimeProviderStatusById, selectedProviderId]
);
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
const prepareModelResultsCacheRef = useRef(
@ -1136,6 +1151,31 @@ export const CreateTeamDialog = ({
),
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const teammateRuntimeCompatibility = useMemo(
() =>
analyzeTeammateRuntimeCompatibility({
leadProviderId: selectedProviderId,
leadProviderBackendId: selectedProviderBackendId,
members: effectiveMemberDrafts,
soloTeam: soloTeam || !canCreate,
extraCliArgs: launchTeam ? customArgs : undefined,
tmuxStatus: tmuxRuntime.status,
tmuxStatusLoading: tmuxRuntime.loading,
tmuxStatusError: tmuxRuntime.error,
}),
[
customArgs,
effectiveMemberDrafts,
launchTeam,
canCreate,
selectedProviderBackendId,
selectedProviderId,
soloTeam,
tmuxRuntime.error,
tmuxRuntime.loading,
tmuxRuntime.status,
]
);
const anthropicRuntimeSelection = useMemo(
() =>
selectedProviderId === 'anthropic'
@ -1279,11 +1319,7 @@ export const CreateTeamDialog = ({
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
providerId: selectedProviderId,
providerBackendId:
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ?? undefined,
providerBackendId: selectedProviderBackendId ?? undefined,
model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
fastMode:
@ -1304,7 +1340,7 @@ export const CreateTeamDialog = ({
effectiveCwd,
prompt,
selectedProviderId,
runtimeProviderStatusById,
selectedProviderBackendId,
effectiveModel,
selectedEffort,
selectedFastMode,
@ -1388,7 +1424,8 @@ export const CreateTeamDialog = ({
isNameTakenByExistingTeam ||
isNameProvisioning ||
!requestValidation.valid ||
!!modelValidationError;
!!modelValidationError ||
teammateRuntimeCompatibility.blocksSubmission;
const internalArgs = useMemo(() => {
const args: string[] = [];
@ -1528,6 +1565,10 @@ export const CreateTeamDialog = ({
setLocalError(modelValidationError);
return;
}
if (teammateRuntimeCompatibility.blocksSubmission) {
setLocalError(teammateRuntimeCompatibility.message);
return;
}
setFieldErrors({});
setLocalError(null);
setIsSubmitting(true);
@ -1661,6 +1702,14 @@ export const CreateTeamDialog = ({
</p>
) : null}
<TeammateRuntimeCompatibilityNotice
analysis={teammateRuntimeCompatibility}
onOpenDashboard={() => {
onClose();
openDashboard();
}}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-name">Team name</Label>
@ -1735,6 +1784,7 @@ export const CreateTeamDialog = ({
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
disableGeminiOption={isGeminiUiFrozen()}
leadModelIssueText={leadModelIssueText}
memberWarningById={teammateRuntimeCompatibility.memberWarningById}
memberModelIssueById={memberModelIssueById}
headerTop={
<div className="flex items-center gap-2">

View file

@ -128,6 +128,11 @@ import {
shouldHideProvisioningProviderStatusList,
updateProviderCheck,
} from './ProvisioningProviderStatusList';
import {
analyzeTeammateRuntimeCompatibility,
useTmuxRuntimeReadiness,
} from './teammateRuntimeCompatibility';
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
import {
computeEffectiveTeamModel,
formatTeamModelSummary,
@ -451,6 +456,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
[membersDrafts, syncModelsWithLead]
);
const tmuxRuntime = useTmuxRuntimeReadiness(open && isLaunchMode);
const selectedMemberProviders = useMemo<TeamProviderId[]>(
() =>
!multimodelEnabled
@ -842,6 +848,46 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) ?? '',
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const selectedProviderBackendId = useMemo(
() =>
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ??
migrateProviderBackendId(
selectedProviderId,
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined,
[
previousLaunchParams?.providerBackendId,
runtimeProviderStatusById,
savedLaunchProviderBackendId,
selectedProviderId,
]
);
const teammateRuntimeCompatibility = useMemo(
() =>
analyzeTeammateRuntimeCompatibility({
leadProviderId: selectedProviderId,
leadProviderBackendId: selectedProviderBackendId,
members: isLaunchMode ? effectiveMemberDrafts : [],
extraCliArgs: isLaunchMode ? customArgs : undefined,
tmuxStatus: tmuxRuntime.status,
tmuxStatusLoading: tmuxRuntime.loading,
tmuxStatusError: tmuxRuntime.error,
}),
[
customArgs,
effectiveMemberDrafts,
isLaunchMode,
selectedProviderBackendId,
selectedProviderId,
tmuxRuntime.error,
tmuxRuntime.loading,
tmuxRuntime.status,
]
);
const anthropicRuntimeSelection = useMemo(
() =>
selectedProviderId === 'anthropic'
@ -1218,6 +1264,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}
return warnings;
}, [effectiveMemberDrafts, runtimeChangeNoteByKey]);
const combinedMemberRuntimeWarningById = useMemo(() => {
const warnings: Record<string, string> = { ...memberRuntimeWarningById };
for (const [memberId, warning] of Object.entries(
teammateRuntimeCompatibility.memberWarningById
)) {
warnings[memberId] = warnings[memberId] ? `${warnings[memberId]} ${warning}` : warning;
}
return warnings;
}, [memberRuntimeWarningById, teammateRuntimeCompatibility.memberWarningById]);
// ---------------------------------------------------------------------------
// Launch-only effects
@ -1823,6 +1878,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setLocalError(modelValidationError);
return;
}
if (isLaunchMode && teammateRuntimeCompatibility.blocksSubmission) {
setLocalError(teammateRuntimeCompatibility.message);
return;
}
if (isLaunchMode && !effectiveCwd) {
setLocalError('Select working directory (cwd)');
return;
@ -1862,10 +1921,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ??
migrateProviderBackendId(
selectedProviderId,
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
selectedProviderBackendId ??
undefined,
model: computeEffectiveTeamModel(
selectedModel,
@ -1902,10 +1958,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ??
migrateProviderBackendId(
selectedProviderId,
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
selectedProviderBackendId ??
undefined;
const scheduleModel = computeEffectiveTeamModel(
selectedModel,
@ -2000,7 +2053,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
validationErrors.length > 0 ||
!!modelValidationError ||
hasInvalidLaunchMemberNames ||
hasDuplicateLaunchMemberNames
hasDuplicateLaunchMemberNames ||
teammateRuntimeCompatibility.blocksSubmission
: isSubmitting || validationErrors.length > 0 || !!modelValidationError;
// ---------------------------------------------------------------------------
@ -2130,6 +2184,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div>
) : null}
{isLaunchMode ? (
<TeammateRuntimeCompatibilityNotice
analysis={teammateRuntimeCompatibility}
onOpenDashboard={() => {
closeDialog();
openDashboard();
}}
/>
) : null}
<div className="space-y-4">
{/*
Schedule-only: Team selector (standalone mode)
@ -2360,7 +2424,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
leadWarningText={leadRuntimeWarningText}
memberWarningById={memberRuntimeWarningById}
memberWarningById={combinedMemberRuntimeWarningById}
leadModelIssueText={leadModelIssueText}
memberModelIssueById={memberModelIssueById}
softDeleteMembers

View file

@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector';
import { OpenCodeDeliveryWarning } from '@renderer/components/team/messages/OpenCodeDeliveryWarning';
import {
Dialog,
DialogContent,
@ -26,6 +27,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import {
extractTaskRefsFromText,
stripEncodedTaskReferenceMetadata,
@ -65,6 +67,7 @@ interface SendMessageDialogProps {
sending: boolean;
sendError: string | null;
sendWarning?: string | null;
sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
lastResult: SendMessageResult | null;
onSend: (
member: string,
@ -93,6 +96,7 @@ export const SendMessageDialog = ({
sending,
sendError,
sendWarning,
sendDebugDetails,
lastResult,
onSend,
onClose,
@ -275,7 +279,13 @@ export const SendMessageDialog = ({
taskRefs
)
)
.then(() => {
.then((result) => {
if (
result?.runtimeDelivery?.attempted === true &&
result.runtimeDelivery.delivered === false
) {
return;
}
textDraft.clearDraft();
chipDraft.clearChipDraft();
clearAttachments();
@ -542,10 +552,10 @@ export const SendMessageDialog = ({
{sendError}
</span>
) : sendWarning ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
<AlertCircle size={10} className="shrink-0" />
{sendWarning}
</span>
<OpenCodeDeliveryWarning
warning={sendWarning}
debugDetails={sendDebugDetails}
/>
) : null}
{remaining < 200 ? (
<span

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import {
@ -17,6 +18,11 @@ import {
GEMINI_UI_DISABLED_REASON,
isGeminiUiFrozen,
} from '@renderer/utils/geminiUiFreeze';
import {
compareOpenCodeTeamModelRecommendations,
getOpenCodeTeamModelRecommendation,
isOpenCodeTeamModelRecommended,
} from '@renderer/utils/openCodeModelRecommendations';
import {
getAvailableTeamProviderModelOptions,
getTeamModelUiDisabledReason,
@ -37,7 +43,7 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { isTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, Info } from 'lucide-react';
import { AlertTriangle, Info, Star } from 'lucide-react';
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
@ -59,8 +65,9 @@ const PROVIDERS: ProviderDef[] = [
];
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
export const OPENCODE_TEAM_LEAD_DISABLED_REASON = 'OpenCode is not available for team lead.';
export const OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL = 'not teamlead';
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 function getTeamModelLabel(model: string): string {
return getCatalogTeamModelLabel(model) ?? model;
@ -161,6 +168,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
modelIssueReasonByValue,
}) => {
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const [recommendedOnly, setRecommendedOnly] = useState(false);
const effectiveProviderId =
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
@ -301,6 +309,47 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
}, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
const hasRecommendedOpenCodeModels = useMemo(
() =>
effectiveProviderId === 'opencode' &&
modelOptions.some((option) => isOpenCodeTeamModelRecommended(option.value)),
[effectiveProviderId, modelOptions]
);
useEffect(() => {
if (effectiveProviderId !== 'opencode' || !hasRecommendedOpenCodeModels) {
setRecommendedOnly(false);
}
}, [effectiveProviderId, hasRecommendedOpenCodeModels]);
const visibleModelOptions = useMemo(() => {
if (effectiveProviderId !== 'opencode') {
return modelOptions;
}
const concreteOptions = modelOptions
.filter((option) => option.value.trim().length > 0)
.map((option, index) => ({ option, index }))
.filter(({ option }) => !recommendedOnly || isOpenCodeTeamModelRecommended(option.value))
.sort((left, right) => {
const recommendationOrder = compareOpenCodeTeamModelRecommendations(
left.option.value,
right.option.value
);
return recommendationOrder || left.index - right.index;
})
.map(({ option }) => option);
if (recommendedOnly) {
return concreteOptions;
}
return [
...modelOptions.filter((option) => option.value.trim().length === 0),
...concreteOptions,
];
}, [effectiveProviderId, modelOptions, recommendedOnly]);
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
return (
<div className="mb-5">
@ -384,11 +433,34 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
list is syncing.
</p>
) : null}
{hasRecommendedOpenCodeModels ? (
<div className="mb-2 flex w-fit items-center gap-2">
<Checkbox
id="opencode-team-model-recommended-only"
checked={recommendedOnly}
onCheckedChange={(checked) => setRecommendedOnly(checked === true)}
className="size-3.5"
/>
<Label
htmlFor="opencode-team-model-recommended-only"
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
>
Recommended only
</Label>
</div>
) : null}
<div
className="grid gap-1.5 rounded-md bg-[var(--color-surface)]"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
data-testid="team-model-selector-model-grid"
className={cn(
'grid gap-1.5 rounded-md bg-[var(--color-surface)]',
shouldConstrainModelListHeight && 'overflow-y-auto pr-1'
)}
style={{
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
maxHeight: shouldConstrainModelListHeight ? 400 : undefined,
}}
>
{modelOptions.map((opt) =>
{visibleModelOptions.map((opt) =>
(() => {
const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId,
@ -414,6 +486,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
effectiveProviderId === 'opencode' && opt.value !== ''
? opt.badgeLabel?.trim() || null
: null;
const modelRecommendation =
effectiveProviderId === 'opencode'
? getOpenCodeTeamModelRecommendation(opt.value)
: null;
return (
<button
@ -456,6 +532,29 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
{sourceBadgeLabel}
</span>
) : null}
{modelRecommendation ? (
<span
className={cn(
'inline-flex items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold',
modelRecommendation.level === 'recommended'
? 'bg-emerald-300/12 border-emerald-300/35 text-emerald-200'
: modelRecommendation.level === 'recommended-with-limits'
? 'bg-amber-300/12 border-amber-300/35 text-amber-200'
: modelRecommendation.level === 'unavailable-in-opencode'
? 'border-slate-300/30 bg-slate-400/10 text-slate-200'
: 'border-red-300/35 bg-red-400/10 text-red-200'
)}
title={modelRecommendation.reason}
>
{modelRecommendation.level === 'not-recommended' ||
modelRecommendation.level === 'unavailable-in-opencode' ? (
<AlertTriangle className="size-3 shrink-0" />
) : (
<Star className="size-3 shrink-0 fill-current" />
)}
<span>{modelRecommendation.label}</span>
</span>
) : null}
{opt.value === '' && (
<span className="flex items-center justify-center gap-1">
<TooltipProvider delayDuration={200}>
@ -527,6 +626,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
})()
)}
</div>
{effectiveProviderId === 'opencode' && visibleModelOptions.length === 0 ? (
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
No recommended OpenCode models are available in the current runtime list.
</div>
) : null}
</div>
</div>
</div>

View file

@ -0,0 +1,60 @@
import React from 'react';
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle, Info } from 'lucide-react';
import type { TeammateRuntimeCompatibility } from './teammateRuntimeCompatibility';
interface TeammateRuntimeCompatibilityNoticeProps {
readonly analysis: TeammateRuntimeCompatibility;
readonly onOpenDashboard?: () => void;
}
export const TeammateRuntimeCompatibilityNotice = ({
analysis,
onOpenDashboard,
}: TeammateRuntimeCompatibilityNoticeProps): React.JSX.Element | null => {
if (!analysis.visible) {
return null;
}
const Icon = analysis.checking ? Info : AlertTriangle;
return (
<div
className="rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<Icon className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">{analysis.title}</p>
<p className="opacity-80">{analysis.message}</p>
{analysis.tmuxDetail ? (
<p className="text-[11px] opacity-70">{analysis.tmuxDetail}</p>
) : null}
{analysis.details.length > 0 ? (
<ul className="list-disc space-y-0.5 pl-4 text-[11px] opacity-80">
{analysis.details.map((detail) => (
<li key={detail}>{detail}</li>
))}
</ul>
) : null}
{onOpenDashboard ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1 h-7 px-2 text-[11px]"
onClick={onOpenDashboard}
>
Open Dashboard
</Button>
) : null}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,357 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { TmuxStatus } from '@features/tmux-installer/contracts';
import type { TeamProviderId } from '@shared/types';
type TeammateRuntimeIssueReason =
| 'mixed-provider'
| 'codex-native-runtime'
| 'explicit-tmux-mode'
| 'opencode-led-mixed-unsupported';
interface RuntimeMemberInput {
id?: string;
name: string;
providerId?: TeamProviderId;
providerBackendId?: string | null;
removedAt?: number | string | null;
}
interface RuntimeIssue {
reason: TeammateRuntimeIssueReason;
memberId?: string;
memberName?: string;
memberProviderId?: TeamProviderId;
}
export interface TeammateRuntimeCompatibility {
visible: boolean;
blocksSubmission: boolean;
checking: boolean;
title: string;
message: string;
details: string[];
tmuxDetail: string | null;
memberWarningById: Record<string, string>;
}
interface AnalyzeTeammateRuntimeCompatibilityInput {
leadProviderId: TeamProviderId;
leadProviderBackendId?: string | null;
members: readonly RuntimeMemberInput[];
soloTeam?: boolean;
extraCliArgs?: string;
tmuxStatus: TmuxStatus | null;
tmuxStatusLoading: boolean;
tmuxStatusError: string | null;
}
export interface TmuxRuntimeReadiness {
status: TmuxStatus | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
const PROVIDER_LABELS: Record<TeamProviderId, string> = {
anthropic: 'Anthropic',
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
};
function getProviderLabel(providerId: TeamProviderId): string {
return PROVIDER_LABELS[providerId] ?? providerId;
}
function getExplicitTeammateMode(
rawExtraCliArgs: string | undefined
): 'auto' | 'tmux' | 'in-process' | null {
const tokens = parseCliArgs(rawExtraCliArgs);
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
// eslint-disable-next-line security/detect-possible-timing-attacks -- parsing UI CLI flags, not comparing secrets
if (token === '--teammate-mode') {
const value = tokens[index + 1];
return value === 'auto' || value === 'tmux' || value === 'in-process' ? value : null;
}
if (token.startsWith('--teammate-mode=')) {
const value = token.slice('--teammate-mode='.length);
return value === 'auto' || value === 'tmux' || value === 'in-process' ? value : null;
}
}
return null;
}
function isTmuxRuntimeReady(status: TmuxStatus | null): boolean {
return status?.effective.available === true && status.effective.runtimeReady === true;
}
function getTmuxDetail(status: TmuxStatus | null, error: string | null): string | null {
if (error) {
return error;
}
return status?.effective.detail ?? status?.wsl?.statusDetail ?? status?.error ?? null;
}
function summarizeIssueNames(
issues: readonly RuntimeIssue[],
reason: TeammateRuntimeIssueReason
): string {
const names = issues
.filter((issue) => issue.reason === reason)
.map((issue) => issue.memberName)
.filter((name): name is string => Boolean(name));
if (names.length === 0) {
return '';
}
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} and ${names.length - 3} more`;
}
export function analyzeTeammateRuntimeCompatibility({
leadProviderId,
leadProviderBackendId,
members,
soloTeam = false,
extraCliArgs,
tmuxStatus,
tmuxStatusLoading,
tmuxStatusError,
}: AnalyzeTeammateRuntimeCompatibilityInput): TeammateRuntimeCompatibility {
const activeMembers = soloTeam
? []
: members.filter((member) => member.removedAt == null && member.name.trim().length > 0);
const explicitTeammateMode = getExplicitTeammateMode(extraCliArgs);
const leadBackendId = migrateProviderBackendId(leadProviderId, leadProviderBackendId);
const issues: RuntimeIssue[] = [];
if (explicitTeammateMode === 'tmux' && activeMembers.length > 0) {
issues.push({ reason: 'explicit-tmux-mode' });
}
for (const member of activeMembers) {
const memberProviderId = normalizeOptionalTeamProviderId(member.providerId) ?? leadProviderId;
const memberName = member.name.trim();
if (memberProviderId !== leadProviderId) {
if (leadProviderId !== 'opencode' && memberProviderId === 'opencode') {
continue;
}
if (leadProviderId === 'opencode') {
issues.push({
reason: 'opencode-led-mixed-unsupported',
memberId: member.id,
memberName,
memberProviderId,
});
continue;
}
issues.push({
reason: 'mixed-provider',
memberId: member.id,
memberName,
memberProviderId,
});
continue;
}
const memberBackendId = migrateProviderBackendId(
memberProviderId,
member.providerBackendId ?? leadBackendId
);
if (memberProviderId === 'codex' && memberBackendId === 'codex-native') {
issues.push({
reason: 'codex-native-runtime',
memberId: member.id,
memberName,
memberProviderId,
});
}
}
if (issues.length === 0) {
return {
visible: false,
blocksSubmission: false,
checking: false,
title: '',
message: '',
details: [],
tmuxDetail: null,
memberWarningById: {},
};
}
const tmuxReady = isTmuxRuntimeReady(tmuxStatus);
const hasOpenCodeLeadMixedUnsupported = issues.some(
(issue) => issue.reason === 'opencode-led-mixed-unsupported'
);
if (tmuxReady && !hasOpenCodeLeadMixedUnsupported) {
return {
visible: false,
blocksSubmission: false,
checking: false,
title: '',
message: '',
details: [],
tmuxDetail: null,
memberWarningById: {},
};
}
const checking = !hasOpenCodeLeadMixedUnsupported && tmuxStatusLoading && !tmuxStatus;
const blocksSubmission = true;
const hasMixedProviders = issues.some((issue) => issue.reason === 'mixed-provider');
const hasCodexNative = issues.some((issue) => issue.reason === 'codex-native-runtime');
const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode');
const details: string[] = [];
const memberWarningById: Record<string, string> = {};
if (hasMixedProviders) {
const names = summarizeIssueNames(issues, 'mixed-provider');
details.push(
names
? `Mixed providers: ${names} use a different provider than the ${getProviderLabel(leadProviderId)} lead.`
: 'Mixed providers require teammate processes.'
);
}
if (hasOpenCodeLeadMixedUnsupported) {
const names = summarizeIssueNames(issues, 'opencode-led-mixed-unsupported');
details.push(
names
? `OpenCode-led mixed team: ${names} use a non-OpenCode provider.`
: 'OpenCode-led mixed teams are not supported in this phase.'
);
}
if (hasCodexNative) {
const names = summarizeIssueNames(issues, 'codex-native-runtime');
details.push(
names
? `Codex native teammates: ${names} must run through separate Codex processes.`
: 'Codex native teammates must run through separate Codex processes.'
);
}
if (hasExplicitTmux) {
details.push('Custom CLI args force --teammate-mode tmux.');
}
if (hasOpenCodeLeadMixedUnsupported) {
details.push(
'Fix: keep the team lead on Anthropic, Codex, or Gemini when mixing OpenCode with other providers.'
);
} else {
details.push(
hasCodexNative && !hasMixedProviders
? 'Fix: install tmux/WSL tmux, use Solo team, or choose a same-provider runtime that supports in-process teammates.'
: 'Fix: install tmux/WSL tmux, use Solo team, or keep every teammate on the same non-Codex-native provider as the lead.'
);
}
for (const issue of issues) {
if (!issue.memberId || !issue.memberName) {
continue;
}
if (issue.reason === 'mixed-provider') {
memberWarningById[issue.memberId] =
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
`Without tmux, teammates must use the same provider as the ${getProviderLabel(leadProviderId)} lead.`;
} else if (issue.reason === 'codex-native-runtime') {
memberWarningById[issue.memberId] =
`${issue.memberName} uses Codex native. Codex native teammates require a separate process, which currently needs tmux.`;
} else if (issue.reason === 'opencode-led-mixed-unsupported') {
memberWarningById[issue.memberId] =
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
'OpenCode cannot be the team lead when mixing providers in this phase.';
}
}
return {
visible: blocksSubmission || checking,
blocksSubmission,
checking,
title: checking
? 'Checking tmux runtime for teammate support'
: hasOpenCodeLeadMixedUnsupported
? 'OpenCode cannot lead mixed-provider teams'
: hasCodexNative && !hasMixedProviders
? 'Codex teammates need tmux before they can run'
: 'This team needs tmux before it can run',
message: checking
? 'Some teammates require separate processes. 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.'
: hasCodexNative && !hasMixedProviders
? 'The Codex lead can run without tmux, but Codex native teammates cannot use the in-process teammate adapter. They must start as separate Codex processes, and this path currently needs tmux.'
: 'tmux is not ready on this machine. Same-provider in-process teammates can run without tmux, but this team has teammates that require separate processes.',
details,
tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError),
memberWarningById,
};
}
export function useTmuxRuntimeReadiness(enabled: boolean): TmuxRuntimeReadiness {
const [status, setStatus] = useState<TmuxStatus | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!enabled) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
if (typeof api.tmux?.getStatus !== 'function') {
throw new Error('tmux status API is not available. Restart the app.');
}
const nextStatus = await api.tmux.getStatus();
setStatus(nextStatus);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux status');
setStatus(null);
} finally {
setLoading(false);
}
}, [enabled]);
useEffect(() => {
if (!enabled) {
setStatus(null);
setError(null);
setLoading(false);
return;
}
void refresh();
}, [enabled, refresh]);
useEffect(() => {
if (!enabled) {
return undefined;
}
if (typeof api.tmux?.onProgress !== 'function') {
return undefined;
}
return api.tmux.onProgress(() => {
void refresh();
});
}, [enabled, refresh]);
const effectiveLoading = enabled && (loading || (!status && !error));
return useMemo(
() => ({
status,
loading: effectiveLoading,
error,
refresh,
}),
[effectiveLoading, error, refresh, status]
);
}

View file

@ -20,8 +20,9 @@ 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: 'not teamlead',
OPENCODE_TEAM_LEAD_DISABLED_REASON: 'OpenCode is not available for team lead.',
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

@ -5,6 +5,7 @@ import { AttachmentPreviewList } from '@renderer/components/team/attachments/Att
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector';
import { OpenCodeDeliveryWarning } from '@renderer/components/team/messages/OpenCodeDeliveryWarning';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -18,6 +19,7 @@ import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import { nameColorSet } from '@renderer/utils/projectColor';
import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
@ -52,6 +54,7 @@ interface MessageComposerProps {
sending: boolean;
sendError: string | null;
sendWarning?: string | null;
sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
lastResult?: SendMessageResult | null;
/** Ref to the underlying textarea element for external focus management. */
textareaRef?: React.Ref<HTMLTextAreaElement>;
@ -80,6 +83,7 @@ export const MessageComposer = ({
sending,
sendError,
sendWarning,
sendDebugDetails,
lastResult,
textareaRef: externalTextareaRef,
onSend,
@ -382,11 +386,11 @@ export const MessageComposer = ({
useEffect(() => {
if (!sending && pendingSendRef.current) {
pendingSendRef.current = false;
if (!sendError) {
if (!sendError && sendDebugDetails?.delivered !== false) {
draft.clearDraft();
}
}
}, [sending, sendError, draft]);
}, [sending, sendError, sendDebugDetails, draft]);
const { addFiles: draftAddFiles } = draft;
const handleFileInputChange = useCallback(
@ -485,10 +489,7 @@ export const MessageComposer = ({
{sendError}
</span>
) : sendWarning ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
<AlertCircle size={10} className="shrink-0" />
{sendWarning}
</span>
<OpenCodeDeliveryWarning warning={sendWarning} debugDetails={sendDebugDetails} />
) : lastResult?.deduplicated ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
<Check size={10} className="shrink-0" />

View file

@ -191,6 +191,7 @@ export const MessagesPanel = memo(function MessagesPanel({
sendingMessage,
sendMessageError,
sendMessageWarning,
sendMessageDebugDetails,
lastSendMessageResult,
teams,
openTeamTab,
@ -205,6 +206,7 @@ export const MessagesPanel = memo(function MessagesPanel({
sendingMessage: s.sendingMessage,
sendMessageError: s.sendMessageError,
sendMessageWarning: s.sendMessageWarning,
sendMessageDebugDetails: s.sendMessageDebugDetails,
lastSendMessageResult: s.lastSendMessageResult,
teams: s.teams,
openTeamTab: s.openTeamTab,
@ -687,6 +689,7 @@ export const MessagesPanel = memo(function MessagesPanel({
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={sendMessageWarning}
sendDebugDetails={sendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
@ -873,6 +876,7 @@ export const MessagesPanel = memo(function MessagesPanel({
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={sendMessageWarning}
sendDebugDetails={sendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
@ -1158,6 +1162,7 @@ export const MessagesPanel = memo(function MessagesPanel({
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={sendMessageWarning}
sendDebugDetails={sendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}

View file

@ -0,0 +1,117 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
formatOpenCodeRuntimeDeliveryDebugDetails,
type OpenCodeRuntimeDeliveryDebugDetails,
} from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import { AlertCircle } from 'lucide-react';
import type { JSX } from 'react';
interface OpenCodeDeliveryWarningProps {
warning: string | null;
debugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
}
export function OpenCodeDeliveryWarning({
warning,
debugDetails,
}: OpenCodeDeliveryWarningProps): JSX.Element | null {
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}`;
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const mountedRef = useRef(true);
const copiedResetTimerRef = useRef<number | null>(null);
const expanded = expandedKey === detailsKey;
const copied = copiedKey === detailsKey;
const copyText = useMemo(
() => (debugDetails ? formatOpenCodeRuntimeDeliveryDebugDetails(debugDetails) : ''),
[debugDetails]
);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (copiedResetTimerRef.current !== null) {
window.clearTimeout(copiedResetTimerRef.current);
}
};
}, []);
if (!warning) return null;
const handleCopy = async (): Promise<void> => {
if (!copyText || !navigator.clipboard?.writeText) return;
try {
await navigator.clipboard.writeText(copyText);
} catch {
return;
}
if (!mountedRef.current) return;
setCopiedKey(detailsKey);
if (copiedResetTimerRef.current !== null) {
window.clearTimeout(copiedResetTimerRef.current);
}
copiedResetTimerRef.current = window.setTimeout(() => {
copiedResetTimerRef.current = null;
if (mountedRef.current) {
setCopiedKey(null);
}
}, 1500);
};
return (
<span className="relative inline-flex flex-col items-start gap-1">
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
<AlertCircle size={10} className="shrink-0" />
<span>{warning}</span>
{debugDetails ? (
<button
type="button"
className="ml-1 rounded px-1 text-[10px] font-medium text-amber-200 underline decoration-amber-300/50 underline-offset-2 hover:text-amber-100"
aria-expanded={expanded}
onClick={() =>
setExpandedKey((currentKey) => (currentKey === detailsKey ? null : detailsKey))
}
>
Details
</button>
) : null}
</span>
{expanded && debugDetails ? (
<span className="z-10 block max-w-[min(34rem,calc(100vw-3rem))] rounded border border-amber-500/20 bg-[var(--color-bg-primary)] p-2 text-left text-[10px] text-[var(--color-text-secondary)] shadow-xl">
<span className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<span className="text-[var(--color-text-muted)]">messageId</span>
<span className="break-all">{debugDetails.messageId}</span>
<span className="text-[var(--color-text-muted)]">providerId</span>
<span>{debugDetails.providerId}</span>
<span className="text-[var(--color-text-muted)]">delivered</span>
<span>{String(debugDetails.delivered)}</span>
<span className="text-[var(--color-text-muted)]">responsePending</span>
<span>{String(debugDetails.responsePending)}</span>
<span className="text-[var(--color-text-muted)]">responseState</span>
<span>{debugDetails.responseState ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">ledgerStatus</span>
<span>{debugDetails.ledgerStatus ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">acceptanceUnknown</span>
<span>{String(debugDetails.acceptanceUnknown)}</span>
<span className="text-[var(--color-text-muted)]">reason</span>
<span>{debugDetails.reason ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">diagnostics</span>
<span>
{debugDetails.diagnostics.length ? debugDetails.diagnostics.join('; ') : '[]'}
</span>
</span>
<button
type="button"
className="mt-2 rounded border border-amber-500/20 px-2 py-1 text-[10px] text-amber-200 hover:border-amber-400/40 hover:text-amber-100"
onClick={() => void handleCopy()}
>
{copied ? 'Copied' : 'Copy debug details'}
</button>
</span>
) : null}
</span>
);
}

View file

@ -19,6 +19,7 @@ import type { QuickOpenFile } from '@shared/types/editor';
const MAX_FILE_SUGGESTIONS = 8;
const MAX_FOLDER_SUGGESTIONS = 5;
const MENTION_PATH_QUOTE_NEEDED = /[\s,)}\]"']/;
export interface UseFileSuggestionsResult {
suggestions: MentionSuggestion[];
@ -35,6 +36,19 @@ interface DerivedFolder {
absolutePath: string;
}
export function formatFileMentionPath(relativePath: string): string {
if (!MENTION_PATH_QUOTE_NEEDED.test(relativePath)) {
return relativePath;
}
if (!relativePath.includes('"')) {
return `"${relativePath}"`;
}
if (!relativePath.includes("'")) {
return `'${relativePath}'`;
}
return `"${relativePath.replace(/"/g, '')}"`;
}
/**
* Extracts unique directories from a list of file paths.
* Returns directories sorted by depth (shallower first), then alphabetically.
@ -94,6 +108,7 @@ export function filterFileSuggestions(files: QuickOpenFile[], query: string): Me
type: 'file',
filePath: f.path,
relativePath: f.relativePath,
insertText: formatFileMentionPath(f.relativePath),
});
}
}
@ -127,6 +142,7 @@ export function filterFolderSuggestions(
type: 'folder',
filePath: f.absolutePath,
relativePath: f.relativePath,
insertText: formatFileMentionPath(f.relativePath),
});
}
}

View file

@ -16,6 +16,22 @@ import { useStore } from '../store';
const logger = createLogger('Hook:KeyboardShortcuts');
export function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
const editableElement = target.closest(
'input, textarea, select, [role="textbox"], [contenteditable]'
);
if (!editableElement) {
return false;
}
const contentEditable = editableElement.getAttribute('contenteditable');
return contentEditable == null || contentEditable.toLowerCase() !== 'false';
}
export function useKeyboardShortcuts(): void {
const {
openTabs,
@ -77,6 +93,10 @@ export function useKeyboardShortcuts(): void {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent): void {
if (isEditableShortcutTarget(event.target)) {
return;
}
// Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed
const isMod = event.metaKey || event.ctrlKey;
// Layout-independent key (uses event.code for letters/symbols)

View file

@ -419,6 +419,36 @@
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
}
/* Provider logos - keep verified brand marks legible on dark provider cards */
.runtime-provider-brand-icon {
background-color: var(
--runtime-provider-brand-background,
var(--runtime-provider-brand-fallback-background, rgba(148, 163, 184, 0.12))
);
border-color: var(
--runtime-provider-brand-border,
var(--runtime-provider-brand-fallback-border, rgba(148, 163, 184, 0.28))
);
color: var(--runtime-provider-brand-fallback-color, inherit);
}
.runtime-provider-brand-icon > img {
filter: var(--runtime-provider-brand-image-filter, none);
}
:root:not(.light) .runtime-provider-brand-icon {
--runtime-provider-brand-background: rgba(15, 23, 42, 0.72);
--runtime-provider-brand-border: rgba(226, 232, 240, 0.2);
--runtime-provider-brand-mark: #f8fafc;
--runtime-provider-brand-image-filter: invert(1);
color: var(--runtime-provider-brand-mark);
}
:root.light .runtime-provider-brand-icon {
--runtime-provider-brand-image-filter: none;
}
/* Light theme overrides - Warm neutral palette for eye comfort */
:root.light {
@ -790,6 +820,20 @@ body.theme-transitioning {
-webkit-app-region: no-drag;
}
button,
input,
textarea,
select,
a[href],
[role='button'],
[role='combobox'],
[role='menuitem'],
[role='tab'],
[role='textbox'],
[contenteditable='true'] {
-webkit-app-region: no-drag;
}
/* Prevent drag region bleed-through on Windows: all fixed overlays must be no-drag */
.fixed {

View file

@ -2,6 +2,10 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' data: blob: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https:; media-src 'self' data: blob: http: https:; frame-src 'self' http: https:; object-src 'none'; base-uri 'self'; form-action 'none'; worker-src 'self' blob:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="./favicon.png" />
<link
@ -95,7 +99,7 @@
href="./assets/participant-avatars/13.png"
fetchpriority="high"
/>
<title>Agent Teams UI</title>
<title>Agent Teams AI</title>
<style>
/* Splash: animated gradient background */
#splash {
@ -155,10 +159,16 @@
height: 100%;
pointer-events: none;
z-index: 1;
opacity: 1;
opacity: 0;
animation: splash-canvas-in 0.62s ease-out 0.08s forwards;
}
@keyframes splash-canvas-in {
to {
opacity: 1;
}
}
#splash-logo,
#splash-text {
#splash-copy {
position: relative;
z-index: 3;
}
@ -168,6 +178,13 @@
splash-breathe 3s ease-in-out infinite,
splash-glow 3s ease-in-out infinite;
}
#splash-copy {
display: flex;
width: min(84vw, 360px);
flex-direction: column;
align-items: center;
text-align: center;
}
@keyframes splash-breathe {
0%,
100% {
@ -197,6 +214,34 @@
color: #a1a1aa;
text-shadow: 0 0 22px rgba(129, 140, 248, 0.26);
}
#splash-tagline {
margin-top: 7px;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 11px;
font-style: italic;
font-weight: 500;
letter-spacing: 0.01em;
color: rgba(212, 212, 216, 0.68);
text-shadow: 0 0 18px rgba(129, 140, 248, 0.18);
}
#splash-tagline > span {
display: inline-block;
white-space: nowrap;
clip-path: inset(0 100% 0 0);
animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards;
will-change: clip-path;
}
@keyframes splash-tagline-type {
to {
clip-path: inset(0 0 0 0);
}
}
/* Logo node breathing - cycles through 3 agent nodes */
@keyframes splash-node {
@ -236,6 +281,9 @@
:root.light #splash-text {
color: #52525b;
}
:root.light #splash-tagline {
color: rgba(63, 63, 70, 0.66);
}
:root.light #splash-noise {
opacity: 0.02;
}
@ -257,10 +305,18 @@
}
@media (prefers-reduced-motion: reduce) {
#splash,
#splash-enhanced-canvas,
#splash-logo,
#splash-tagline > span,
.splash-node {
animation: none !important;
}
#splash-enhanced-canvas {
opacity: 1;
}
#splash-tagline > span {
clip-path: inset(0 0 0 0);
}
}
</style>
<script>
@ -387,7 +443,10 @@
fill="#f3e8ff"
/>
</svg>
<div id="splash-text">Agent Teams UI</div>
<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>
</div>
<div id="root"></div>
<script>
@ -397,6 +456,7 @@
var TAU = Math.PI * 2;
var TEAM_MEMBER_COUNTS = [4, 3, 5];
var TEAM_MEMBER_OFFSETS = [0, 4, 7];
var TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'];
var MAX_DPR = 2;
var AVATAR_URLS = [
'./assets/participant-avatars/01.png',
@ -545,12 +605,14 @@
for (var i = 0; i < teams.length; i++) drawHalo(ctx, teams[i], time, p);
drawMessages(ctx, teams, time, p, mobile);
for (var t = 0; t < teams.length; t++) drawLinks(ctx, teams[t], p);
for (var ti = 0; ti < teams.length; ti++) {
for (var ri = 0; ri < teams[ti].robots.length; ri++) {
drawRobot(ctx, teams[ti].robots[ri], time, p);
}
}
for (var labelIndex = 0; labelIndex < teams.length; labelIndex++) {
drawTeamLabel(ctx, teams[labelIndex], p, mobile);
}
}
function buildTeams(width, height, time, mobile, p, center) {
@ -700,21 +762,6 @@
ctx.setLineDash([]);
}
function drawLinks(ctx, team, p) {
var pairs = connectionPairs(team.robots.length);
for (var i = 0; i < pairs.length; i++) {
var from = team.robots[pairs[i][0]];
var to = team.robots[pairs[i][1]];
if (!from || !to) continue;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.strokeStyle = withAlpha(team.color, p.isLight ? 0.34 : 0.42);
ctx.lineWidth = 1;
ctx.stroke();
}
}
function drawMessages(ctx, teams, time, p, mobile) {
for (var i = 0; i < teams.length; i++) {
var pairs = localPairs(i, teams[i].robots.length);
@ -724,14 +771,14 @@
var from = teams[i].robots[pairs[pi][0]];
var to = teams[i].robots[pairs[pi][1]];
if (to) applyReceivePulse(to, receivePulse(raw, 0.76));
if (raw > 0.76) continue;
var flightState = messageFlightState(raw, 0.76, 0.12);
if (!flightState) continue;
if (from && to)
drawFlight(
ctx,
localCurve(from, to, teams[i].center, teams[i].radius * 0.42),
ease(raw / 0.76),
flightState,
teams[i].color,
time,
mobile ? 4.6 : 5.8,
p,
false
@ -752,13 +799,13 @@
var crossFrom = fromTeam.robots[route[1] % fromTeam.robots.length];
var crossTo = toTeam.robots[route[3] % toTeam.robots.length];
applyReceivePulse(crossTo, receivePulse(rawCross, 0.64) * 0.88);
if (rawCross > 0.64) continue;
var crossState = messageFlightState(rawCross, 0.64, 0.1);
if (!crossState) continue;
drawFlight(
ctx,
straightCurve(fromTeam.center, toTeam.center),
ease(rawCross / 0.64),
straightCurve(crossFrom, crossTo),
crossState,
route[5] ? p.messageAccent : fromTeam.color,
time,
mobile ? 5.2 : 6.8,
p,
true
@ -766,58 +813,142 @@
}
}
function drawFlight(ctx, curve, progress, color, time, size, p, crossTeam) {
function drawFlight(ctx, curve, state, color, size, p, crossTeam) {
var p0 = curve[0],
p1 = curve[1],
p2 = curve[2],
p3 = curve[3];
ctx.save();
if (!crossTeam) {
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
ctx.strokeStyle = withAlpha(color, 0.12);
ctx.lineWidth = 0.85;
ctx.setLineDash([4, 8]);
ctx.lineDashOffset = -time * 34;
ctx.stroke();
ctx.setLineDash([]);
}
for (var i = 6; i >= 1; i--) {
var t = progress - i * 0.04;
if (t <= 0) continue;
var trailPoint = cubicPoint(p0, p1, p2, p3, t);
ctx.fillStyle = withAlpha(color, (1 - i / 7) * (p.isLight ? 0.14 : 0.2));
ctx.beginPath();
ctx.arc(trailPoint.x, trailPoint.y, size * 0.34, 0, TAU);
ctx.fill();
var progress = state.progress;
var speed = clamp(state.motionSpeed, 0, 1);
if (speed > 0.045) {
drawSpeedTrail(ctx, curve, progress, speed, color, size, p, crossTeam);
}
var position = cubicPoint(p0, p1, p2, p3, progress);
var tangent = cubicTangent(p0, p1, p2, p3, progress);
drawBubble(ctx, position, Math.atan2(tangent.y, tangent.x), size, color, p, crossTeam);
drawBubble(
ctx,
position,
Math.atan2(tangent.y, tangent.x),
size,
color,
p,
crossTeam,
state.bubbleScale,
state.bubbleAlpha
);
ctx.restore();
}
function drawSpeedTrail(ctx, curve, progress, speed, color, size, p, crossTeam) {
var p0 = curve[0],
p1 = curve[1],
p2 = curve[2],
p3 = curve[3];
var trailLength = (crossTeam ? 0.26 : 0.21) * (0.24 + speed * 1.08);
var segmentCount = Math.round(9 + speed * 10);
var alphaBase = (p.isLight ? 0.22 : 0.32) * speed;
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowColor = withAlpha(color, alphaBase * 0.58);
ctx.shadowBlur = size * (0.78 + speed * 1.28);
for (var segment = 0; segment < segmentCount; segment++) {
var startRatio = segment / segmentCount;
var endRatio = (segment + 1) / segmentCount;
var t0 = progress - trailLength * (1 - startRatio);
var t1 = progress - trailLength * (1 - endRatio);
if (t1 <= 0) continue;
var from = cubicPoint(p0, p1, p2, p3, Math.max(0, t0));
var to = cubicPoint(p0, p1, p2, p3, Math.max(0, t1));
var headWeight = endRatio * endRatio;
var width = size * (0.12 + headWeight * 0.48) * (0.9 + speed * 0.45);
var alpha = alphaBase * headWeight;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.strokeStyle = withAlpha(color, alpha * 0.34);
ctx.lineWidth = width * 2.35;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.strokeStyle = withAlpha(color, alpha);
ctx.lineWidth = width;
ctx.stroke();
}
ctx.restore();
}
function messageFlightState(raw, activeWindow, settleWindow) {
if (raw > activeWindow + settleWindow) return null;
if (raw <= activeWindow) {
var phase = raw / activeWindow;
return {
progress: ease(phase),
motionSpeed: easedMotionSpeed(phase),
bubbleScale: 1,
bubbleAlpha: 1,
};
}
var settlePhase = (raw - activeWindow) / settleWindow;
var eased = easeOutCubic(settlePhase);
return {
progress: 1,
motionSpeed: 0,
bubbleScale: Math.max(0.12, 1 - eased * 0.88),
bubbleAlpha: Math.max(0, 1 - eased),
};
}
function applyReceivePulse(robot, pulse) {
robot.receivePulse = Math.max(robot.receivePulse, pulse);
}
function receivePulse(raw, activeWindow) {
var start = activeWindow * 0.78;
var end = Math.min(0.96, activeWindow + 0.11);
var previousStart = activeWindow * 0.78;
var previousEnd = Math.min(0.96, activeWindow + 0.11);
var duration = (previousEnd - previousStart) / 3;
var start = activeWindow - duration * 0.62;
var end = activeWindow + duration * 0.38;
if (raw < start || raw > end) return 0;
var phase = (raw - start) / (end - start);
return Math.sin(phase * Math.PI) * (1 - phase * 0.28);
}
function drawBubble(ctx, position, angle, size, color, p, crossTeam) {
function easedMotionSpeed(value) {
var t = clamp(value, 0, 1);
var derivative = t < 0.5 ? 12 * t * t : 12 * (1 - t) * (1 - t);
return clamp(derivative / 3, 0, 1);
}
function easeOutCubic(value) {
var t = clamp(value, 0, 1);
return 1 - Math.pow(1 - t, 3);
}
function drawBubble(ctx, position, angle, size, color, p, crossTeam, scale, alpha) {
scale = scale === undefined ? 1 : scale;
alpha = alpha === undefined ? 1 : alpha;
if (scale <= 0.02 || alpha <= 0.01) return;
ctx.save();
ctx.translate(position.x, position.y);
ctx.rotate(angle * 0.08);
ctx.shadowColor = withAlpha(color, p.isLight ? 0.16 : 0.3);
ctx.shadowBlur = crossTeam ? 12 : 8;
ctx.scale(scale, scale);
ctx.globalAlpha = alpha;
ctx.shadowColor = withAlpha(color, (p.isLight ? 0.16 : 0.3) * alpha);
ctx.shadowBlur = (crossTeam ? 12 : 8) * (0.5 + scale * 0.5);
var width = size * (crossTeam ? 2.28 : 2.06);
var height = size * 1.42;
roundRectPath(ctx, -width / 2, -height / 2, width, height, size * 0.28);
@ -839,6 +970,42 @@
ctx.restore();
}
function drawTeamLabel(ctx, team, p, mobile) {
var label = TEAM_LABELS[team.index] || '';
if (!label) return;
var fontSize = mobile ? 7.5 : 8.5;
var y = team.center.y + team.radius * (mobile ? 1.65 : 1.58);
ctx.save();
ctx.font =
'600 ' +
fontSize +
'px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
var metrics = ctx.measureText(label);
var paddingX = mobile ? 4 : 5;
var paddingY = mobile ? 2 : 2.5;
var width = metrics.width + paddingX * 2;
var height = fontSize + paddingY * 2;
var x = team.center.x - width / 2;
var rectY = y - height / 2;
roundRectPath(ctx, x, rectY, width, height, height / 2);
ctx.fillStyle = withAlpha(p.isLight ? '#ffffff' : '#090a14', p.isLight ? 0.36 : 0.24);
ctx.fill();
ctx.strokeStyle = withAlpha(team.color, p.isLight ? 0.18 : 0.24);
ctx.lineWidth = 0.75;
ctx.stroke();
ctx.shadowColor = withAlpha(team.color, p.isLight ? 0.12 : 0.22);
ctx.shadowBlur = mobile ? 4 : 6;
ctx.fillStyle = withAlpha(p.isLight ? '#3f3f46' : '#e4e4e7', p.isLight ? 0.58 : 0.66);
ctx.fillText(label, team.center.x, y + 0.2);
ctx.restore();
}
function drawRobot(ctx, robot, time, p) {
var size = robot.size;
var y = robot.y + robot.bob * 0.9 - robot.receivePulse * size * 0.24;
@ -933,20 +1100,6 @@
ctx.fill();
}
function connectionPairs(count) {
if (count <= 3)
return [
[0, 1],
[1, 2],
[2, 0],
];
var pairs = [];
for (var i = 0; i < count; i++) pairs.push([i, (i + 1) % count]);
if (count >= 4) pairs.push([0, 2]);
if (count >= 5) pairs.push([1, 4]);
return pairs;
}
function localPairs(teamIndex, count) {
var map = [
[

View file

@ -27,7 +27,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
{ providerId: 'opencode', displayName: 'OpenCode' },
{ providerId: 'opencode', displayName: 'OpenCode (75+ LLM providers)' },
] as const
).map((provider) => ({
...provider,
@ -212,7 +212,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode';
return 'OpenCode (75+ LLM providers)';
}
}
@ -480,7 +480,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
if (get().cliStatus && !get().cliStatus?.installed) {
return;
}
const verifyModels = options?.verifyModels === true;
const verifyModels = options?.verifyModels === true && providerId !== 'opencode';
const requestKey = `${providerId}:${verifyModels ? 'verify' : 'status'}`;
const inFlight = cliProviderStatusInFlight.get(requestKey);
if (inFlight) return inFlight;

View file

@ -10,6 +10,7 @@ import {
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { buildOpenCodeRuntimeDeliveryDiagnostics } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { isLeadMember } from '@shared/utils/leadDetection';
@ -22,8 +23,9 @@ import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
import type { AppState } from '../types';
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
import type { AppConfig } from '@renderer/types/data';
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type {
ActiveToolCall,
@ -1848,6 +1850,33 @@ function pruneTeamGraphSlotAssignmentsForVisibleOwners(
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
}
function normalizeTeamGraphGridOwnerOrder(
order: readonly string[] | undefined,
visibleOwnerIds: readonly string[]
): string[] {
const visibleOwnerIdSet = new Set(visibleOwnerIds);
const normalizedOrder: string[] = [];
const seenOwnerIds = new Set<string>();
for (const stableOwnerId of order ?? []) {
if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) {
continue;
}
normalizedOrder.push(stableOwnerId);
seenOwnerIds.add(stableOwnerId);
}
for (const stableOwnerId of visibleOwnerIds) {
if (seenOwnerIds.has(stableOwnerId)) {
continue;
}
normalizedOrder.push(stableOwnerId);
seenOwnerIds.add(stableOwnerId);
}
return normalizedOrder;
}
export function getDefaultTeamGraphSlotAssignmentsForMembers(
members: readonly TeamGraphMemberSeedInput[],
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
@ -1921,6 +1950,8 @@ export interface TeamSlice {
/** Team-scoped detailed cache used by multi-pane views like agent graph. */
teamDataCacheByName: Record<string, TeamViewSnapshot>;
slotLayoutVersion: string;
graphLayoutModeByTeam: Record<string, GraphLayoutMode>;
gridOwnerOrderByTeam: Record<string, string[]>;
slotAssignmentsByTeam: Record<string, TeamGraphSlotAssignments>;
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
@ -1931,6 +1962,7 @@ export interface TeamSlice {
sendingMessage: boolean;
sendMessageError: string | null;
sendMessageWarning: string | null;
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
lastSendMessageResult: SendMessageResult | null;
reviewActionError: string | null;
provisioningRuns: Record<string, TeamProvisioningProgress>;
@ -1987,6 +2019,12 @@ export interface TeamSlice {
displacedStableOwnerId?: string,
displacedAssignment?: GraphOwnerSlotAssignment
) => void;
setTeamGraphLayoutMode: (teamName: string, mode: GraphLayoutMode) => void;
swapTeamGraphGridOwners: (
teamName: string,
stableOwnerId: string,
targetStableOwnerId: string
) => void;
swapTeamGraphOwnerSlots: (
teamName: string,
stableOwnerId: string,
@ -2256,6 +2294,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamData: null,
teamDataCacheByName: {},
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
graphLayoutModeByTeam: {},
gridOwnerOrderByTeam: {},
slotAssignmentsByTeam: {},
teamMessagesByName: {},
memberActivityMetaByTeam: {},
@ -2266,6 +2306,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
sendingMessage: false,
sendMessageError: null,
sendMessageWarning: null,
sendMessageDebugDetails: null,
lastSendMessageResult: null,
crossTeamTargets: [],
crossTeamTargetsLoading: false,
@ -2912,6 +2953,62 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
});
},
setTeamGraphLayoutMode: (teamName, mode) => {
set((state) => {
if ((state.graphLayoutModeByTeam[teamName] ?? 'radial') === mode) {
return {};
}
return {
graphLayoutModeByTeam: {
...state.graphLayoutModeByTeam,
[teamName]: mode,
},
};
});
},
swapTeamGraphGridOwners: (teamName, stableOwnerId, targetStableOwnerId) => {
if (stableOwnerId === targetStableOwnerId) {
return;
}
set((state) => {
const teamData = selectTeamDataForName(state, teamName);
const fallbackVisibleOwnerIds = [...(state.gridOwnerOrderByTeam[teamName] ?? [])];
for (const ownerId of [stableOwnerId, targetStableOwnerId]) {
if (!fallbackVisibleOwnerIds.includes(ownerId)) {
fallbackVisibleOwnerIds.push(ownerId);
}
}
const visibleOwnerIds = teamData
? buildTeamGraphDefaultLayoutSeed(teamData.members, teamData.config.members ?? [])
.orderedVisibleOwnerIds
: fallbackVisibleOwnerIds;
const normalizedOrder = normalizeTeamGraphGridOwnerOrder(
state.gridOwnerOrderByTeam[teamName],
visibleOwnerIds
);
const stableOwnerIndex = normalizedOrder.indexOf(stableOwnerId);
const targetOwnerIndex = normalizedOrder.indexOf(targetStableOwnerId);
if (stableOwnerIndex < 0 || targetOwnerIndex < 0) {
return {};
}
const nextOrder = [...normalizedOrder];
nextOrder[stableOwnerIndex] = targetStableOwnerId;
nextOrder[targetOwnerIndex] = stableOwnerId;
return {
gridOwnerOrderByTeam: {
...state.gridOwnerOrderByTeam,
[teamName]: nextOrder,
},
};
});
},
swapTeamGraphOwnerSlots: (teamName, stableOwnerId, otherStableOwnerId) => {
if (stableOwnerId === otherStableOwnerId) {
return;
@ -3865,6 +3962,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
sendingMessage: true,
sendMessageError: null,
sendMessageWarning: null,
sendMessageDebugDetails: null,
lastSendMessageResult: null,
});
try {
@ -3873,13 +3971,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
);
const runtimeDeliveryFailed =
result.runtimeDelivery?.attempted === true && result.runtimeDelivery.delivered === false;
const runtimeDeliveryWarning = runtimeDeliveryFailed
? `OpenCode runtime delivery failed: ${
result.runtimeDelivery?.reason ??
result.runtimeDelivery?.diagnostics?.[0] ??
'message was saved to inbox but not delivered live'
}`
: null;
const runtimeDeliveryDiagnostics = buildOpenCodeRuntimeDeliveryDiagnostics(result);
const optimisticMessage: InboxMessage = {
from: request.from ?? 'user',
to: request.to ?? request.member,
@ -3906,7 +3998,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set((state) => ({
sendingMessage: false,
sendMessageError: null,
sendMessageWarning: runtimeDeliveryWarning,
sendMessageWarning: runtimeDeliveryDiagnostics.warning,
sendMessageDebugDetails: runtimeDeliveryDiagnostics.debugDetails,
lastSendMessageResult: runtimeDeliveryFailed ? null : result,
teamMessagesByName: {
...state.teamMessagesByName,
@ -3923,6 +4016,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
sendingMessage: false,
lastSendMessageResult: null,
sendMessageWarning: null,
sendMessageDebugDetails: null,
sendMessageError: mapSendMessageError(error),
});
throw error;
@ -3945,6 +4039,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
sendingMessage: true,
sendMessageError: null,
sendMessageWarning: null,
sendMessageDebugDetails: null,
lastSendMessageResult: null,
});
try {
@ -3953,6 +4048,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
sendingMessage: false,
sendMessageError: null,
sendMessageWarning: null,
sendMessageDebugDetails: null,
lastSendMessageResult: {
messageId: result.messageId,
deliveredToInbox: result.deliveredToInbox,
@ -3965,6 +4061,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
sendingMessage: false,
lastSendMessageResult: null,
sendMessageWarning: null,
sendMessageDebugDetails: null,
sendMessageError: mapSendMessageError(error),
});
}

View file

@ -353,17 +353,21 @@ const KNOWN_DIRS = new Set([
'node_modules',
]);
/**
* Simple pattern for detecting @ mentions that could be file paths.
* The filtering logic in extractFileReferences determines validity.
*/
const FILE_REF_PATTERN = /@([~a-zA-Z0-9._/-]+)/g;
export type FileReferenceToken = FileReference & {
startIndex: number;
endIndex: number;
};
const UNQUOTED_FILE_REF_STOP = /[\s,)}\]]/;
/**
* Checks if a path looks like a valid file reference.
* Must start with known dir, contain /, or start with ./ or ../
*/
function isValidFileRef(path: string): boolean {
if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')) {
return true;
}
// Check for relative path indicators
if (isRelativePath(path)) {
return true;
@ -385,6 +389,58 @@ function isValidFileRef(path: string): boolean {
return false;
}
function readFileRefAt(text: string, atIndex: number): FileReferenceToken | null {
const valueStart = atIndex + 1;
const firstChar = text[valueStart];
if (!firstChar) return null;
let path = '';
let endIndex = valueStart;
if (firstChar === '"' || firstChar === "'") {
const quote = firstChar;
const quotedStart = valueStart + 1;
const quotedEnd = text.indexOf(quote, quotedStart);
if (quotedEnd < 0) return null;
path = text.slice(quotedStart, quotedEnd);
endIndex = quotedEnd + 1;
} else {
while (endIndex < text.length && !UNQUOTED_FILE_REF_STOP.test(text[endIndex])) {
endIndex += 1;
}
path = text.slice(valueStart, endIndex);
}
if (!path || !isValidFileRef(path)) return null;
return {
path,
raw: text.slice(atIndex, endIndex),
startIndex: atIndex,
endIndex,
};
}
export function extractFileReferenceTokens(text: string): FileReferenceToken[] {
if (!text) return [];
const references: FileReferenceToken[] = [];
let index = 0;
while (index < text.length) {
const atIndex = text.indexOf('@', index);
if (atIndex < 0) break;
const reference = readFileRefAt(text, atIndex);
if (reference) {
references.push(reference);
index = reference.endIndex;
} else {
index = atIndex + 1;
}
}
return references;
}
/**
* Extracts file references (@file.ts) from text.
*
@ -392,25 +448,7 @@ function isValidFileRef(path: string): boolean {
* @returns Array of FileReference objects
*/
export function extractFileReferences(text: string): FileReference[] {
if (!text) return [];
const references: FileReference[] = [];
// Reset regex state before use
FILE_REF_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = FILE_REF_PATTERN.exec(text)) !== null) {
const [fullMatch, path] = match;
// Only include if it looks like a valid file reference
if (isValidFileRef(path)) {
references.push({
path,
raw: fullMatch,
});
}
}
return references;
return extractFileReferenceTokens(text).map(({ path, raw }) => ({ path, raw }));
}
// =============================================================================

View file

@ -0,0 +1,228 @@
export type OpenCodeTeamModelRecommendationLevel =
| 'recommended'
| 'recommended-with-limits'
| 'unavailable-in-opencode'
| 'not-recommended';
export interface OpenCodeTeamModelRecommendation {
readonly level: OpenCodeTeamModelRecommendationLevel;
readonly label: string;
readonly reason: string;
}
const PASSED_REAL_AGENT_TEAMS_E2E_REASON =
'This exact model route passed real OpenCode Agent Teams E2E: launch, direct reply, and teammate-to-teammate relay.';
const PASSED_FREE_ROUTE_REAL_AGENT_TEAMS_E2E_REASON =
'This exact free model route passed real OpenCode Agent Teams E2E, but free routes can still have capacity limits, rate limits, and variable latency.';
const OPENCODE_TEAM_RECOMMENDED_MODELS = new Set<string>([
'openrouter/anthropic/claude-haiku-4.5',
'openrouter/anthropic/claude-opus-4.6',
'openrouter/anthropic/claude-opus-4.7',
'openrouter/anthropic/claude-sonnet-4.5',
'openrouter/anthropic/claude-sonnet-4.6',
'openrouter/google/gemini-2.5-flash',
'openrouter/google/gemini-3.1-flash-lite-preview',
'openrouter/google/gemini-3.1-pro-preview',
'openrouter/google/gemini-3-flash-preview',
'openrouter/minimax/minimax-m2.5',
'openrouter/minimax/minimax-m2.7',
'openrouter/moonshotai/kimi-k2.6',
'openrouter/mistralai/codestral-2508',
'openrouter/mistralai/devstral-2512',
'openrouter/mistralai/mistral-medium-3.1',
'openrouter/openai/gpt-5.1',
'openrouter/openai/gpt-5.1-codex',
'openrouter/openai/gpt-5.1-codex-mini',
'openrouter/openai/gpt-5.3-codex',
'openrouter/openai/gpt-5.4',
'openrouter/openai/gpt-5.4-mini',
'openrouter/qwen/qwen3-max',
'openrouter/qwen/qwen3-coder',
'openrouter/qwen/qwen3-coder-flash',
'openrouter/x-ai/grok-4.1-fast',
'openrouter/x-ai/grok-4-fast',
'openrouter/xiaomi/mimo-v2-pro',
'openrouter/z-ai/glm-4.6',
'openrouter/z-ai/glm-5',
'openrouter/z-ai/glm-5.1',
]);
const OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS = new Set<string>([
'opencode/minimax-m2.5-free',
'openrouter/openai/gpt-oss-120b:free',
]);
const OPENCODE_TEAM_UNAVAILABLE_MODELS = new Map<string, string>([
[
'openrouter/qwen/qwen3-coder-plus',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
[
'openrouter/qwen/qwen3-coder-next',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
[
'openrouter/qwen/qwen3-max-thinking',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
[
'openrouter/mistralai/devstral-medium',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
[
'openrouter/mistralai/mistral-large-2512',
'This route exists in OpenRouter, but was not found in the live OpenCode provider catalog used for Agent Teams launch.',
],
]);
const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map<string, string>([
[
'opencode/ling-2.6-flash-free',
'Real OpenCode Agent Teams E2E showed unreliable peer relay for this model.',
],
[
'opencode/nemotron-3-super-free',
'Real OpenCode Agent Teams E2E showed empty assistant turns during peer relay.',
],
[
'openrouter/google/gemini-2.5-pro',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay.',
],
[
'openrouter/google/gemini-2.5-flash-lite',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay with plain/control-character output instead of MCP message_send.',
],
[
'openrouter/google/gemini-3-pro-preview',
'OpenRouter reported no runnable endpoints for this model during execution verification.',
],
[
'openrouter/deepseek/deepseek-v3.2',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay after treating Agent Teams MCP tools as unavailable.',
],
[
'openrouter/meta-llama/llama-3.3-70b-instruct:free',
'Execution verification timed out before Agent Teams launch could proceed.',
],
[
'openrouter/minimax/minimax-m2.5:free',
'This OpenRouter free route for MiniMax M2.5 passed direct reply but failed teammate-to-teammate relay. The non-free OpenRouter route and the OpenCode free alias are tracked separately.',
],
[
'openrouter/moonshotai/kimi-k2-thinking',
'Real OpenCode Agent Teams E2E failed during launch reconciliation with an aborted assistant message.',
],
[
'openrouter/openai/gpt-5.2-codex',
'Real OpenCode Agent Teams E2E failed launch readiness because model verification timed out.',
],
[
'openrouter/openai/gpt-5.1-chat',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay by delegating to the lead instead of messaging the requested teammate.',
],
[
'openrouter/openai/gpt-oss-20b:free',
'Execution verification passed, but real Agent Teams E2E produced fake tool text instead of MCP message_send.',
],
[
'openrouter/openrouter/free',
'Aggregator routing was unstable in real Agent Teams E2E and timed out during peer relay.',
],
[
'openrouter/x-ai/grok-code-fast-1',
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay by delegating to the lead instead of messaging the requested teammate.',
],
[
'openrouter/z-ai/glm-4.5-air:free',
'Real OpenCode Agent Teams E2E was slow and failed peer relay with empty assistant turns.',
],
]);
function normalizeOpenCodeTeamModelId(modelId: string | null | undefined): string {
return modelId?.trim().toLowerCase() ?? '';
}
export function getOpenCodeTeamModelRecommendation(
modelId: string | null | undefined
): OpenCodeTeamModelRecommendation | null {
const normalizedModelId = normalizeOpenCodeTeamModelId(modelId);
if (!normalizedModelId) {
return null;
}
if (OPENCODE_TEAM_RECOMMENDED_MODELS.has(normalizedModelId)) {
return {
level: 'recommended',
label: 'Recommended',
reason: PASSED_REAL_AGENT_TEAMS_E2E_REASON,
};
}
if (OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS.has(normalizedModelId)) {
return {
level: 'recommended-with-limits',
label: 'Recommended with limits',
reason: PASSED_FREE_ROUTE_REAL_AGENT_TEAMS_E2E_REASON,
};
}
const unavailableReason = OPENCODE_TEAM_UNAVAILABLE_MODELS.get(normalizedModelId);
if (unavailableReason) {
return {
level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode',
reason: unavailableReason,
};
}
const notRecommendedReason = OPENCODE_TEAM_NOT_RECOMMENDED_MODELS.get(normalizedModelId);
if (notRecommendedReason) {
return {
level: 'not-recommended',
label: 'Not recommended',
reason: notRecommendedReason,
};
}
return null;
}
export function isOpenCodeTeamModelRecommended(modelId: string | null | undefined): boolean {
const recommendation = getOpenCodeTeamModelRecommendation(modelId);
return (
recommendation?.level === 'recommended' || recommendation?.level === 'recommended-with-limits'
);
}
export function getOpenCodeTeamModelRecommendationSortRank(
modelId: string | null | undefined
): number {
const recommendation = getOpenCodeTeamModelRecommendation(modelId);
if (recommendation?.level === 'recommended') {
return 0;
}
if (recommendation?.level === 'recommended-with-limits') {
return 1;
}
if (recommendation?.level === 'unavailable-in-opencode') {
return 3;
}
if (recommendation?.level === 'not-recommended') {
return 4;
}
return 2;
}
export function compareOpenCodeTeamModelRecommendations(
leftModelId: string | null | undefined,
rightModelId: string | null | undefined
): number {
const leftRank = getOpenCodeTeamModelRecommendationSortRank(leftModelId);
const rightRank = getOpenCodeTeamModelRecommendationSortRank(rightModelId);
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
return 0;
}

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