feat(runtime): improve provider delivery visibility

This commit is contained in:
777genius 2026-05-12 23:33:08 +03:00
parent 7138887a3b
commit 20c3194160
62 changed files with 2849 additions and 750 deletions

View file

@ -93,17 +93,29 @@ const ruGuide: DefaultTheme.SidebarItem[] = [
];
const rootNav: DefaultTheme.NavItem[] = [
{ text: "Guide", link: "/guide/quickstart" },
{ text: "Reference", link: "/reference/concepts" },
{ text: "Troubleshooting", link: "/guide/troubleshooting" },
{ text: "Download", link: downloadUrl, target: "_self" }
{ text: "Guide", link: "/guide/quickstart", activeMatch: "^/guide/(?!troubleshooting(?:/|$))" },
{ text: "Reference", link: "/reference/concepts", activeMatch: "^/reference/" },
{
text: "Troubleshooting",
link: "/guide/troubleshooting",
activeMatch: "^/guide/troubleshooting(?:/|$)"
},
{ text: "Download", link: downloadUrl, target: "_self", noIcon: true }
];
const ruNav: DefaultTheme.NavItem[] = [
{ text: "Руководство", link: "/ru/guide/quickstart" },
{ text: "Справочник", link: "/ru/reference/concepts" },
{ text: "Диагностика", link: "/ru/guide/troubleshooting" },
{ text: "Скачать", link: ruDownloadUrl, target: "_self" }
{
text: "Руководство",
link: "/ru/guide/quickstart",
activeMatch: "^/ru/guide/(?!troubleshooting(?:/|$))"
},
{ text: "Справочник", link: "/ru/reference/concepts", activeMatch: "^/ru/reference/" },
{
text: "Диагностика",
link: "/ru/guide/troubleshooting",
activeMatch: "^/ru/guide/troubleshooting(?:/|$)"
},
{ text: "Скачать", link: ruDownloadUrl, target: "_self", noIcon: true }
];
export default defineConfig({
@ -173,12 +185,20 @@ export default defineConfig({
}
},
themeConfig: {
logo: "/logo-192.png",
logo: {
light: "/logo-192.png",
dark: "/logo-192.png",
alt: "Agent Teams"
},
siteTitle: "Agent Teams",
outline: {
level: [2, 3],
label: "On this page"
},
externalLinkIcon: true,
darkModeSwitchLabel: "Appearance",
lightModeSwitchTitle: "Switch to light theme",
darkModeSwitchTitle: "Switch to dark theme",
search: {
provider: "local",
options: {
@ -198,6 +218,14 @@ export default defineConfig({
}
}
},
lastUpdated: {
text: "Last updated",
formatOptions: {
dateStyle: "medium",
timeStyle: "short",
forceLocale: true
}
},
nav: rootNav,
sidebar: {
"/ru/": ruGuide,
@ -236,6 +264,9 @@ export default defineConfig({
level: [2, 3],
label: "На этой странице"
},
darkModeSwitchLabel: "Оформление",
lightModeSwitchTitle: "Переключить на светлую тему",
darkModeSwitchTitle: "Переключить на тёмную тему",
search: {
provider: "local",
options: {
@ -255,6 +286,14 @@ export default defineConfig({
}
}
},
lastUpdated: {
text: "Обновлено",
formatOptions: {
dateStyle: "medium",
timeStyle: "short",
forceLocale: true
}
},
editLink: {
pattern: `https://github.com/${REPO}/edit/main/landing/product-docs/:path`,
text: "Редактировать на GitHub"

View file

@ -145,7 +145,7 @@
"cronstrue": "^3.13.0",
"date-fns": "^3.6.0",
"diff": "^8.0.3",
"dompurify": "^3.3.1",
"dompurify": "^3.4.2",
"electron-updater": "^6.7.3",
"fastify": "^5.8.5",
"highlight.js": "^11.11.1",
@ -153,7 +153,7 @@
"isbinaryfile": "^6.0.0",
"lucide-react": "^0.577.0",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.12.3",
"mermaid": "^11.15.0",
"motion": "12.38.0",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
@ -177,7 +177,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"yaml": "^2.8.2",
"yaml": "^2.9.0",
"yet-another-react-lightbox": "^3.29.1",
"zustand": "^4.5.0"
},
@ -330,6 +330,10 @@
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"pnpm": {
"overrides": {
"lodash-es": "^4.18.1",
"uuid": "^11.1.1"
},
"onlyBuiltDependencies": [
"electron",
"node-pty",

View file

@ -30,6 +30,69 @@ export function truncateText(
return lo > 0 ? text.slice(0, lo) + '...' : '...';
}
function fitTextPrefix(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
font: string,
): string {
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (measureTextCached(ctx, font, text.slice(0, mid)) <= maxWidth) {
lo = mid;
} else {
hi = mid - 1;
}
}
return text.slice(0, lo);
}
/**
* Wrap text into a small fixed number of canvas lines.
* The final line is truncated with "..." when the remaining text still overflows.
*/
export function wrapTextLines(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
font: string,
maxLines: number,
): string[] {
const normalized = text.trim().replace(/\s+/g, ' ');
if (!normalized || maxLines <= 0) return [];
if (maxLines === 1) return [truncateText(ctx, normalized, maxWidth, font)];
const lines: string[] = [];
let remaining = normalized;
while (remaining && lines.length < maxLines) {
if (measureTextCached(ctx, font, remaining) <= maxWidth) {
lines.push(remaining);
break;
}
if (lines.length === maxLines - 1) {
lines.push(truncateText(ctx, remaining, maxWidth, font));
break;
}
const prefix = fitTextPrefix(ctx, remaining, maxWidth, font).trimEnd();
if (!prefix) {
lines.push(truncateText(ctx, remaining, maxWidth, font));
break;
}
const breakIndex = prefix.lastIndexOf(' ');
const line = breakIndex > 0 ? prefix.slice(0, breakIndex) : prefix;
lines.push(line);
remaining = remaining.slice(line.length).trimStart();
}
return lines;
}
// Pre-computed hex vertex unit offsets (avoids cos/sin per call)
const HEX_COS: number[] = [];
const HEX_SIN: number[] = [];

View file

@ -6,7 +6,7 @@
import type { GraphNode } from '../ports/types';
import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors';
import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants';
import { truncateText } from './draw-misc';
import { truncateText, wrapTextLines } from './draw-misc';
import { drawPillShell, drawPillStackLayer } from './draw-pill-shell';
import { hexWithAlpha } from './render-cache';
import type { KanbanZoneInfo } from '../layout/kanbanLayout';
@ -153,7 +153,8 @@ function drawTaskPill(
drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time);
}
// Subject (main title — large)
// Subject (main title - up to two lines)
let subjectLineCount = 0;
if (node.sublabel) {
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
ctx.textAlign = 'left';
@ -164,8 +165,13 @@ function drawTaskPill(
node.reviewState !== 'approved' &&
(node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName));
const maxW = hasReviewChip ? w - 88 : w - 24;
const subject = truncateText(ctx, node.sublabel, maxW, ctx.font);
ctx.fillText(subject, textX, -12);
const subjectLines = wrapTextLines(ctx, node.sublabel, maxW, ctx.font, 2);
subjectLineCount = subjectLines.length;
const titleStartY = subjectLines.length > 1 ? -16 : -12;
const titleLineHeight = TASK_PILL.idFontSize + 1.5;
subjectLines.forEach((line, index) => {
ctx.fillText(line, textX, titleStartY + index * titleLineHeight);
});
}
// Display ID (secondary — small)
@ -174,7 +180,7 @@ function drawTaskPill(
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textDim;
ctx.fillText(displayId, -halfW + 10, 12);
ctx.fillText(displayId, -halfW + 10, subjectLineCount > 1 ? 23 : 12);
// Approved badge: checkmark at right side
if (node.reviewState === 'approved') {

View file

@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
lodash-es: ^4.18.1
uuid: ^11.1.1
importers:
.:
@ -204,8 +208,8 @@ importers:
specifier: ^8.0.3
version: 8.0.3
dompurify:
specifier: ^3.3.1
version: 3.3.1
specifier: ^3.4.2
version: 3.4.2
electron-updater:
specifier: ^6.7.3
version: 6.7.3
@ -228,8 +232,8 @@ importers:
specifier: ^13.2.1
version: 13.2.1
mermaid:
specifier: ^11.12.3
version: 11.12.3
specifier: ^11.15.0
version: 11.15.0
motion:
specifier: 12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -295,13 +299,13 @@ importers:
version: 3.5.0
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
unified:
specifier: ^11.0.5
version: 11.0.5
yaml:
specifier: ^2.8.2
version: 2.8.2
specifier: ^2.9.0
version: 2.9.0
yet-another-react-lightbox:
specifier: ^3.29.1
version: 3.29.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -323,7 +327,7 @@ importers:
version: 5.1.1(encoding@0.1.13)(rollup@4.60.0)
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
'@types/hast':
specifier: ^3.0.4
version: 3.0.4
@ -401,7 +405,7 @@ importers:
version: 3.0.6(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-tailwindcss:
specifier: ^3.18.2
version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
globals:
specifier: ^17.2.0
version: 17.2.0
@ -428,7 +432,7 @@ importers:
version: 0.7.2(prettier@3.8.1)
tailwindcss:
specifier: ^3.4.1
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
version: 3.4.19(tsx@4.21.0)(yaml@2.9.0)
tsx:
specifier: ^4.21.0
version: 4.21.0
@ -460,13 +464,13 @@ importers:
version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)))
'@vueuse/nuxt':
specifier: ^10.11.1
version: 10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
version: 10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))
nuxt:
specifier: ^3.20.2
version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)
nuxt-icon:
specifier: ^0.6.10
version: 0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
version: 0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
@ -491,7 +495,7 @@ importers:
version: 7.4.47
'@nuxt/eslint':
specifier: ^1.12.1
version: 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@shikijs/transformers':
specifier: 3.22.0
version: 3.22.0
@ -512,13 +516,13 @@ importers:
version: 1.98.0
tailwindcss:
specifier: ^3.4.19
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
version: 3.4.19(tsx@4.21.0)(yaml@2.9.0)
vite-plugin-vuetify:
specifier: ^2.1.3
version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
vitepress:
specifier: 2.0.0-alpha.17
version: 2.0.0-alpha.17(@types/node@25.0.7)(axios@1.13.6)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.117.0)(postcss@8.5.8)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
version: 2.0.0-alpha.17(@types/node@25.0.7)(axios@1.13.6)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.117.0)(postcss@8.5.8)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
vitepress-codeblock-collapse:
specifier: ^1.0.0
version: 1.0.0(vue@3.5.30(typescript@5.9.3))
@ -543,7 +547,7 @@ importers:
version: 22.19.15
tsup:
specifier: ^8.5.1
version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
tsx:
specifier: ^4.21.0
version: 4.21.0
@ -784,21 +788,9 @@ packages:
'@braintree/sanitize-url@7.1.2':
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
'@chevrotain/cst-dts-gen@11.1.2':
resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==}
'@chevrotain/gast@11.1.2':
resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==}
'@chevrotain/regexp-to-ast@11.1.2':
resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==}
'@chevrotain/types@11.1.2':
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
'@chevrotain/utils@11.1.2':
resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==}
'@clack/core@1.1.0':
resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==}
@ -1979,8 +1971,8 @@ packages:
'@mdi/js@7.4.47':
resolution: {integrity: sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==}
'@mermaid-js/parser@1.0.0':
resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==}
'@mermaid-js/parser@1.1.1':
resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
'@miyaneee/rollup-plugin-json5@1.2.0':
resolution: {integrity: sha512-JjTIaXZp9WzhUHpElrqPnl1AzBi/rvRs065F71+aTmlqvTMVkdbjZ8vfFl4nRlgJy+TPBw69ZK4pwFdmOAt4aA==}
@ -4767,6 +4759,9 @@ packages:
cpu: [x64]
os: [win32]
'@upsetjs/venn.js@2.0.0':
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
'@vercel/nft@1.5.0':
resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==}
engines: {node: '>=20'}
@ -5548,14 +5543,6 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
chevrotain-allstar@0.3.1:
resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==}
peerDependencies:
chevrotain: ^11.0.0
chevrotain@11.1.2:
resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@ -6031,8 +6018,8 @@ packages:
resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
engines: {node: '>=12'}
dagre-d3-es@7.0.13:
resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==}
dagre-d3-es@7.0.14:
resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@ -6231,8 +6218,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
dompurify@3.4.2:
resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@ -6401,6 +6388,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
es-toolkit@1.46.1:
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
es6-error@4.1.1:
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
@ -7828,10 +7818,6 @@ packages:
resolution: {integrity: sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==}
engines: {node: '>= 18'}
langium@4.2.1:
resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==}
engines: {node: '>=20.10.0', npm: '>=10.2.3'}
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@ -7908,8 +7894,8 @@ packages:
resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==}
engines: {node: '>=20'}
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@ -8122,8 +8108,8 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
mermaid@11.12.3:
resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==}
mermaid@11.15.0:
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@ -10651,8 +10637,8 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
vary@1.1.2:
@ -10876,23 +10862,6 @@ packages:
jsdom:
optional: true
vscode-jsonrpc@8.2.0:
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
engines: {node: '>=14.0.0'}
vscode-languageserver-protocol@3.17.5:
resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==}
vscode-languageserver-textdocument@1.0.12:
resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==}
vscode-languageserver-types@3.17.5:
resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==}
vscode-languageserver@9.0.1:
resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==}
hasBin: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@ -11125,6 +11094,11 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@ -11504,23 +11478,8 @@ snapshots:
'@braintree/sanitize-url@7.1.2': {}
'@chevrotain/cst-dts-gen@11.1.2':
dependencies:
'@chevrotain/gast': 11.1.2
'@chevrotain/types': 11.1.2
lodash-es: 4.17.23
'@chevrotain/gast@11.1.2':
dependencies:
'@chevrotain/types': 11.1.2
lodash-es: 4.17.23
'@chevrotain/regexp-to-ast@11.1.2': {}
'@chevrotain/types@11.1.2': {}
'@chevrotain/utils@11.1.2': {}
'@clack/core@1.1.0':
dependencies:
sisteransi: 1.0.5
@ -12745,9 +12704,9 @@ snapshots:
'@mdi/js@7.4.47': {}
'@mermaid-js/parser@1.0.0':
'@mermaid-js/parser@1.1.1':
dependencies:
langium: 4.2.1
'@chevrotain/types': 11.1.2
'@miyaneee/rollup-plugin-json5@1.2.0(rollup@4.60.0)':
dependencies:
@ -12857,20 +12816,20 @@ snapshots:
'@nuxt/devalue@2.0.2': {}
'@nuxt/devtools-kit@1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@nuxt/devtools-kit@1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@nuxt/kit': 3.21.2(magicast@0.5.2)
'@nuxt/schema': 3.21.2
execa: 7.2.0
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- magicast
'@nuxt/devtools-kit@3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@nuxt/devtools-kit@3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@nuxt/kit': 4.4.2(magicast@0.5.2)
execa: 8.0.1
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- magicast
@ -12885,9 +12844,9 @@ snapshots:
pkg-types: 2.3.0
semver: 7.7.4
'@nuxt/devtools@3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))':
'@nuxt/devtools@3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@nuxt/devtools-wizard': 3.2.4
'@nuxt/kit': 4.4.2(magicast@0.5.2)
'@vue/devtools-core': 8.1.0(vue@3.5.30(typescript@5.9.3))
@ -12915,9 +12874,9 @@ snapshots:
sirv: 3.0.2
structured-clone-es: 2.0.0
tinyglobby: 0.2.15
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite-plugin-vue-tracer: 1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
vite-plugin-vue-tracer: 1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))
which: 6.0.1
ws: 8.20.0
transitivePeerDependencies:
@ -12966,10 +12925,10 @@ snapshots:
- supports-color
- typescript
'@nuxt/eslint@1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@nuxt/eslint@1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@eslint/config-inspector': 1.5.0(eslint@9.39.2(jiti@2.6.1))
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@nuxt/eslint-config': 1.15.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@nuxt/eslint-plugin': 1.15.2(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@nuxt/kit': 4.4.2(magicast@0.5.2)
@ -13045,7 +13004,7 @@ snapshots:
transitivePeerDependencies:
- magicast
'@nuxt/nitro-server@3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3)':
'@nuxt/nitro-server@3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0))(typescript@5.9.3)':
dependencies:
'@nuxt/devalue': 2.0.2
'@nuxt/kit': 3.21.2(magicast@0.5.2)
@ -13063,7 +13022,7 @@ snapshots:
klona: 2.0.6
mocked-exports: 0.1.1
nitropack: 2.13.2(encoding@0.1.13)(idb-keyval@6.2.2)
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)
ohash: 2.0.11
pathe: 2.0.3
pkg-types: 2.3.0
@ -13128,12 +13087,12 @@ snapshots:
rc9: 3.0.0
std-env: 3.10.0
'@nuxt/vite-builder@3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)':
'@nuxt/vite-builder@3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.9.0)':
dependencies:
'@nuxt/kit': 3.21.2(magicast@0.5.2)
'@rollup/plugin-replace': 6.0.3(rollup@4.60.0)
'@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
'@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))
autoprefixer: 10.5.0(postcss@8.5.8)
consola: 3.4.2
cssnano: 7.1.3(postcss@8.5.8)
@ -13147,7 +13106,7 @@ snapshots:
magic-string: 0.30.21
mlly: 1.8.2
mocked-exports: 0.1.1
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)
nypm: 0.6.5
ohash: 2.0.11
pathe: 2.0.3
@ -13158,9 +13117,9 @@ snapshots:
std-env: 4.0.0
ufo: 1.6.3
unenv: 2.0.0-rc.24
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite-node: 5.3.0(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-checker: 0.12.0(eslint@9.39.2(jiti@2.6.1))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite-node: 5.3.0(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite-plugin-checker: 0.12.0(eslint@9.39.2(jiti@2.6.1))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
vue: 3.5.30(typescript@5.9.3)
vue-bundle-renderer: 2.2.0
optionalDependencies:
@ -14875,10 +14834,10 @@ snapshots:
dependencies:
defer-to-connect: 2.0.1
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))':
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.9.0)
'@tanstack/react-virtual@3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
@ -15638,6 +15597,11 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@upsetjs/venn.js@2.0.0':
optionalDependencies:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
'@vercel/nft@1.5.0(encoding@0.1.13)(rollup@4.60.0)':
dependencies:
'@mapbox/node-pre-gyp': 2.0.3(encoding@0.1.13)
@ -15669,22 +15633,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))':
'@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@rolldown/pluginutils': 1.0.0-rc.11
'@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vue: 3.5.30(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vue: 3.5.30(typescript@5.9.3)
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(sass@1.98.0)(terser@5.46.0))':
@ -15955,13 +15919,13 @@ snapshots:
'@vueuse/metadata@14.3.0': {}
'@vueuse/nuxt@10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))':
'@vueuse/nuxt@10.11.1(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@nuxt/kit': 3.21.2(magicast@0.5.2)
'@vueuse/core': 10.11.1(vue@3.5.30(typescript@5.9.3))
'@vueuse/metadata': 10.11.1
local-pkg: 0.5.1
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
nuxt: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)
vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/composition-api'
@ -16614,20 +16578,6 @@ snapshots:
check-error@2.1.3: {}
chevrotain-allstar@0.3.1(chevrotain@11.1.2):
dependencies:
chevrotain: 11.1.2
lodash-es: 4.17.23
chevrotain@11.1.2:
dependencies:
'@chevrotain/cst-dts-gen': 11.1.2
'@chevrotain/gast': 11.1.2
'@chevrotain/regexp-to-ast': 11.1.2
'@chevrotain/types': 11.1.2
'@chevrotain/utils': 11.1.2
lodash-es: 4.17.23
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@ -17129,10 +17079,10 @@ snapshots:
d3-transition: 3.0.1(d3-selection@3.0.0)
d3-zoom: 3.0.0
dagre-d3-es@7.0.13:
dagre-d3-es@7.0.14:
dependencies:
d3: 7.9.0
lodash-es: 4.17.23
lodash-es: 4.18.1
damerau-levenshtein@1.0.8: {}
@ -17298,7 +17248,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.3.1:
dompurify@3.4.2:
optionalDependencies:
'@types/trusted-types': 2.0.7
@ -17566,6 +17516,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
es-toolkit@1.46.1: {}
es6-error@4.1.1:
optional: true
@ -17955,11 +17907,11 @@ snapshots:
semver: 7.7.3
typescript: 5.9.3
eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)):
eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
fast-glob: 3.3.3
postcss: 8.5.6
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.9.0)
eslint-plugin-unicorn@63.0.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
@ -19447,14 +19399,6 @@ snapshots:
type-is: 2.0.1
vary: 1.1.2
langium@4.2.1:
dependencies:
chevrotain: 11.1.2
chevrotain-allstar: 0.3.1(chevrotain@11.1.2)
vscode-languageserver: 9.0.1
vscode-languageserver-textdocument: 1.0.12
vscode-uri: 3.1.0
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@ -19505,7 +19449,7 @@ snapshots:
nano-spawn: 2.0.0
pidtree: 0.6.0
string-argv: 0.3.2
yaml: 2.8.2
yaml: 2.9.0
listhen@1.9.0:
dependencies:
@ -19558,7 +19502,7 @@ snapshots:
dependencies:
p-locate: 6.0.0
lodash-es@4.17.23: {}
lodash-es@4.18.1: {}
lodash.defaults@4.2.0: {}
@ -19904,28 +19848,29 @@ snapshots:
merge2@1.4.1: {}
mermaid@11.12.3:
mermaid@11.15.0:
dependencies:
'@braintree/sanitize-url': 7.1.2
'@iconify/utils': 3.1.0
'@mermaid-js/parser': 1.0.0
'@mermaid-js/parser': 1.1.1
'@types/d3': 7.4.3
'@upsetjs/venn.js': 2.0.0
cytoscape: 3.33.1
cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1)
cytoscape-fcose: 2.2.0(cytoscape@3.33.1)
d3: 7.9.0
d3-sankey: 0.12.3
dagre-d3-es: 7.0.13
dagre-d3-es: 7.0.14
dayjs: 1.11.19
dompurify: 3.3.1
dompurify: 3.4.2
es-toolkit: 1.46.1
katex: 0.16.33
khroma: 2.1.0
lodash-es: 4.17.23
marked: 16.4.2
roughjs: 4.6.6
stylis: 4.3.6
ts-dedent: 2.2.0
uuid: 11.1.0
uuid: 11.1.1
micromark-core-commonmark@2.0.3:
dependencies:
@ -20467,27 +20412,27 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuxt-icon@0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)):
nuxt-icon@0.6.10(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)):
dependencies:
'@iconify/collections': 1.0.665
'@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3))
'@nuxt/devtools-kit': 1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@nuxt/devtools-kit': 1.7.0(magicast@0.5.2)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@nuxt/kit': 3.21.2(magicast@0.5.2)
transitivePeerDependencies:
- magicast
- vite
- vue
nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2):
nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0):
dependencies:
'@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3)
'@nuxt/cli': 3.34.0(@nuxt/schema@3.21.2)(cac@6.7.14)(magicast@0.5.2)
'@nuxt/devtools': 3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
'@nuxt/devtools': 3.2.4(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))
'@nuxt/kit': 3.21.2(magicast@0.5.2)
'@nuxt/nitro-server': 3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(typescript@5.9.3)
'@nuxt/nitro-server': 3.21.2(db0@0.3.4)(encoding@0.1.13)(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0))(typescript@5.9.3)
'@nuxt/schema': 3.21.2
'@nuxt/telemetry': 2.7.0(@nuxt/kit@3.21.2(magicast@0.5.2))
'@nuxt/vite-builder': 3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2)
'@nuxt/vite-builder': 3.21.2(@types/node@25.0.7)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(nuxt@3.21.2(@parcel/watcher@2.5.6)(@types/node@25.0.7)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.0))(rollup@4.60.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))(yaml@2.9.0)
'@unhead/vue': 2.1.12(vue@3.5.30(typescript@5.9.3))
'@vue/shared': 3.5.30
c12: 3.3.3(magicast@0.5.2)
@ -21111,23 +21056,23 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.5.6
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2):
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.6
tsx: 4.21.0
yaml: 2.8.2
yaml: 2.9.0
postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2):
postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 2.6.1
postcss: 8.5.8
tsx: 4.21.0
yaml: 2.8.2
yaml: 2.9.0
postcss-merge-longhand@7.0.5(postcss@8.5.8):
dependencies:
@ -22461,11 +22406,11 @@ snapshots:
tailwind-merge@3.5.0: {}
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)):
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.9.0)
tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2):
tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@ -22484,7 +22429,7 @@ snapshots:
postcss: 8.5.6
postcss-import: 15.1.0(postcss@8.5.6)
postcss-js: 4.1.0(postcss@8.5.6)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.9.0)
postcss-nested: 6.2.0(postcss@8.5.6)
postcss-selector-parser: 6.1.2
resolve: 1.22.11
@ -22655,7 +22600,7 @@ snapshots:
tsscmp@1.0.6: {}
tsup@8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
tsup@8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.2)
cac: 6.7.14
@ -22666,7 +22611,7 @@ snapshots:
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
picocolors: 1.1.1
postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2)
postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.9.0)
resolve-from: 5.0.0
rollup: 4.55.1
source-map: 0.7.6
@ -23084,7 +23029,7 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@11.1.0: {}
uuid@11.1.1: {}
vary@1.1.2: {}
@ -23110,15 +23055,15 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
vite-dev-rpc@1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
birpc: 2.9.0
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite-hot-client: 2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite-hot-client: 2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
vite-hot-client@2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
vite-hot-client@2.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite-node@3.2.4(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0):
dependencies:
@ -23156,13 +23101,13 @@ snapshots:
- supports-color
- terser
vite-node@5.3.0(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
vite-node@5.3.0(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
cac: 6.7.14
es-module-lexer: 2.0.0
obug: 2.1.1
pathe: 2.0.3
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@ -23176,7 +23121,7 @@ snapshots:
- tsx
- yaml
vite-plugin-checker@0.12.0(eslint@9.39.2(jiti@2.6.1))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
vite-plugin-checker@0.12.0(eslint@9.39.2(jiti@2.6.1))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
'@babel/code-frame': 7.29.0
chokidar: 4.0.3
@ -23185,14 +23130,14 @@ snapshots:
picomatch: 4.0.3
tiny-invariant: 1.3.3
tinyglobby: 0.2.15
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vscode-uri: 3.1.0
optionalDependencies:
eslint: 9.39.2(jiti@2.6.1)
optionator: 0.9.4
typescript: 5.9.3
vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.2(magicast@0.5.2))(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
ansis: 4.2.0
debug: 4.4.3
@ -23202,29 +23147,29 @@ snapshots:
perfect-debounce: 2.1.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite-dev-rpc: 1.1.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
optionalDependencies:
'@nuxt/kit': 4.4.2(magicast@0.5.2)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-tracer@1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)):
vite-plugin-vue-tracer@1.3.0(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)):
dependencies:
estree-walker: 3.0.3
exsolve: 1.0.8
magic-string: 0.30.21
pathe: 2.0.3
source-map-js: 1.2.1
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vue: 3.5.30(typescript@5.9.3)
vite-plugin-vuetify@2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3):
vite-plugin-vuetify@2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3):
dependencies:
'@vuetify/loader-shared': 2.1.2(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
debug: 4.4.3
upath: 2.0.1
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vue: 3.5.30(typescript@5.9.3)
vuetify: 3.12.3(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.30(typescript@5.9.3))
transitivePeerDependencies:
@ -23252,7 +23197,7 @@ snapshots:
sass: 1.98.0
terser: 5.46.0
vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
esbuild: 0.27.4
fdir: 6.5.0(picomatch@4.0.3)
@ -23267,7 +23212,7 @@ snapshots:
sass: 1.98.0
terser: 5.46.0
tsx: 4.21.0
yaml: 2.8.2
yaml: 2.9.0
vitepress-codeblock-collapse@1.0.0(vue@3.5.30(typescript@5.9.3)):
dependencies:
@ -23292,7 +23237,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
vitepress@2.0.0-alpha.17(@types/node@25.0.7)(axios@1.13.6)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.117.0)(postcss@8.5.8)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
vitepress@2.0.0-alpha.17(@types/node@25.0.7)(axios@1.13.6)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jiti@2.6.1)(oxc-minify@0.117.0)(postcss@8.5.8)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
dependencies:
'@docsearch/css': 4.6.3
'@docsearch/js': 4.6.3
@ -23302,7 +23247,7 @@ snapshots:
'@shikijs/transformers': 3.22.0
'@shikijs/types': 3.23.0
'@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
'@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))
'@vue/devtools-api': 8.1.1
'@vue/shared': 3.5.30
'@vueuse/core': 14.3.0(vue@3.5.30(typescript@5.9.3))
@ -23311,7 +23256,7 @@ snapshots:
mark.js: 8.11.1
minisearch: 7.2.0
shiki: 3.23.0
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vue: 3.5.30(typescript@5.9.3)
optionalDependencies:
oxc-minify: 0.117.0
@ -23421,21 +23366,6 @@ snapshots:
- supports-color
- terser
vscode-jsonrpc@8.2.0: {}
vscode-languageserver-protocol@3.17.5:
dependencies:
vscode-jsonrpc: 8.2.0
vscode-languageserver-types: 3.17.5
vscode-languageserver-textdocument@1.0.12: {}
vscode-languageserver-types@3.17.5: {}
vscode-languageserver@9.0.1:
dependencies:
vscode-languageserver-protocol: 3.17.5
vscode-uri@3.1.0: {}
vue-bundle-renderer@2.2.0:
@ -23495,7 +23425,7 @@ snapshots:
vue: 3.5.30(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
vite-plugin-vuetify: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
vite-plugin-vuetify: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
w3c-keyname@2.2.8: {}
@ -23639,6 +23569,8 @@ snapshots:
yaml@2.8.2: {}
yaml@2.9.0: {}
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}

View file

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

View file

@ -770,8 +770,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
}
private async loadApiKeyAvailability(): Promise<CodexApiKeyAvailabilityDto> {
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
if (storedKey?.value.trim()) {
if (await this.apiKeyService.hasPreferred('OPENAI_API_KEY')) {
return {
available: true,
source: 'stored',

View file

@ -42,6 +42,7 @@ export class ClaudeMemberTranscriptPreviewSource implements MemberLogPreviewSour
[input.memberName],
{
forceRefresh: input.forceRefresh === true,
includeTeamSubagentSessionDiscovery: false,
}
);
const dedupedRefs = dedupeMemberLogRefs(refs);

View file

@ -125,7 +125,6 @@ import {
PluginCatalogService,
PluginInstallationStateService,
PluginInstallService,
RUNTIME_MANAGED_API_KEY_ENV_VARS,
SkillsCatalogService,
SkillsMutationService,
SkillsWatcherService,
@ -1478,7 +1477,6 @@ async function initializeServices(): Promise<void> {
phase: 'settings',
message: 'Loading secure settings...',
});
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
// warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
httpServer = new HttpServer();

View file

@ -28,10 +28,7 @@ import {
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import {
type ApiKeyService,
RUNTIME_MANAGED_API_KEY_ENV_VARS,
} from '../services/extensions/apikeys/ApiKeyService';
import { type ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
@ -396,12 +393,7 @@ async function handleApiKeysSave(
): Promise<IpcResult<ApiKeyEntry>> {
return wrapHandler('apiKeysSave', () => {
if (!request) throw new Error('Request is required');
return getApiKeyService()
.save(request)
.then(async (entry) => {
await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
return entry;
});
return getApiKeyService().save(request);
});
}
@ -411,11 +403,7 @@ async function handleApiKeysDelete(
): Promise<IpcResult<void>> {
return wrapHandler('apiKeysDelete', () => {
if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
return getApiKeyService()
.delete(id)
.then(async () => {
await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
});
return getApiKeyService().delete(id);
});
}

View file

@ -215,9 +215,9 @@ import type {
TeamCreateResponse,
TeamFastMode,
TeamGetDataOptions,
TeamLaunchFailureDiagnosticsBundle,
TeamLaunchRequest,
TeamLaunchResponse,
TeamLaunchFailureDiagnosticsBundle,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProviderBackendId,

View file

@ -51,14 +51,11 @@ const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_KEY_BYTES = 32;
const PBKDF2_SALT = 'claude-apikey-storage-v1';
export const RUNTIME_MANAGED_API_KEY_ENV_VARS = ['GEMINI_API_KEY'] as const;
export class ApiKeyService {
private readonly filePath: string;
private cache: StoredApiKey[] | null = null;
private aesKey: Buffer | null = null;
private readonly reportedDecryptFailures = new Set<string>();
private readonly originalProcessEnv = new Map<string, string | undefined>();
constructor(claudeDir?: string) {
const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
@ -185,6 +182,16 @@ export class ApiKeyService {
};
}
async hasPreferred(envVarName: string, projectPath?: string): Promise<boolean> {
const keys = await this.readStore();
const preferred = this.pickPreferredKey(
keys.filter((key) => key.envVarName === envVarName),
projectPath
);
return Boolean(preferred?.encryptedValue);
}
async getStorageStatus(): Promise<ApiKeyStorageStatus> {
const secure = this.isSecureBackend();
const backend = this.getBackendName();
@ -204,31 +211,6 @@ export class ApiKeyService {
};
}
async syncProcessEnv(envVarNames: readonly string[]): Promise<void> {
if (!envVarNames.length) {
return;
}
for (const envVarName of envVarNames) {
if (!this.originalProcessEnv.has(envVarName)) {
this.originalProcessEnv.set(envVarName, process.env[envVarName]);
}
const nextValue = (await this.lookupPreferred(envVarName))?.value;
if (nextValue && nextValue.trim().length > 0) {
process.env[envVarName] = nextValue;
continue;
}
const originalValue = this.originalProcessEnv.get(envVarName);
if (typeof originalValue === 'string' && originalValue.length > 0) {
process.env[envVarName] = originalValue;
} else {
delete process.env[envVarName];
}
}
}
// ── Encryption ──────────────────────────────────────────────────────────
/**

View file

@ -2,7 +2,7 @@
* Extension services barrel export.
*/
export { ApiKeyService, RUNTIME_MANAGED_API_KEY_ENV_VARS } from './apikeys/ApiKeyService';
export { ApiKeyService } from './apikeys/ApiKeyService';
export { GitHubStarsService } from './catalog/GitHubStarsService';
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';

View file

@ -18,6 +18,7 @@ async function buildManagementCliEnvForBinary(binaryPath: string): Promise<NodeJ
const { env } = await buildProviderAwareCliEnv({
binaryPath,
connectionMode: 'augment',
allowStoredApiKeyDecryption: false,
});
return env;
}

View file

@ -42,7 +42,7 @@ interface OpenCodeRuntimeManifest {
installedAt: string;
}
interface PlatformCandidate {
export interface PlatformCandidate {
packageName: string;
reason: string;
}
@ -98,6 +98,22 @@ export function resolveAppManagedOpenCodeRuntimeBinaryPath(): string | null {
return isAbsoluteExistingFile(manifest?.binaryPath) ? manifest.binaryPath : null;
}
export async function resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(): Promise<string | null> {
const binaryPath = resolveAppManagedOpenCodeRuntimeBinaryPath();
if (!binaryPath) {
return null;
}
try {
await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
return binaryPath;
} catch {
return null;
}
}
function getExecutableName(): string {
return process.platform === 'win32' ? 'opencode.exe' : 'opencode';
}
@ -150,10 +166,12 @@ function isLinuxMuslRuntime(): boolean {
return !header?.glibcVersionRuntime;
}
function getPlatformCandidates(): PlatformCandidate[] {
const arch = process.arch;
const musl = isLinuxMuslRuntime();
if (process.platform === 'darwin') {
export function getOpenCodeRuntimePlatformCandidates(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
musl: boolean = isLinuxMuslRuntime()
): PlatformCandidate[] {
if (platform === 'darwin') {
if (arch === 'arm64') return [{ packageName: 'opencode-darwin-arm64', reason: 'macOS arm64' }];
if (arch === 'x64') {
return [
@ -162,7 +180,7 @@ function getPlatformCandidates(): PlatformCandidate[] {
];
}
}
if (process.platform === 'linux') {
if (platform === 'linux') {
if (arch === 'arm64') {
return musl
? [
@ -191,7 +209,7 @@ function getPlatformCandidates(): PlatformCandidate[] {
];
}
}
if (process.platform === 'win32') {
if (platform === 'win32') {
if (arch === 'arm64')
return [{ packageName: 'opencode-windows-arm64', reason: 'Windows arm64' }];
if (arch === 'x64') {
@ -201,7 +219,7 @@ function getPlatformCandidates(): PlatformCandidate[] {
];
}
}
throw new Error(`OpenCode app install is not supported on ${process.platform}/${arch}`);
throw new Error(`OpenCode app install is not supported on ${platform}/${arch}`);
}
async function fetchText(url: string): Promise<string> {
@ -231,7 +249,7 @@ async function fetchPackageMetadata(
return parsed;
}
function verifyIntegrity(buffer: Buffer, integrity: string): void {
export function verifyOpenCodeRuntimePackageIntegrity(buffer: Buffer, integrity: string): void {
const match = /^sha512-([A-Za-z0-9+/=]+)$/.exec(integrity.trim());
if (!match) {
throw new Error('OpenCode package integrity is missing sha512 metadata');
@ -320,7 +338,7 @@ function assertSafeTarPath(name: string): void {
}
}
function extractBinaryFromTarball(tarball: Buffer): Buffer {
export function extractOpenCodeRuntimeBinaryFromTarball(tarball: Buffer): Buffer {
const tar = gunzipSync(tarball, { maxOutputLength: MAX_BINARY_BYTES + 1024 * 1024 });
const targetName = `package/bin/${getExecutableName()}`;
let offset = 0;
@ -479,7 +497,7 @@ export class OpenCodeRuntimeInstallerService {
try {
this.publishProgress({ phase: 'checking', detail: 'Resolving latest OpenCode package...' });
const rootMetadata = await fetchPackageMetadata(ROOT_PACKAGE_NAME);
const candidates = getPlatformCandidates();
const candidates = getOpenCodeRuntimePlatformCandidates();
const optionalDependencies = rootMetadata.optionalDependencies ?? {};
const selected = candidates.find((candidate) => optionalDependencies[candidate.packageName]);
if (!selected) {
@ -498,10 +516,10 @@ export class OpenCodeRuntimeInstallerService {
const tarball = await downloadTarball(platformMetadata.dist!.tarball!, (progress) => {
this.publishProgress(progress);
});
verifyIntegrity(tarball, platformMetadata.dist!.integrity!);
verifyOpenCodeRuntimePackageIntegrity(tarball, platformMetadata.dist!.integrity!);
this.publishProgress({ phase: 'installing', detail: 'Extracting OpenCode binary...' });
const binary = extractBinaryFromTarball(tarball);
const binary = extractOpenCodeRuntimeBinaryFromTarball(tarball);
const runtimeRoot = getRuntimeRootPath();
const tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
const versionDir = path.join(

View file

@ -58,6 +58,7 @@ export class PtyTerminalService {
const { env } = await buildProviderAwareCliEnv({
env: options?.env,
connectionMode: 'augment',
allowStoredApiKeyDecryption: false,
});
const shell =
options?.command ??

View file

@ -24,6 +24,8 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
interface RuntimeExtensionCapabilityResponse {
status?: 'supported' | 'read-only' | 'unsupported';
@ -82,6 +84,7 @@ interface RuntimeProviderModelCatalogItemResponse {
source?: 'anthropic-models-api' | 'app-server' | 'static-fallback';
badgeLabel?: string | null;
statusMessage?: string | null;
metadata?: Record<string, unknown> | null;
}
interface RuntimeProviderModelCatalogResponse {
@ -478,6 +481,21 @@ function collectRuntimeReasoningEfforts(values?: string[]): CliProviderReasoning
);
}
function mapRuntimeProviderModelMetadata(
metadata?: Record<string, unknown> | null
): NonNullable<CliProviderStatus['modelCatalog']>['models'][number]['metadata'] {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
return null;
}
const context = metadata.context;
return {
cost: metadata.cost ?? null,
context: typeof context === 'number' && Number.isFinite(context) ? context : null,
limits: metadata.limits ?? null,
free: metadata.free === true,
};
}
function mapRuntimeProviderModelCatalog(
providerId: CliProviderId,
modelCatalog?: RuntimeProviderModelCatalogResponse | null
@ -540,6 +558,7 @@ function mapRuntimeProviderModelCatalog(
source: itemSource,
badgeLabel: model.badgeLabel ?? null,
statusMessage: model.statusMessage ?? null,
metadata: mapRuntimeProviderModelMetadata(model.metadata),
},
];
}) ?? [];
@ -605,14 +624,18 @@ export class ClaudeMultimodelBridgeService {
private async buildCliEnv(
binaryPath: string
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
return buildProviderAwareCliEnv({ binaryPath });
return buildProviderAwareCliEnv({ binaryPath, allowStoredApiKeyDecryption: false });
}
private async buildProviderCliEnv(
binaryPath: string,
providerId: CliProviderId
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
return buildProviderAwareCliEnv({ binaryPath, providerId });
return buildProviderAwareCliEnv({
binaryPath,
providerId,
allowStoredApiKeyDecryption: false,
});
}
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
@ -769,6 +792,7 @@ export class ClaudeMultimodelBridgeService {
['runtime', 'status', '--json', '--provider', providerId],
{
timeout: PROVIDER_STATUS_TIMEOUT_MS,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
}
);
@ -850,6 +874,7 @@ export class ClaudeMultimodelBridgeService {
['runtime', 'verify', '--json', '--provider', 'opencode'],
{
timeout: PROVIDER_STATUS_TIMEOUT_MS,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
}
);
@ -1048,6 +1073,7 @@ export class ClaudeMultimodelBridgeService {
['model', 'list', '--json', '--provider', 'all'],
{
timeout: PROVIDER_MODELS_TIMEOUT_MS,
maxBuffer: PROVIDER_MODELS_MAX_BUFFER_BYTES,
env,
}
);
@ -1119,6 +1145,7 @@ export class ClaudeMultimodelBridgeService {
try {
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
});
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
@ -1145,10 +1172,12 @@ export class ClaudeMultimodelBridgeService {
const [statusResult, modelsResult] = await Promise.allSettled([
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
}),
execCli(binaryPath, ['model', 'list', '--json', '--provider', 'all'], {
timeout: PROVIDER_MODELS_TIMEOUT_MS,
maxBuffer: PROVIDER_MODELS_MAX_BUFFER_BYTES,
env,
}),
]);

View file

@ -193,6 +193,7 @@ export class CliProviderModelAvailabilityService {
cliEnvPromise: buildProviderAwareCliEnv({
binaryPath: context.binaryPath,
providerId: context.provider.providerId,
allowStoredApiKeyDecryption: false,
}).then((result) => ({
env: result.env,
providerArgs: result.providerArgs ?? [],

View file

@ -34,6 +34,10 @@ type ExternalCredential = {
value: string;
} | null;
interface StoredApiKeyAccessOptions {
allowStoredApiKeyDecryption?: boolean;
}
const PROVIDER_CAPABILITIES: Record<
CliProviderId,
Pick<CliProviderConnectionInfo, 'supportsOAuth' | 'supportsApiKey' | 'configurableAuthModes'>
@ -258,7 +262,8 @@ export class ProviderConnectionService {
async applyConfiguredConnectionEnv(
env: NodeJS.ProcessEnv,
providerId: CliProviderId,
runtimeBackendOverride?: string | null
runtimeBackendOverride?: string | null,
options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> {
if (providerId === 'anthropic') {
const authMode = this.getConfiguredAuthMode(providerId);
@ -272,7 +277,7 @@ export class ProviderConnectionService {
return env;
}
const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY');
const storedKey = await this.lookupStoredApiKeyValue('ANTHROPIC_API_KEY', options);
if (storedKey?.value.trim()) {
env.ANTHROPIC_API_KEY = storedKey.value;
delete env.ANTHROPIC_AUTH_TOKEN;
@ -288,6 +293,14 @@ export class ProviderConnectionService {
return env;
}
if (providerId === 'gemini') {
const storedKey = await this.lookupStoredApiKeyValue('GEMINI_API_KEY', options);
if (storedKey?.value.trim()) {
env.GEMINI_API_KEY = storedKey.value;
}
return env;
}
if (providerId !== 'codex') {
return env;
}
@ -310,7 +323,7 @@ export class ProviderConnectionService {
return env;
}
const resolvedApiKey = await this.resolveCodexApiKeyValue(env, runtimeBackendOverride);
const resolvedApiKey = await this.resolveCodexApiKeyValue(env, runtimeBackendOverride, options);
if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) {
env.OPENAI_API_KEY = resolvedApiKey;
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
@ -327,10 +340,13 @@ export class ProviderConnectionService {
return env;
}
async applyAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
async applyAllConfiguredConnectionEnv(
env: NodeJS.ProcessEnv,
options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> {
let nextEnv = env;
for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) {
nextEnv = await this.applyConfiguredConnectionEnv(nextEnv, providerId);
nextEnv = await this.applyConfiguredConnectionEnv(nextEnv, providerId, undefined, options);
}
return nextEnv;
}
@ -338,20 +354,29 @@ export class ProviderConnectionService {
async augmentConfiguredConnectionEnv(
env: NodeJS.ProcessEnv,
providerId: CliProviderId,
runtimeBackendOverride?: string | null
runtimeBackendOverride?: string | null,
options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> {
if (providerId === 'anthropic') {
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
return env;
}
const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY');
const storedKey = await this.lookupStoredApiKeyValue('ANTHROPIC_API_KEY', options);
if (storedKey?.value.trim()) {
env.ANTHROPIC_API_KEY = storedKey.value;
}
return env;
}
if (providerId === 'gemini') {
const storedKey = await this.lookupStoredApiKeyValue('GEMINI_API_KEY', options);
if (storedKey?.value.trim()) {
env.GEMINI_API_KEY = storedKey.value;
}
return env;
}
if (providerId !== 'codex') {
return env;
}
@ -374,7 +399,7 @@ export class ProviderConnectionService {
return env;
}
const resolvedApiKey = await this.resolveCodexApiKeyValue(env, runtimeBackendOverride);
const resolvedApiKey = await this.resolveCodexApiKeyValue(env, runtimeBackendOverride, options);
if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) {
env.OPENAI_API_KEY = resolvedApiKey;
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
@ -386,10 +411,13 @@ export class ProviderConnectionService {
return env;
}
async augmentAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
async augmentAllConfiguredConnectionEnv(
env: NodeJS.ProcessEnv,
options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> {
let nextEnv = env;
for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) {
nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId);
nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId, undefined, options);
}
return nextEnv;
}
@ -408,8 +436,7 @@ export class ProviderConnectionService {
return null;
}
const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY');
if (storedKey?.value.trim()) {
if (await this.hasStoredApiKey('ANTHROPIC_API_KEY')) {
return null;
}
@ -654,7 +681,7 @@ export class ProviderConnectionService {
async getConnectionInfo(providerId: CliProviderId): Promise<CliProviderConnectionInfo> {
const capabilities = PROVIDER_CAPABILITIES[providerId];
const storedApiKey = await this.getStoredApiKey(providerId);
const hasStoredApiKey = await this.hasStoredProviderApiKey(providerId);
const externalCredential = this.getExternalCredential(providerId);
const codexSnapshot = providerId === 'codex' ? await this.getCodexAccountSnapshot() : null;
const configurableAuthModes = capabilities.configurableAuthModes;
@ -665,11 +692,11 @@ export class ProviderConnectionService {
const apiKeyConfigured =
providerId === 'codex'
? (codexSnapshot?.apiKey.available ?? false)
: Boolean(storedApiKey?.value.trim() || externalCredential?.value.trim());
: Boolean(hasStoredApiKey || externalCredential?.value.trim());
const apiKeySource =
providerId === 'codex'
? (codexSnapshot?.apiKey.source ?? null)
: storedApiKey?.value.trim()
: hasStoredApiKey
? 'stored'
: externalCredential?.value.trim()
? 'environment'
@ -677,7 +704,7 @@ export class ProviderConnectionService {
const apiKeySourceLabel =
providerId === 'codex'
? (codexSnapshot?.apiKey.sourceLabel ?? null)
: storedApiKey?.value.trim()
: hasStoredApiKey
? 'Stored in app'
: (externalCredential?.label ?? null);
@ -709,11 +736,33 @@ export class ProviderConnectionService {
};
}
private async getStoredApiKey(
providerId: CliProviderId
): Promise<{ envVarName: string; value: string } | null> {
private async hasStoredProviderApiKey(providerId: CliProviderId): Promise<boolean> {
const envVarName = PROVIDER_API_KEY_ENV_VARS[providerId];
if (!envVarName) {
return false;
}
return this.hasStoredApiKey(envVarName);
}
private async hasStoredApiKey(envVarName: string): Promise<boolean> {
const service = this.apiKeyService as ApiKeyService & {
hasPreferred?: (envVarName: string) => Promise<boolean>;
};
if (typeof service.hasPreferred === 'function') {
return service.hasPreferred(envVarName);
}
const storedKey = await service.lookupPreferred(envVarName);
return Boolean(storedKey?.value.trim());
}
private async lookupStoredApiKeyValue(
envVarName: string,
options?: StoredApiKeyAccessOptions
): Promise<{ envVarName: string; value: string } | null> {
if (options?.allowStoredApiKeyDecryption === false) {
return null;
}
@ -736,17 +785,17 @@ export class ProviderConnectionService {
(this.configManager.getConfig().providerConnections.codex.preferredAuthMode as
| CodexAccountAuthMode
| undefined) ?? 'auto';
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
const hasStoredOpenAiKey = await this.hasStoredApiKey('OPENAI_API_KEY');
const externalCredential = this.getExternalCredential('codex');
const apiKeyAvailable = Boolean(storedKey?.value.trim() || externalCredential?.value.trim());
const apiKeyAvailable = Boolean(hasStoredOpenAiKey || externalCredential?.value.trim());
const apiKey = {
available: apiKeyAvailable,
source: storedKey?.value.trim()
source: hasStoredOpenAiKey
? 'stored'
: externalCredential?.value.trim()
? 'environment'
: null,
sourceLabel: storedKey?.value.trim() ? 'Stored in app' : (externalCredential?.label ?? null),
sourceLabel: hasStoredOpenAiKey ? 'Stored in app' : (externalCredential?.label ?? null),
} satisfies CodexAccountSnapshotDto['apiKey'];
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode,
@ -786,10 +835,11 @@ export class ProviderConnectionService {
private async resolveCodexApiKeyValue(
env: NodeJS.ProcessEnv,
runtimeBackendOverride?: string | null
runtimeBackendOverride?: string | null,
options?: StoredApiKeyAccessOptions
): Promise<string | null> {
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend(runtimeBackendOverride);
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
const storedKey = await this.lookupStoredApiKeyValue('OPENAI_API_KEY', options);
const existingOpenAiKey =
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()
? env.OPENAI_API_KEY

View file

@ -1,6 +1,6 @@
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { resolveAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
import { providerConnectionService } from './ProviderConnectionService';
@ -16,6 +16,7 @@ export interface ProviderAwareCliEnvOptions {
shellEnv?: NodeJS.ProcessEnv | null;
env?: NodeJS.ProcessEnv;
connectionMode?: 'strict' | 'augment';
allowStoredApiKeyDecryption?: boolean;
}
export interface ProviderAwareCliEnvResult {
@ -28,6 +29,10 @@ export async function buildProviderAwareCliEnv(
options: ProviderAwareCliEnvOptions = {}
): Promise<ProviderAwareCliEnvResult> {
const connectionMode = options.connectionMode ?? 'strict';
const storedApiKeyAccessArgs =
options.allowStoredApiKeyDecryption === undefined
? []
: [{ allowStoredApiKeyDecryption: options.allowStoredApiKeyDecryption }];
const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {};
const { env, resolvedProviderId } = buildRuntimeBaseEnv({
binaryPath: options.binaryPath,
@ -36,7 +41,7 @@ export async function buildProviderAwareCliEnv(
shellEnv,
env: options.env,
});
const appManagedOpenCodeBinary = resolveAppManagedOpenCodeRuntimeBinaryPath();
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
if (
appManagedOpenCodeBinary &&
!env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH &&
@ -53,7 +58,8 @@ export async function buildProviderAwareCliEnv(
await providerConnectionService.augmentConfiguredConnectionEnv(
env,
resolvedProviderId,
options.providerBackendId
options.providerBackendId,
...storedApiKeyAccessArgs
);
return {
env,
@ -65,7 +71,8 @@ export async function buildProviderAwareCliEnv(
await providerConnectionService.applyConfiguredConnectionEnv(
env,
resolvedProviderId,
options.providerBackendId
options.providerBackendId,
...storedApiKeyAccessArgs
);
return {
@ -87,7 +94,10 @@ export async function buildProviderAwareCliEnv(
}
if (connectionMode === 'augment') {
await providerConnectionService.augmentAllConfiguredConnectionEnv(env);
await providerConnectionService.augmentAllConfiguredConnectionEnv(
env,
...storedApiKeyAccessArgs
);
return {
env,
connectionIssues: {},
@ -95,7 +105,7 @@ export async function buildProviderAwareCliEnv(
};
}
await providerConnectionService.applyAllConfiguredConnectionEnv(env);
await providerConnectionService.applyAllConfiguredConnectionEnv(env, ...storedApiKeyAccessArgs);
return {
env,
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env),

View file

@ -58,6 +58,7 @@ export interface TeamLaunchFailureArtifactPackResult {
}
export type LaunchFailureArtifactClassificationCode =
| 'workspace_trust_required'
| 'transport_rejected'
| 'stdin_missing'
| 'provider_quota'
@ -215,6 +216,13 @@ function firstEvidence(parts: readonly string[], pattern: RegExp): string[] {
return evidence;
}
const WORKSPACE_TRUST_FAILURE_PATTERN =
/workspace trust is not accepted|cannot start in headless process runtime because workspace trust|open that workspace once interactively and accept trust/i;
export function isWorkspaceTrustLaunchFailureText(value: string): boolean {
return WORKSPACE_TRUST_FAILURE_PATTERN.test(value);
}
export function classifyLaunchFailureArtifact(
input: TeamLaunchFailureArtifactPackInput
): LaunchFailureArtifactClassification {
@ -225,6 +233,11 @@ export function classifyLaunchFailureArtifact(
confidence: number;
pattern: RegExp;
}[] = [
{
code: 'workspace_trust_required',
confidence: 0.96,
pattern: WORKSPACE_TRUST_FAILURE_PATTERN,
},
{
code: 'transport_rejected',
confidence: 0.95,

View file

@ -1,5 +1,5 @@
import { createLogger } from '@shared/utils/logger';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { watch } from 'chokidar';
import { createHash } from 'crypto';
import * as fs from 'fs/promises';
@ -14,7 +14,6 @@ import {
BOARD_TASK_CHANGES_DIRNAME,
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX,
TEAM_TASK_LOG_FRESHNESS_DIRNAME,
classifyLogSourceWatcherEvent,
getRelativeLogSourceParts,
isAgentTranscriptFileName,
@ -22,6 +21,7 @@ import {
MAX_PENDING_UNKNOWN_ROOT_SESSIONS,
normalizeLogSourceSessionId,
PENDING_UNKNOWN_ROOT_SESSION_TTL_MS,
TEAM_TASK_LOG_FRESHNESS_DIRNAME,
} from './teamLogSourceWatchScope';
import type { TeamLogSourceLiveContext, TeamMemberLogsFinder } from './TeamMemberLogsFinder';

View file

@ -101,6 +101,11 @@ interface ProjectSessionDiscovery {
knownMembers: Set<string>;
}
interface ProjectSessionDiscoveryOptions {
forceRefresh?: boolean;
includeTeamSubagentSessionDiscovery?: boolean;
}
interface TaskMentionIndex {
exactTaskIds: Set<string>;
lowerTaskIds: Set<string>;
@ -136,6 +141,7 @@ type FindRecentMemberLogFileRefsOptions =
| {
mtimeSinceMs?: number | null;
forceRefresh?: boolean;
includeTeamSubagentSessionDiscovery?: boolean;
};
export interface TeamLogSourceLiveContext {
@ -193,6 +199,14 @@ function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[]
return roots;
}
function buildProjectSessionDiscoveryCacheKey(
teamName: string,
options?: ProjectSessionDiscoveryOptions
): string {
const subagentMode = options?.includeTeamSubagentSessionDiscovery === false ? 'known' : 'full';
return `${teamName}\0${subagentMode}`;
}
export class TeamMemberLogsFinder {
private readonly taskMentionIndexCache = new Map<string, TaskMentionIndex>();
private readonly taskMentionIndexInFlight = new Map<string, Promise<TaskMentionIndex>>();
@ -200,6 +214,10 @@ export class TeamMemberLogsFinder {
string,
SubagentAttribution | RootSessionAttribution | null
>();
private readonly attributionInFlight = new Map<
string,
Promise<SubagentAttribution | RootSessionAttribution | null>
>();
private readonly discoveryCache = new Map<
string,
{
@ -209,7 +227,11 @@ export class TeamMemberLogsFinder {
>();
private readonly discoveryInFlight = new Map<
string,
{ generation: number; promise: Promise<ProjectSessionDiscovery | null> }
{
generation: number;
promise: Promise<ProjectSessionDiscovery | null>;
forceRefresh: boolean;
}
>();
private readonly discoveryGenerationByTeam = new Map<string, number>();
@ -979,10 +1001,16 @@ export class TeamMemberLogsFinder {
): Promise<MemberLogFileRef[]> {
const parsedOptions =
typeof options === 'number' || options === null
? { mtimeSinceMs: options ?? null, forceRefresh: false }
? {
mtimeSinceMs: options ?? null,
forceRefresh: false,
includeTeamSubagentSessionDiscovery: true,
}
: {
mtimeSinceMs: options?.mtimeSinceMs ?? null,
forceRefresh: options?.forceRefresh === true,
includeTeamSubagentSessionDiscovery:
options?.includeTeamSubagentSessionDiscovery !== false,
};
const requestedMembersByKey = new Map<string, string>();
for (const memberName of memberNames) {
@ -1001,6 +1029,7 @@ export class TeamMemberLogsFinder {
const discovery = await this.discoverProjectSessions(teamName, {
forceRefresh: parsedOptions.forceRefresh,
includeTeamSubagentSessionDiscovery: parsedOptions.includeTeamSubagentSessionDiscovery,
});
if (!discovery) {
return [];
@ -1147,41 +1176,50 @@ export class TeamMemberLogsFinder {
private async discoverProjectSessions(
teamName: string,
options?: { forceRefresh?: boolean }
options?: ProjectSessionDiscoveryOptions
): Promise<ProjectSessionDiscovery | null> {
let generation = this.discoveryGenerationByTeam.get(teamName) ?? 0;
const cacheKey = buildProjectSessionDiscoveryCacheKey(teamName, options);
let generation = this.discoveryGenerationByTeam.get(cacheKey) ?? 0;
if (options?.forceRefresh) {
const inFlight = this.discoveryInFlight.get(cacheKey);
if (inFlight?.forceRefresh === true) {
return inFlight.promise;
}
generation += 1;
this.discoveryGenerationByTeam.set(teamName, generation);
this.discoveryCache.delete(teamName);
this.discoveryInFlight.delete(teamName);
this.discoveryGenerationByTeam.set(cacheKey, generation);
this.discoveryCache.delete(cacheKey);
} else {
// Check discovery cache — avoids re-reading config/dirs within rapid successive calls
const cached = this.discoveryCache.get(teamName);
const cached = this.discoveryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
const inFlight = this.discoveryInFlight.get(teamName);
const inFlight = this.discoveryInFlight.get(cacheKey);
if (inFlight) {
return inFlight.promise;
}
}
const promise = this.loadProjectSessionDiscovery(teamName, options, generation).finally(() => {
const current = this.discoveryInFlight.get(teamName);
const current = this.discoveryInFlight.get(cacheKey);
if (current?.promise === promise) {
this.discoveryInFlight.delete(teamName);
this.discoveryInFlight.delete(cacheKey);
}
});
this.discoveryInFlight.set(teamName, { generation, promise });
this.discoveryInFlight.set(cacheKey, {
generation,
promise,
forceRefresh: options?.forceRefresh === true,
});
return promise;
}
private async loadProjectSessionDiscovery(
teamName: string,
options: { forceRefresh?: boolean } | undefined,
options: ProjectSessionDiscoveryOptions | undefined,
generation: number
): Promise<ProjectSessionDiscovery | null> {
const cacheKey = buildProjectSessionDiscoveryCacheKey(teamName, options);
const context = await this.projectResolver.getContext(teamName, options);
if (!context) {
logger.debug(`No transcript context for team "${teamName}"`);
@ -1214,8 +1252,8 @@ export class TeamMemberLogsFinder {
}
const discovery = { projectDir, projectId, config, sessionIds, knownMembers };
if ((this.discoveryGenerationByTeam.get(teamName) ?? 0) === generation) {
this.discoveryCache.set(teamName, {
if ((this.discoveryGenerationByTeam.get(cacheKey) ?? 0) === generation) {
this.discoveryCache.set(cacheKey, {
result: discovery,
expiresAt: Date.now() + DISCOVERY_CACHE_TTL,
});
@ -1585,7 +1623,15 @@ export class TeamMemberLogsFinder {
if (this.attributionCache.has(cacheKey)) {
return this.attributionCache.get(cacheKey) ?? null;
}
const attribution = await this.attributeSubagent(filePath, knownMembers);
const existing = this.attributionInFlight.get(cacheKey);
if (existing) {
return (await existing) as SubagentAttribution | null;
}
const promise = this.attributeSubagent(filePath, knownMembers).finally(() => {
this.attributionInFlight.delete(cacheKey);
});
this.attributionInFlight.set(cacheKey, promise);
const attribution = await promise;
this.attributionCache.set(cacheKey, attribution);
if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) {
const oldestKey = this.attributionCache.keys().next().value;
@ -1604,7 +1650,15 @@ export class TeamMemberLogsFinder {
if (this.attributionCache.has(cacheKey)) {
return (this.attributionCache.get(cacheKey) as RootSessionAttribution | null) ?? null;
}
const attribution = await this.attributeMemberSession(filePath, teamName, knownMembers);
const existing = this.attributionInFlight.get(cacheKey);
if (existing) {
return (await existing) as RootSessionAttribution | null;
}
const promise = this.attributeMemberSession(filePath, teamName, knownMembers).finally(() => {
this.attributionInFlight.delete(cacheKey);
});
this.attributionInFlight.set(cacheKey, promise);
const attribution = await promise;
this.attributionCache.set(cacheKey, attribution);
if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) {
const oldestKey = this.attributionCache.keys().next().value;

View file

@ -298,7 +298,10 @@ import {
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamInboxWriter } from './TeamInboxWriter';
import { writeTeamLaunchFailureArtifactPack } from './TeamLaunchFailureArtifactPack';
import {
isWorkspaceTrustLaunchFailureText,
writeTeamLaunchFailureArtifactPack,
} from './TeamLaunchFailureArtifactPack';
import {
createPersistedLaunchSnapshot,
deriveTeamLaunchAggregateState,
@ -924,6 +927,12 @@ function classifyDeterministicBootstrapFailure(reason: string): {
} {
const normalizedReason = reason.trim();
const lower = normalizedReason.toLowerCase();
if (isWorkspaceTrustLaunchFailureText(normalizedReason)) {
return {
title: 'Workspace trust required',
normalizedReason,
};
}
if (lower.includes('disabled by kill switch')) {
return {
title: 'Deterministic bootstrap disabled',
@ -1860,6 +1869,8 @@ interface ProvisioningRun {
lastMemberSpawnAuditConfigReadWarningAt: number;
/** Per-member warning throttle for repeated "missing from config" logs. */
lastMemberSpawnAuditMissingWarningAt: Map<string, number>;
/** Prevents duplicate Team Launched notifications for the same live run. */
teamLaunchedNotificationFired?: boolean;
}
const PROVISIONING_TRACE_STORAGE_LIMIT = 500;
@ -11825,12 +11836,7 @@ export class TeamProvisioningService {
);
this.invalidateRuntimeSnapshotCaches(input.teamName);
if (trackedUpdate.changed) {
this.teamChangeEmitter?.({
type: 'member-spawn',
teamName: input.teamName,
runId: input.runId,
detail: input.memberName,
});
this.emitMemberSpawnChange(trackedUpdate.run, input.memberName);
}
return;
}
@ -24788,7 +24794,7 @@ export class TeamProvisioningService {
private emitMemberSpawnChange(
run: Pick<ProvisioningRun, 'teamName' | 'runId'>,
memberName: string
) {
): void {
this.invalidateMemberSpawnStatusesCache(run.teamName);
this.teamChangeEmitter?.({
type: 'member-spawn',
@ -24796,6 +24802,39 @@ export class TeamProvisioningService {
runId: run.runId,
detail: memberName,
});
const trackedRun = this.runs.get(run.runId);
if (trackedRun?.teamName === run.teamName) {
void this.maybeFireTeamLaunchedNotificationWhenAllMembersJoined(trackedRun);
}
}
private async maybeFireTeamLaunchedNotificationWhenAllMembersJoined(
run: ProvisioningRun
): Promise<void> {
if (
!run.isLaunch ||
run.teamLaunchedNotificationFired ||
run.processKilled ||
run.cancelRequested ||
!this.isProvisioningRunPromotedToAlive(run) ||
!this.areAllExpectedLaunchMembersConfirmed(run)
) {
return;
}
await this.fireTeamLaunchedNotification(run);
}
private areAllExpectedLaunchMembersConfirmed(run: ProvisioningRun): boolean {
const expectedMembers = run.expectedMembers ?? [];
if (expectedMembers.length === 0) {
return false;
}
return expectedMembers.every((memberName) => {
const member = run.memberSpawnStatuses.get(memberName);
return member?.launchState === 'confirmed_alive' || member?.bootstrapConfirmed === true;
});
}
private async publishMixedSecondaryLaneStatusChange(
@ -30499,12 +30538,21 @@ export class TeamProvisioningService {
* Uses the existing addTeamNotification() pipeline.
*/
private async fireTeamLaunchedNotification(run: ProvisioningRun): Promise<void> {
if (run.teamLaunchedNotificationFired) {
return;
}
run.teamLaunchedNotificationFired = true;
try {
const config = ConfigManager.getInstance().getConfig();
const suppressToast = !config.notifications.notifyOnTeamLaunched;
const displayName = run.request.displayName || run.teamName;
const joinedCount = run.expectedMembers?.length ?? 0;
const allJoined = joinedCount > 0 && this.areAllExpectedLaunchMembersConfirmed(run);
const body = run.isLaunch
? `Team "${displayName}" has been launched and is ready for tasks.`
? allJoined
? `Team "${displayName}" has been launched - all ${joinedCount} teammates joined and are ready for tasks.`
: `Team "${displayName}" has been launched and is ready for tasks.`
: `Team "${displayName}" has been provisioned and is ready for tasks.`;
await NotificationManager.getInstance().addTeamNotification({
@ -30520,6 +30568,7 @@ export class TeamProvisioningService {
suppressToast,
});
} catch (error) {
run.teamLaunchedNotificationFired = false;
logger.warn(
`[${run.teamName}] Failed to fire team_launched notification: ${
error instanceof Error ? error.message : String(error)

View file

@ -22,6 +22,7 @@ const logger = createLogger('Service:TeamTranscriptProjectResolver');
const SESSION_DISCOVERY_CACHE_TTL = 30_000;
const TEAM_AFFINITY_SCAN_LINES = 40;
const ROOT_DISCOVERY_CONCURRENCY = 12;
const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000;
type ProjectEvidenceSource =
| 'projectPath'
@ -51,6 +52,11 @@ interface TeamTranscriptProjectConfigReader {
getConfigSnapshot?: (teamName: string) => Promise<TeamConfig | null>;
}
interface TeamTranscriptProjectContextOptions {
forceRefresh?: boolean;
includeTeamSubagentSessionDiscovery?: boolean;
}
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
projectPath?: string;
};
@ -72,6 +78,45 @@ function isSessionDirectoryName(name: string): boolean {
return name !== 'memory' && !name.startsWith('.');
}
function buildContextCacheKey(
teamName: string,
options?: TeamTranscriptProjectContextOptions
): string {
const subagentMode = options?.includeTeamSubagentSessionDiscovery === false ? 'known' : 'full';
return `${teamName}\0${subagentMode}`;
}
function parseTimestampMs(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function teamLifecycleMtimeCutoffMs(config: TeamConfig): number | null {
const timestamps: number[] = [];
const createdAt = parseTimestampMs((config as { createdAt?: unknown }).createdAt);
if (createdAt !== null) {
timestamps.push(createdAt);
}
for (const member of config.members ?? []) {
const joinedAt = parseTimestampMs((member as { joinedAt?: unknown }).joinedAt);
if (joinedAt !== null) {
timestamps.push(joinedAt);
}
}
if (timestamps.length === 0) {
return null;
}
return Math.max(0, Math.min(...timestamps) - FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS);
}
function normalizeProjectPathCandidate(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
@ -207,12 +252,21 @@ export class TeamTranscriptProjectResolver {
: this.configReader.getConfig(teamName);
}
private deleteContextCacheForTeam(teamName: string): void {
this.contextCache.delete(teamName);
for (const key of this.contextCache.keys()) {
if (key === teamName || key.startsWith(`${teamName}\0`)) {
this.contextCache.delete(key);
}
}
}
async getLiveBaseContext(
teamName: string,
options?: { forceRefresh?: boolean; extraProjectPathCandidates?: readonly unknown[] }
): Promise<TeamTranscriptProjectLiveBaseContext | null> {
if (options?.forceRefresh) {
this.contextCache.delete(teamName);
this.deleteContextCacheForTeam(teamName);
}
const config = await this.readConfigForObservation(teamName);
@ -244,13 +298,14 @@ export class TeamTranscriptProjectResolver {
async getContext(
teamName: string,
options?: { forceRefresh?: boolean }
options?: TeamTranscriptProjectContextOptions
): Promise<TeamTranscriptProjectContext | null> {
const cacheKey = buildContextCacheKey(teamName, options);
if (options?.forceRefresh) {
this.contextCache.delete(teamName);
this.deleteContextCacheForTeam(teamName);
}
const cached = this.contextCache.get(teamName);
const cached = this.contextCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
@ -282,7 +337,8 @@ export class TeamTranscriptProjectResolver {
const sessionIds = await this.discoverSessionIds(
teamName,
resolution.projectDir,
resolvedConfig
resolvedConfig,
options
);
const value = {
projectDir: resolution.projectDir,
@ -290,7 +346,7 @@ export class TeamTranscriptProjectResolver {
config: resolvedConfig,
sessionIds,
};
this.contextCache.set(teamName, {
this.contextCache.set(cacheKey, {
value,
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
});
@ -741,12 +797,20 @@ export class TeamTranscriptProjectResolver {
private async discoverSessionIds(
teamName: string,
projectDir: string,
config: TeamConfig
config: TeamConfig,
options?: TeamTranscriptProjectContextOptions
): Promise<string[]> {
const knownSessionIds = collectKnownSessionIds(config);
const [teamRootSessionIds, sessionDirIds] = await Promise.all([
this.listTeamRootSessionIds(projectDir, teamName),
this.listSessionDirIds(projectDir),
const includeTeamSubagentSessionDiscovery =
options?.includeTeamSubagentSessionDiscovery !== false;
const rootMtimeSinceMs = includeTeamSubagentSessionDiscovery
? null
: teamLifecycleMtimeCutoffMs(config);
const [teamRootSessionIds, teamSubagentSessionIds] = await Promise.all([
this.listTeamRootSessionIds(projectDir, teamName, rootMtimeSinceMs),
includeTeamSubagentSessionDiscovery
? this.listTeamSubagentSessionIds(projectDir, teamName)
: Promise.resolve([]),
]);
const orderedSessionIds: string[] = [];
@ -762,7 +826,7 @@ export class TeamTranscriptProjectResolver {
for (const sessionId of knownSessionIds) {
push(sessionId);
}
for (const sessionId of [...teamRootSessionIds, ...sessionDirIds].sort((left, right) =>
for (const sessionId of [...teamRootSessionIds, ...teamSubagentSessionIds].sort((left, right) =>
left.localeCompare(right)
)) {
push(sessionId);
@ -816,21 +880,69 @@ export class TeamTranscriptProjectResolver {
}
}
private async listSessionDirIds(projectDir: string): Promise<string[]> {
private async listTeamSubagentSessionIds(
projectDir: string,
teamName: string
): Promise<string[]> {
const dirEntries = await this.readProjectDirEntries(projectDir);
if (!dirEntries) {
return [];
}
return dirEntries
.filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name))
.map((entry) => entry.name);
const sessionDirEntries = dirEntries.filter(
(entry) => entry.isDirectory() && isSessionDirectoryName(entry.name)
);
const discovered = new Set<string>();
let nextIndex = 0;
const scanNextSessionDir = async (): Promise<void> => {
while (nextIndex < sessionDirEntries.length) {
const entry = sessionDirEntries[nextIndex++];
const subagentsDir = path.join(projectDir, entry.name, 'subagents');
let subagentEntries: Dirent[];
try {
subagentEntries = await fs.readdir(subagentsDir, { withFileTypes: true });
} catch {
continue;
}
for (const subagentEntry of subagentEntries) {
if (!subagentEntry.isFile()) {
continue;
}
if (!subagentEntry.name.endsWith('.jsonl')) {
continue;
}
if (!subagentEntry.name.startsWith('agent-')) {
continue;
}
if (subagentEntry.name.startsWith('agent-acompact')) {
continue;
}
const filePath = path.join(subagentsDir, subagentEntry.name);
if (await this.fileBelongsToTeam(filePath, teamName)) {
discovered.add(entry.name);
break;
}
}
}
};
await Promise.all(
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, sessionDirEntries.length) }, () =>
scanNextSessionDir()
)
);
return [...discovered];
}
private async collectRootJsonlSessionIds(
rootJsonlEntries: Dirent[],
projectDir: string,
teamName: string
teamName: string,
mtimeSinceMs?: number | null
): Promise<string[]> {
const discovered = new Set<string>();
let nextIndex = 0;
@ -839,6 +951,16 @@ export class TeamTranscriptProjectResolver {
while (nextIndex < rootJsonlEntries.length) {
const entry = rootJsonlEntries[nextIndex++];
const filePath = path.join(projectDir, entry.name);
if (mtimeSinceMs != null) {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile() || stat.mtimeMs < mtimeSinceMs) {
continue;
}
} catch {
continue;
}
}
if (!(await this.fileBelongsToTeam(filePath, teamName))) {
continue;
}
@ -855,7 +977,11 @@ export class TeamTranscriptProjectResolver {
return [...discovered];
}
private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise<string[]> {
private async listTeamRootSessionIds(
projectDir: string,
teamName: string,
mtimeSinceMs?: number | null
): Promise<string[]> {
const dirEntries = await this.readProjectDirEntries(projectDir);
if (!dirEntries) {
return [];
@ -864,7 +990,7 @@ export class TeamTranscriptProjectResolver {
const rootJsonlEntries = dirEntries.filter(
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
);
return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName);
return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName, mtimeSinceMs);
}
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {

View file

@ -1,7 +1,8 @@
import { randomUUID } from 'crypto';
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
import { stableHash } from './OpenCodeBridgeCommandContract';
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
import type {
OpenCodeTeamLaunchReadiness,
OpenCodeTeamLaunchReadinessState,
@ -14,10 +15,10 @@ import type {
OpenCodeBridgeFailureKind,
OpenCodeBridgeResult,
OpenCodeBridgeRuntimeSnapshot,
OpenCodeCommandStatusCommandBody,
OpenCodeCommandStatusCommandData,
OpenCodeCleanupHostsCommandBody,
OpenCodeCleanupHostsCommandData,
OpenCodeCommandStatusCommandBody,
OpenCodeCommandStatusCommandData,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeObserveMessageDeliveryCommandBody,
@ -79,10 +80,6 @@ const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
const DEFAULT_COMMAND_STATUS_TIMEOUT_MS = 5_000;
function isCommandStatusRecoveryEnabled(): boolean {
return process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY === '1';
}
function buildSendPayloadHash(input: OpenCodeSendMessageCommandBody): string {
const { payloadHash: _payloadHash, ...hashable } = input;
return stableHash(hashable);
@ -246,7 +243,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
if (result.ok) {
return result.data;
}
if (result.error.kind === 'timeout' && isCommandStatusRecoveryEnabled()) {
if (result.error.kind === 'timeout') {
const recovered = await this.recoverTimedOutSendMessage({
originalRequestId: commandRequestId,
body,

View file

@ -1,8 +1,9 @@
import { classifyRuntimeDiagnostic } from '../../runtime/RuntimeDiagnosticClassifier';
import {
isActionRequiredOpenCodeRuntimeDeliveryReason,
selectOpenCodeRuntimeDeliveryReason,
} from './OpenCodeRuntimeDeliveryDiagnostics';
import { classifyRuntimeDiagnostic } from '../../runtime/RuntimeDiagnosticClassifier';
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
import type {

View file

@ -621,6 +621,13 @@ function getOpenCodeInstallLabel(status: OpenCodeRuntimeStatus | null): string {
return 'Install';
}
const OPENCODE_PROVIDER_FREE_BADGE_TITLE =
'OpenCode includes free model options such as Big Pickle when available in your setup. OpenRouter through OpenCode can also expose free models, but not every OpenCode/OpenRouter model is free. Availability and limits may change.';
function shouldShowOpenCodeProviderFreeBadge(provider: CliProviderStatus): boolean {
return provider.providerId === 'opencode';
}
const InstalledBanner = ({
cliStatus,
sourceProviderMap,
@ -847,6 +854,14 @@ const InstalledBanner = ({
? getProviderLabel(provider.providerId)
: provider.displayName}
</span>
{shouldShowOpenCodeProviderFreeBadge(provider) ? (
<span
className="rounded bg-[rgba(34,197,94,0.14)] px-1.5 py-px text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]"
title={OPENCODE_PROVIDER_FREE_BADGE_TITLE}
>
Free models
</span>
) : null}
</span>
<span
className="text-xs"
@ -1039,6 +1054,7 @@ const InstalledBanner = ({
modelAvailability={provider.modelAvailability}
providerStatus={provider}
collapseAfter={15}
maxCollapsedRows={provider.providerId === 'opencode' ? 2 : undefined}
/>
</div>
)}

View file

@ -270,11 +270,6 @@ export const ExtensionStoreView = (): React.JSX.Element => {
void mcpFetchInstalled(projectPath ?? undefined);
}, [mcpFetchInstalled, projectPath]);
// Fetch API keys on mount
useEffect(() => {
void fetchApiKeys();
}, [fetchApiKeys]);
// Fetch Skills catalog on mount / project change
useEffect(() => {
void fetchSkillsCatalog(projectPath ?? undefined);
@ -287,7 +282,9 @@ export const ExtensionStoreView = (): React.JSX.Element => {
bootstrapCliStatus,
fetchCliStatus,
});
void fetchApiKeys();
if (tabState.activeSubTab === 'api-keys') {
void fetchApiKeys();
}
void fetchPluginCatalog(projectPath ?? undefined, true);
void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled(projectPath ?? undefined);
@ -302,6 +299,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
mcpBrowse,
mcpFetchInstalled,
projectPath,
tabState.activeSubTab,
]);
const isRefreshing =

View file

@ -36,6 +36,7 @@ export const ApiKeysPanel = ({
apiKeysLoading,
apiKeysError,
storageStatus,
fetchApiKeys,
fetchStorageStatus,
cliStatus,
cliStatusLoading,
@ -46,6 +47,7 @@ export const ApiKeysPanel = ({
apiKeysLoading: s.apiKeysLoading,
apiKeysError: s.apiKeysError,
storageStatus: s.apiKeyStorageStatus,
fetchApiKeys: s.fetchApiKeys,
fetchStorageStatus: s.fetchApiKeyStorageStatus,
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
@ -85,8 +87,9 @@ export const ApiKeysPanel = ({
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
useEffect(() => {
void fetchApiKeys();
void fetchStorageStatus();
}, [fetchStorageStatus]);
}, [fetchApiKeys, fetchStorageStatus]);
const handleAdd = () => {
setEditingKey(null);

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import { cn } from '@renderer/lib/utils';
import {
@ -56,13 +56,43 @@ function getCatalogBadgeLabel(
return catalogItem?.badgeLabel?.trim() || null;
}
function normalizeBadgeText(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase();
}
function shouldRenderCatalogBadge(modelLabel: string, catalogBadgeLabel: string | null): boolean {
if (!catalogBadgeLabel) {
return false;
}
return normalizeBadgeText(modelLabel) !== normalizeBadgeText(catalogBadgeLabel);
}
function hasChildAfterRowLimit(container: HTMLElement, rowLimit: number): boolean {
const rowTops: number[] = [];
const children = Array.from(container.children) as HTMLElement[];
for (const child of children) {
const top = child.offsetTop;
let rowIndex = rowTops.findIndex((rowTop) => Math.abs(rowTop - top) <= 1);
if (rowIndex < 0) {
rowTops.push(top);
rowIndex = rowTops.length - 1;
}
if (rowIndex >= rowLimit) {
return true;
}
}
return false;
}
export const ProviderModelBadges = ({
providerId,
models,
modelAvailability,
providerStatus,
collapseAfter,
expandedMaxHeightPx = 200,
maxCollapsedRows,
}: {
readonly providerId: CliProviderId;
readonly models: string[];
@ -72,16 +102,73 @@ export const ProviderModelBadges = ({
'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
> | null;
readonly collapseAfter?: number;
readonly expandedMaxHeightPx?: number;
readonly maxCollapsedRows?: number;
}): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
const [collapsedModelLimit, setCollapsedModelLimit] = useState<number | null>(null);
const [measureTick, setMeasureTick] = useState(0);
const listRef = useRef<HTMLDivElement | null>(null);
const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus);
const displayModelAvailability = providerId === 'opencode' ? undefined : modelAvailability;
const shouldCollapse =
typeof collapseAfter === 'number' && collapseAfter > 0 && visibleModels.length > collapseAfter;
const collapsedBaseLimit = shouldCollapse ? collapseAfter : visibleModels.length;
const collapsedLimit =
shouldCollapse && !expanded
? Math.max(0, Math.min(collapsedModelLimit ?? collapsedBaseLimit, collapsedBaseLimit))
: visibleModels.length;
const displayedModels =
shouldCollapse && !expanded ? visibleModels.slice(0, collapseAfter) : visibleModels;
const hiddenCount = shouldCollapse ? visibleModels.length - collapseAfter : 0;
shouldCollapse && !expanded ? visibleModels.slice(0, collapsedLimit) : visibleModels;
const hiddenCount = shouldCollapse ? visibleModels.length - displayedModels.length : 0;
useLayoutEffect(() => {
setCollapsedModelLimit(null);
}, [collapseAfter, maxCollapsedRows, models, providerStatus]);
useLayoutEffect(() => {
if (!shouldCollapse || expanded || !maxCollapsedRows || maxCollapsedRows < 1) {
return;
}
const container = listRef.current;
if (!container) {
return;
}
if (!hasChildAfterRowLimit(container, maxCollapsedRows)) {
return;
}
const nextLimit = Math.max(0, collapsedLimit - 1);
if (nextLimit !== collapsedLimit) {
setCollapsedModelLimit(nextLimit);
}
}, [collapsedLimit, expanded, maxCollapsedRows, measureTick, shouldCollapse]);
useLayoutEffect(() => {
if (!shouldCollapse || expanded || !maxCollapsedRows || typeof ResizeObserver === 'undefined') {
return;
}
const container = listRef.current;
if (!container) {
return;
}
let lastWidth = container.clientWidth;
const observer = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width ?? container.clientWidth);
if (width === lastWidth) {
return;
}
lastWidth = width;
setCollapsedModelLimit(null);
setMeasureTick((value) => value + 1);
});
observer.observe(container);
return () => observer.disconnect();
}, [expanded, maxCollapsedRows, shouldCollapse]);
const badgeClassName =
'inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4';
@ -92,20 +179,18 @@ export const ProviderModelBadges = ({
};
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 listClassName = cn('flex flex-wrap gap-1.5');
const renderModelBadge = (model: string, index: number): React.JSX.Element => {
const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability);
const availabilityReason = getAvailabilityReason(model, displayModelAvailability);
const availabilityChip = getAvailabilityChip(availabilityStatus);
const modelLabel = formatModelBadgeLabel(providerId, model);
const catalogBadgeLabel = getCatalogBadgeLabel(model, providerStatus);
const showCatalogBadge = shouldRenderCatalogBadge(modelLabel, catalogBadgeLabel);
const title = [
availabilityReason ?? availabilityChip,
catalogBadgeLabel === 'Free'
showCatalogBadge && catalogBadgeLabel === 'Free'
? 'Reported by OpenCode metadata. Availability and limits may change.'
: null,
]
@ -119,8 +204,8 @@ export const ProviderModelBadges = ({
style={badgeStyle}
title={title || undefined}
>
<span>{formatModelBadgeLabel(providerId, model)}</span>
{catalogBadgeLabel ? (
<span>{modelLabel}</span>
{showCatalogBadge ? (
<span className="rounded bg-[rgba(34,197,94,0.14)] px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]">
{catalogBadgeLabel}
</span>
@ -149,7 +234,7 @@ export const ProviderModelBadges = ({
return (
<div className="flex flex-col items-start gap-1.5">
<div className={listClassName} style={listStyle}>
<div ref={listRef} className={listClassName}>
{displayedModels.map(renderModelBadge)}
{shouldCollapse && !expanded ? (
<button type="button" className={buttonClassName} onClick={() => setExpanded(true)}>

View file

@ -152,6 +152,7 @@ import type {
TeamCreateRequest,
TeamLaunchRequest,
TeamProviderId,
TeamSummary,
TeamTaskWithKanban,
} from '@shared/types';
import type { EditorSelectionAction } from '@shared/types/editor';
@ -174,6 +175,61 @@ interface CreateTaskDialogState {
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
function getSummaryKnownTeammateCount(summary: TeamSummary | undefined): number {
if (!summary) {
return 0;
}
const normalizedLeadName = summary.leadName?.trim().toLowerCase();
const rosterNames = new Set<string>();
for (const member of summary.members ?? []) {
const name = member.name?.trim();
if (!name) {
continue;
}
const normalizedName = name.toLowerCase();
if (
normalizedName === 'user' ||
isLeadMember({ name }) ||
(normalizedLeadName && normalizedName === normalizedLeadName)
) {
continue;
}
rosterNames.add(normalizedName);
}
const launchNames = new Set<string>();
for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) {
const name = rawName.trim();
if (!name) {
continue;
}
const normalizedName = name.toLowerCase();
if (
normalizedName === 'user' ||
isLeadMember({ name }) ||
(normalizedLeadName && normalizedName === normalizedLeadName)
) {
continue;
}
launchNames.add(normalizedName);
}
const activeRosterCount = Math.max(summary.memberCount, rosterNames.size);
if (activeRosterCount > 0) {
return activeRosterCount;
}
return Math.max(
summary.expectedMemberCount ?? 0,
launchNames.size,
(summary.confirmedCount ?? 0) +
(summary.pendingCount ?? 0) +
(summary.failedCount ?? 0) +
(summary.skippedCount ?? 0)
);
}
function areResolvedMembersEqual(
prev: readonly ResolvedTeamMember[],
next: readonly ResolvedTeamMember[]
@ -1578,6 +1634,7 @@ export const TeamDetailView = memo(function TeamDetailView({
selectReviewFile,
pendingReviewRequest,
setPendingReviewRequest,
summaryKnownTeammateCount,
} = useStore(
useShallow((s) => ({
projects: s.projects,
@ -1612,6 +1669,9 @@ export const TeamDetailView = memo(function TeamDetailView({
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
members: selectResolvedMembersForTeamName(s, teamName),
summaryKnownTeammateCount: teamName
? getSummaryKnownTeammateCount(s.teamByName[teamName])
: 0,
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
refreshTeamData: s.refreshTeamData,
@ -1995,10 +2055,13 @@ export const TeamDetailView = memo(function TeamDetailView({
return filterKanbanTasks(filteredTasks, kanbanSearchQuery);
}, [filteredTasks, kanbanSearchQuery]);
const activeTeammateCount = useMemo(
() => activeMembers.filter((m) => !isLeadMember(m)).length,
[activeMembers]
);
const activeTeammateCount = useMemo(() => {
const resolvedCount = activeMembers.filter((m) => !isLeadMember(m)).length;
if (membersWithLiveBranches.some((m) => m.removedAt)) {
return resolvedCount;
}
return resolvedCount > 0 ? resolvedCount : summaryKnownTeammateCount;
}, [activeMembers, membersWithLiveBranches, summaryKnownTeammateCount]);
const leadProviderId = useMemo<TeamProviderId | undefined>(() => {
const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId;
if (activeLeadProviderId) return activeLeadProviderId;
@ -2814,6 +2877,7 @@ export const TeamDetailView = memo(function TeamDetailView({
<TeamMemberListBridge
teamName={teamName}
members={membersWithLiveBranches}
expectedTeammateCount={activeTeammateCount}
memberTaskCounts={memberTaskCounts}
taskMap={taskMap}
pendingRepliesByMember={pendingRepliesByMember}

View file

@ -30,6 +30,7 @@ import type {
interface MemberListProps {
teamName?: string;
members: ResolvedTeamMember[];
expectedTeammateCount?: number;
memberTaskCounts?: Map<string, TaskStatusCounts>;
taskMap?: Map<string, TeamTaskWithKanban>;
pendingRepliesByMember?: Record<string, number>;
@ -312,6 +313,7 @@ function areMemberListPropsEqual(
return (
prev.teamName === next.teamName &&
areResolvedMembersEquivalent(prev.members, next.members) &&
prev.expectedTeammateCount === next.expectedTeammateCount &&
areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) &&
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
@ -452,6 +454,7 @@ const MemberCardRow = memo(function MemberCardRow({
export const MemberList = memo(function MemberList({
teamName = '__unknown_team__',
members,
expectedTeammateCount,
memberTaskCounts,
taskMap,
pendingRepliesByMember,
@ -502,6 +505,10 @@ export const MemberList = memo(function MemberList({
[members]
);
const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]);
const activeTeammateCount = useMemo(
() => activeMembers.filter((member) => !isLeadMember(member)).length,
[activeMembers]
);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
// Pre-compute reviewer->task map to avoid O(n*n) scan per member.
const reviewTaskByMember = useMemo(() => {
@ -644,10 +651,22 @@ export const MemberList = memo(function MemberList({
[launchParams]
);
if (members.length === 0) {
const expectsTeammates = (expectedTeammateCount ?? 0) > 0;
const hasOnlyLeadWhileTeammatesLoad =
expectsTeammates && activeTeammateCount === 0 && removedMembers.length === 0;
if (members.length === 0 || hasOnlyLeadWhileTeammatesLoad) {
if (expectsTeammates) {
return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Team members are loading
</div>
);
}
return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Solo team lead only
Solo team - lead only
</div>
);
}

View file

@ -110,9 +110,9 @@ export function buildTeamChangeRequestPlan(
}
seenTaskIds.add(task.id);
const options = buildTaskChangeRequestOptions(task, { summaryOnly: true });
const requestOptions = buildTaskChangeRequestOptions(task, { summaryOnly: true });
const presence = task.changePresence ?? 'unknown';
const canDisplay = canDisplayTaskChangesForOptions(options);
const canDisplay = canDisplayTaskChangesForOptions(requestOptions);
const shouldScanUnknown =
presence === 'unknown' && (canDisplay || hasTaskChangeScanEvidence(task));
if (
@ -125,19 +125,19 @@ export function buildTeamChangeRequestPlan(
}
if (presence === 'has_changes') {
primary.push({ task, options, priority: 0 });
primary.push({ task, options: requestOptions, priority: 0 });
continue;
}
if (presence === 'needs_attention') {
primary.push({ task, options, priority: 1 });
primary.push({ task, options: requestOptions, priority: 1 });
continue;
}
if (options.stateBucket === 'active' && options.status === 'in_progress') {
active.push({ task, options, priority: 2 });
if (requestOptions.stateBucket === 'active' && requestOptions.status === 'in_progress') {
active.push({ task, options: requestOptions, priority: 2 });
continue;
}
if (shouldScanUnknown) {
unknown.push({ task, options, priority: 3 });
unknown.push({ task, options: requestOptions, priority: 3 });
}
}
@ -166,15 +166,15 @@ export function buildTeamChangeRequestPlan(
const selected = [...requestPrimary, ...requestActive, ...unknownWindow].slice(0, maxRequests);
const requestOptionsByTaskId = new Map<string, TaskChangeRequestOptions>();
const requests = selected.map((candidate) => {
const options = {
const requestOptions = {
...candidate.options,
summaryOnly: true,
forceFresh: forceFresh ? true : candidate.options.forceFresh,
};
requestOptionsByTaskId.set(candidate.task.id, options);
requestOptionsByTaskId.set(candidate.task.id, requestOptions);
return {
taskId: candidate.task.id,
options,
options: requestOptions,
};
});
const eligibleCount = primary.length + active.length + unknown.length;

View file

@ -196,8 +196,7 @@ function isDocumentHidden(): boolean {
function isSilentCounterLoad(options: TeamChangesLoadOptions | null): boolean {
return Boolean(
options &&
options.storeSummaries === false &&
options?.storeSummaries === false &&
options.reportError === false &&
options.showSpinner !== true
);

View file

@ -1983,6 +1983,165 @@ function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMem
return fallbackMembers;
}
function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] {
if (!snapshot) {
return [];
}
const names = new Set<string>();
for (const member of snapshot.members) {
const name = member.name.trim();
const key = name.toLowerCase();
if (!name || key === 'user' || member.removedAt || isLeadMember(member)) {
continue;
}
names.add(key);
}
return Array.from(names).sort((left, right) => left.localeCompare(right));
}
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return getActiveRawTeammateNameKeys(snapshot).length > 0;
}
function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(snapshot?.members.some((member) => member.removedAt));
}
function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(
snapshot?.config.members?.some((member) => {
const name = member.name?.trim();
return Boolean(name) && !member.removedAt && !isLeadMember(member);
})
);
}
interface SummaryFallbackMemberSource {
name: string;
agentId?: string;
role?: string;
color?: string;
}
function normalizeSummaryTeammateName(
name: string | undefined | null,
leadName?: string
): string | null {
const trimmed = name?.trim();
const normalizedName = trimmed?.toLowerCase();
const normalizedLeadName = leadName?.trim().toLowerCase();
if (
!trimmed ||
normalizedName === 'user' ||
isLeadMember({ name: trimmed }) ||
(normalizedLeadName && normalizedName === normalizedLeadName)
) {
return null;
}
return trimmed;
}
function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
const seenNames = new Set<string>();
const sources: SummaryFallbackMemberSource[] = [];
for (const member of summary.members ?? []) {
const name = normalizeSummaryTeammateName(member.name, summary.leadName);
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seenNames.has(key)) {
continue;
}
seenNames.add(key);
sources.push({
name,
agentId: member.agentId,
role: member.role,
color: member.color,
});
}
return sources;
}
function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean {
return (
summary.partialLaunchFailure === true ||
summary.teamLaunchState === 'partial_failure' ||
summary.teamLaunchState === 'partial_pending' ||
summary.teamLaunchState === 'partial_skipped'
);
}
function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
if (!shouldUseSummaryLaunchTeammateSources(summary)) {
return [];
}
const seenNames = new Set<string>();
const sources: SummaryFallbackMemberSource[] = [];
for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) {
const name = normalizeSummaryTeammateName(rawName, summary.leadName);
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seenNames.has(key)) {
continue;
}
seenNames.add(key);
sources.push({ name });
}
return sources;
}
function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] {
return getSummaryLaunchTeammateSources(summary)
.map((member) => member.name.toLowerCase())
.sort((left, right) => left.localeCompare(right));
}
function getSummaryTeammateNameKeys(summary: TeamSummary): string[] {
const rosterNames = getSummaryRosterTeammateSources(summary)
.map((member) => member.name.toLowerCase())
.sort((left, right) => left.localeCompare(right));
if (rosterNames.length > 0) {
return rosterNames;
}
const launchNames = getSummaryLaunchTeammateNameKeys(summary);
const expectedCount = summary.expectedMemberCount ?? summary.memberCount;
if (expectedCount > 0 && launchNames.length === expectedCount) {
return launchNames;
}
return [];
}
function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
return getSummaryRosterTeammateSources(summary);
}
function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((name, index) => name === right[index]);
}
function summaryConfirmsActiveTeammateRoster(
current: TeamViewSnapshot,
summary: TeamSummary
): boolean {
if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) {
return false;
}
const currentNames = getActiveRawTeammateNameKeys(current);
const summaryNames = getSummaryTeammateNameKeys(summary);
if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) {
return false;
}
return areNameKeyListsEqual(summaryNames, currentNames);
}
function buildSummaryFallbackMemberSnapshots(
snapshot: TeamViewSnapshot,
summary: TeamSummary | undefined
@ -1990,8 +2149,8 @@ function buildSummaryFallbackMemberSnapshots(
if (!summary) {
return [];
}
const summaryMembers = summary.members ?? [];
if (summaryMembers.length === 0 || summary.memberCount <= 0) {
const summaryMembers = getSummaryFallbackTeammateSources(summary);
if (summaryMembers.length === 0) {
return [];
}
@ -2021,11 +2180,7 @@ function buildSummaryFallbackMemberSnapshots(
};
const teammates = summaryMembers.flatMap((member) => {
const name = member.name?.trim();
if (!name || name === 'user' || isLeadMember(member)) {
return [];
}
const item = buildSnapshot(name, member);
const item = buildSnapshot(member.name, member);
return item ? [item] : [];
});
if (teammates.length === 0) {
@ -2080,71 +2235,6 @@ function getResolvableMemberSnapshots(
return snapshot.members;
}
function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] {
if (!snapshot) {
return [];
}
const names = new Set<string>();
for (const member of snapshot.members) {
const name = member.name.trim();
if (!name || name === 'user' || member.removedAt || isLeadMember(member)) {
continue;
}
names.add(name.toLowerCase());
}
return Array.from(names).sort((left, right) => left.localeCompare(right));
}
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return getActiveRawTeammateNameKeys(snapshot).length > 0;
}
function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(snapshot?.members.some((member) => member.removedAt));
}
function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(
snapshot?.config.members?.some((member) => {
const name = member.name?.trim();
return Boolean(name) && !member.removedAt && !isLeadMember(member);
})
);
}
function getSummaryTeammateNameKeys(summary: TeamSummary): string[] {
const names = new Set<string>();
for (const member of summary.members ?? []) {
const name = member.name?.trim();
if (!name || name === 'user' || isLeadMember(member)) {
continue;
}
names.add(name.toLowerCase());
}
return Array.from(names).sort((left, right) => left.localeCompare(right));
}
function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((name, index) => name === right[index]);
}
function summaryConfirmsActiveTeammateRoster(
current: TeamViewSnapshot,
summary: TeamSummary
): boolean {
if (summary.memberCount <= 0) {
return false;
}
const currentNames = getActiveRawTeammateNameKeys(current);
const summaryNames = getSummaryTeammateNameKeys(summary);
if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) {
return false;
}
return areNameKeyListsEqual(summaryNames, currentNames);
}
function shouldPreserveSelectedTeamSnapshot(
current: TeamViewSnapshot | null,
baseline: TeamViewSnapshot | null | undefined,

View file

@ -294,7 +294,7 @@ function getRuntimeSelectorModels(
return getVisibleTeamProviderModels(providerId, catalogModels, providerStatus);
}
return sortTeamProviderModels(providerId, providerStatus.models);
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);
}
function getVisibleRuntimeModels(

View file

@ -400,9 +400,38 @@ export function getRuntimeAwareTeamModelBadgeLabel(
return getTeamModelBadgeLabel(providerId, model);
}
function hasExplicitFreeOpenCodeModelMarker(model: string): boolean {
const normalized = model.trim().toLowerCase();
return (
normalized === 'opencode/big-pickle' ||
normalized.includes(':free') ||
normalized.endsWith('-free') ||
normalized.endsWith('/free')
);
}
function isFreeOpenCodeModelForOrdering(
providerId: SupportedProviderId,
model: string,
providerStatus?: RuntimeAwareProviderStatus | null
): boolean {
if (providerId !== 'opencode') {
return false;
}
const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus);
const badgeLabel = runtimeModel?.badgeLabel?.trim().toLowerCase();
if (badgeLabel) {
return badgeLabel === 'free';
}
return hasExplicitFreeOpenCodeModelMarker(model);
}
export function sortTeamProviderModels(
providerId: SupportedProviderId,
models: readonly string[]
models: readonly string[],
providerStatus?: RuntimeAwareProviderStatus | null
): string[] {
const seen = new Set<string>();
const deduped = models.filter((model) => {
@ -415,7 +444,7 @@ export function sortTeamProviderModels(
});
const order = TEAM_PROVIDER_MODEL_ORDER[providerId];
return [...deduped].sort((left, right) => {
const sorted = [...deduped].sort((left, right) => {
const leftRank = order.get(left) ?? Number.MAX_SAFE_INTEGER;
const rightRank = order.get(right) ?? Number.MAX_SAFE_INTEGER;
if (leftRank !== rightRank) {
@ -423,6 +452,22 @@ export function sortTeamProviderModels(
}
return left.localeCompare(right);
});
if (providerId !== 'opencode') {
return sorted;
}
return sorted
.map((model, index) => ({ model, index }))
.sort((left, right) => {
const leftFree = isFreeOpenCodeModelForOrdering(providerId, left.model, providerStatus);
const rightFree = isFreeOpenCodeModelForOrdering(providerId, right.model, providerStatus);
if (leftFree !== rightFree) {
return leftFree ? -1 : 1;
}
return left.index - right.index;
})
.map((entry) => entry.model);
}
export function isCodexChatGptSubscriptionProviderStatus(
@ -468,7 +513,11 @@ export function getVisibleTeamProviderModels(
): string[] {
return sortTeamProviderModels(
providerId,
filterVisibleProviderRuntimeModels(providerId, getSupplementalVisibleModels(providerId, models))
filterVisibleProviderRuntimeModels(
providerId,
getSupplementalVisibleModels(providerId, models)
),
providerStatus
).filter((model) => !isRuntimeHiddenTeamModel(providerId, model, providerStatus));
}

View file

@ -147,6 +147,12 @@ export interface CliProviderModelCatalogItem {
source: CliProviderModelCatalogSource;
badgeLabel?: string | null;
statusMessage?: string | null;
metadata?: {
cost?: unknown;
context?: number | null;
limits?: unknown;
free?: boolean;
} | null;
}
export interface CliProviderModelCatalog {

View file

@ -9,6 +9,7 @@ import type {
} from '@features/codex-account/contracts';
const {
apiKeyHasPreferredMock,
apiKeyLookupMock,
binaryResolveMock,
detectLocalAccountStateMock,
@ -25,6 +26,7 @@ const {
readRateLimitsMock,
} = vi.hoisted(() => ({
binaryResolveMock: vi.fn(),
apiKeyHasPreferredMock: vi.fn(),
apiKeyLookupMock: vi.fn(),
detectLocalAccountStateMock: vi.fn(),
getCachedShellEnvMock: vi.fn(),
@ -59,6 +61,7 @@ function emitLoginState(nextState: CodexLoginStateDto): void {
vi.mock('../../../../src/main/services/extensions', () => ({
ApiKeyService: class MockApiKeyService {
hasPreferred = apiKeyHasPreferredMock;
lookupPreferred = apiKeyLookupMock;
},
}));
@ -228,6 +231,7 @@ describe('createCodexAccountFeature', () => {
delete process.env.OPENAI_API_KEY;
delete process.env.CODEX_API_KEY;
binaryResolveMock.mockResolvedValue('/usr/local/bin/codex');
apiKeyHasPreferredMock.mockResolvedValue(false);
apiKeyLookupMock.mockResolvedValue(null);
detectLocalAccountStateMock.mockResolvedValue({
hasArtifacts: false,

View file

@ -36,21 +36,22 @@ describe('ApiKeyService', () => {
});
it('persists projectPath for project-scoped API keys', async () => {
const projectPath = path.join(tempDir, 'project-a');
const saved = await service.save({
name: 'Project Tavily',
envVarName: 'TAVILY_API_KEY',
value: 'secret',
scope: 'project',
projectPath: '/tmp/project-a',
projectPath,
});
expect(saved.scope).toBe('project');
expect(saved.projectPath).toBe('/tmp/project-a');
expect(saved.projectPath).toBe(projectPath);
await expect(service.list()).resolves.toEqual([
expect.objectContaining({
scope: 'project',
projectPath: '/tmp/project-a',
projectPath,
}),
]);
});
@ -67,6 +68,7 @@ describe('ApiKeyService', () => {
});
it('prefers exact project matches over user keys during lookup', async () => {
const projectPath = path.join(tempDir, 'project-a');
await service.save({
name: 'Shared Tavily',
envVarName: 'TAVILY_API_KEY',
@ -78,10 +80,10 @@ describe('ApiKeyService', () => {
envVarName: 'TAVILY_API_KEY',
value: 'project-secret',
scope: 'project',
projectPath: '/tmp/project-a',
projectPath,
});
await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([
await expect(service.lookup(['TAVILY_API_KEY'], projectPath)).resolves.toEqual([
{
envVarName: 'TAVILY_API_KEY',
value: 'project-secret',
@ -90,6 +92,8 @@ describe('ApiKeyService', () => {
});
it('falls back to user keys when project-specific matches do not exist', async () => {
const projectPath = path.join(tempDir, 'project-a');
const otherProjectPath = path.join(tempDir, 'project-b');
await service.save({
name: 'Shared Tavily',
envVarName: 'TAVILY_API_KEY',
@ -101,10 +105,10 @@ describe('ApiKeyService', () => {
envVarName: 'TAVILY_API_KEY',
value: 'project-secret',
scope: 'project',
projectPath: '/tmp/project-b',
projectPath: otherProjectPath,
});
await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([
await expect(service.lookup(['TAVILY_API_KEY'], projectPath)).resolves.toEqual([
{
envVarName: 'TAVILY_API_KEY',
value: 'user-secret',
@ -113,18 +117,38 @@ describe('ApiKeyService', () => {
});
it('does not leak project-scoped keys without project context', async () => {
const projectPath = path.join(tempDir, 'project-a');
await service.save({
name: 'Project only key',
envVarName: 'TAVILY_API_KEY',
value: 'project-secret',
scope: 'project',
projectPath: '/tmp/project-a',
projectPath,
});
await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]);
await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull();
});
it('checks preferred key presence without decrypting the stored value', async () => {
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
vi.mocked(safeStorage.getSelectedStorageBackend).mockReturnValue('gnome_libsecret');
vi.mocked(safeStorage.encryptString).mockReturnValue(Buffer.from('encrypted-value'));
await service.save({
name: 'Anthropic API Key',
envVarName: 'ANTHROPIC_API_KEY',
value: 'secret',
scope: 'user',
});
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
throw new Error('decrypt should not be called');
});
await expect(service.hasPreferred('ANTHROPIC_API_KEY')).resolves.toBe(true);
expect(safeStorage.decryptString).not.toHaveBeenCalled();
});
it('does not print decrypt failures to the normal console', async () => {
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
vi.mocked(safeStorage.getSelectedStorageBackend).mockReturnValue('gnome_libsecret');

View file

@ -1,17 +1,70 @@
import { createHash } from 'crypto';
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { gzipSync } from 'zlib';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveAppManagedOpenCodeRuntimeBinaryPath } from '@main/services/infrastructure/OpenCodeRuntimeInstallerService';
const execCliMock = vi.hoisted(() => vi.fn());
vi.mock('@main/utils/childProcess', () => ({
execCli: execCliMock,
}));
import {
extractOpenCodeRuntimeBinaryFromTarball,
getOpenCodeRuntimePlatformCandidates,
resolveAppManagedOpenCodeRuntimeBinaryPath,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
verifyOpenCodeRuntimePackageIntegrity,
} from '@main/services/infrastructure/OpenCodeRuntimeInstallerService';
import { setAppDataBasePath } from '@main/utils/pathDecoder';
let tempRoot: string | null = null;
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
const encoded = value
.toString(8)
.padStart(length - 1, '0')
.slice(-(length - 1));
header.write(`${encoded}\0`, offset, length, 'ascii');
}
function createTarEntry(name: string, data: Buffer): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, Math.min(Buffer.byteLength(name), 100), 'utf8');
writeOctal(header, 100, 8, 0o755);
writeOctal(header, 108, 8, 0);
writeOctal(header, 116, 8, 0);
writeOctal(header, 124, 12, data.length);
writeOctal(header, 136, 12, 0);
header.fill(' ', 148, 156);
header.write('0', 156, 1, 'ascii');
header.write('ustar\0', 257, 6, 'ascii');
header.write('00', 263, 2, 'ascii');
const checksum = header.reduce((sum, byte) => sum + byte, 0);
const checksumText = checksum.toString(8).padStart(6, '0');
header.write(`${checksumText}\0 `, 148, 8, 'ascii');
const padding = Buffer.alloc((512 - (data.length % 512)) % 512);
return Buffer.concat([header, data, padding]);
}
function createTarball(entries: { name: string; data: string }[]): Buffer {
return gzipSync(
Buffer.concat([
...entries.map((entry) => createTarEntry(entry.name, Buffer.from(entry.data))),
Buffer.alloc(1024),
])
);
}
describe('OpenCodeRuntimeInstallerService resolver', () => {
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-resolver-'));
setAppDataBasePath(tempRoot);
execCliMock.mockReset();
execCliMock.mockResolvedValue({ stdout: 'opencode 1.0.0\n', stderr: '' });
});
afterEach(async () => {
@ -71,4 +124,111 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
expect(resolveAppManagedOpenCodeRuntimeBinaryPath()).toBeNull();
});
it('returns the verified app-managed binary path only when --version succeeds', async () => {
const binaryPath = path.join(
tempRoot!,
'data',
'runtimes',
'opencode',
'versions',
'1.0.0',
'opencode-test',
'opencode'
);
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json');
await mkdir(path.dirname(binaryPath), { recursive: true });
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
await writeFile(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
version: '1.0.0',
platformPackage: 'opencode-test',
binaryPath,
integrity: 'sha512-test',
installedAt: '2026-05-12T00:00:00.000Z',
})}\n`,
'utf8'
);
await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBe(binaryPath);
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
timeout: 10_000,
windowsHide: true,
});
execCliMock.mockRejectedValueOnce(new Error('broken binary'));
await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBeNull();
});
});
describe('OpenCodeRuntimeInstallerService package safety helpers', () => {
it('selects expected platform packages with Linux musl and baseline fallbacks', () => {
expect(
getOpenCodeRuntimePlatformCandidates('darwin', 'arm64', false).map((item) => item.packageName)
).toEqual(['opencode-darwin-arm64']);
expect(
getOpenCodeRuntimePlatformCandidates('darwin', 'x64', false).map((item) => item.packageName)
).toEqual(['opencode-darwin-x64', 'opencode-darwin-x64-baseline']);
expect(
getOpenCodeRuntimePlatformCandidates('linux', 'x64', false).map((item) => item.packageName)
).toEqual(['opencode-linux-x64', 'opencode-linux-x64-baseline', 'opencode-linux-x64-musl']);
expect(
getOpenCodeRuntimePlatformCandidates('linux', 'x64', true).map((item) => item.packageName)
).toEqual([
'opencode-linux-x64-musl',
'opencode-linux-x64-baseline-musl',
'opencode-linux-x64',
]);
expect(
getOpenCodeRuntimePlatformCandidates('linux', 'arm64', false).map((item) => item.packageName)
).toEqual(['opencode-linux-arm64', 'opencode-linux-arm64-musl']);
expect(
getOpenCodeRuntimePlatformCandidates('linux', 'arm64', true).map((item) => item.packageName)
).toEqual(['opencode-linux-arm64-musl', 'opencode-linux-arm64']);
expect(
getOpenCodeRuntimePlatformCandidates('win32', 'x64', false).map((item) => item.packageName)
).toEqual(['opencode-windows-x64', 'opencode-windows-x64-baseline']);
expect(
getOpenCodeRuntimePlatformCandidates('win32', 'arm64', false).map((item) => item.packageName)
).toEqual(['opencode-windows-arm64']);
});
it('fails npm integrity mismatches', () => {
const payload = Buffer.from('actual package');
const wrongHash = createHash('sha512').update('different package').digest('base64');
expect(() => verifyOpenCodeRuntimePackageIntegrity(payload, `sha512-${wrongHash}`)).toThrow(
'integrity check failed'
);
});
it('extracts only the expected OpenCode binary from the package tarball', () => {
const tarball = createTarball([
{ name: 'package/bin/not-opencode', data: 'wrong' },
{
name: process.platform === 'win32' ? 'package/bin/opencode.exe' : 'package/bin/opencode',
data: 'right',
},
]);
expect(extractOpenCodeRuntimeBinaryFromTarball(tarball).toString()).toBe('right');
});
it('rejects tar path traversal before extraction', () => {
const tarball = createTarball([
{ name: '../opencode', data: 'unsafe' },
{
name: process.platform === 'win32' ? 'package/bin/opencode.exe' : 'package/bin/opencode',
data: 'right',
},
]);
expect(() => extractOpenCodeRuntimeBinaryFromTarball(tarball)).toThrow(
'Unsafe OpenCode package tar entry'
);
});
});

View file

@ -320,6 +320,11 @@ describe('ClaudeMultimodelBridgeService', () => {
'runtime status --json --provider opencode',
])
);
expect(
execCliMock.mock.calls
.filter((call) => call[1].join(' ').startsWith('runtime status --json --provider '))
.map((call) => call[2]?.maxBuffer)
).toEqual([8 * 1024 * 1024, 8 * 1024 * 1024, 8 * 1024 * 1024, 8 * 1024 * 1024]);
expect(providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
@ -448,6 +453,12 @@ describe('ClaudeMultimodelBridgeService', () => {
upgrade: false,
source: 'anthropic-models-api',
badgeLabel: 'Opus 4.8',
metadata: {
cost: { input: 0, output: 0 },
context: 200000,
limits: { context: 200000, output: 32000 },
free: true,
},
},
{
id: 'opus[1m]',
@ -539,6 +550,12 @@ describe('ClaudeMultimodelBridgeService', () => {
hidden: false,
source: 'anthropic-models-api',
badgeLabel: 'Opus 4.8',
metadata: {
cost: { input: 0, output: 0 },
context: 200000,
limits: { context: 200000, output: 32000 },
free: true,
},
}),
expect.objectContaining({
launchModel: 'opus[1m]',

View file

@ -107,6 +107,54 @@ describe('ProviderConnectionService', () => {
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
});
it('does not decrypt stored Anthropic keys when metadata-only env building is requested', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',
value: 'stored-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () => createConfig('api_key'),
} as never
);
const result = await service.applyConfiguredConnectionEnv({}, 'anthropic', undefined, {
allowStoredApiKeyDecryption: false,
});
expect(lookupPreferred).not.toHaveBeenCalled();
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
});
it('injects stored Gemini API keys for runtime launches', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'GEMINI_API_KEY',
value: 'gemini-stored-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
const result = await service.applyConfiguredConnectionEnv({}, 'gemini');
expect(lookupPreferred).toHaveBeenCalledWith('GEMINI_API_KEY');
expect(result.GEMINI_API_KEY).toBe('gemini-stored-key');
});
it('reports a missing Anthropic API key when api_key mode is selected', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');

View file

@ -10,9 +10,11 @@ const applyConfiguredConnectionEnvMock = vi.fn();
const applyAllConfiguredConnectionEnvMock = vi.fn();
const getConfiguredConnectionIssuesMock = vi.fn();
const getConfiguredConnectionLaunchArgsMock = vi.fn();
const resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock = vi.fn();
vi.mock('@main/utils/cliEnv', () => ({
buildEnrichedEnv: (...args: Parameters<typeof buildEnrichedEnvMock>) => buildEnrichedEnvMock(...args),
buildEnrichedEnv: (...args: Parameters<typeof buildEnrichedEnvMock>) =>
buildEnrichedEnvMock(...args),
}));
vi.mock('@main/utils/shellEnv', () => ({
@ -35,22 +37,31 @@ vi.mock('../../../../src/main/services/infrastructure/ConfigManager', () => ({
vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () => ({
providerConnectionService: {
augmentConfiguredConnectionEnv: (...args: Parameters<typeof augmentConfiguredConnectionEnvMock>) =>
augmentConfiguredConnectionEnvMock(...args),
augmentAllConfiguredConnectionEnv: (...args: Parameters<typeof augmentAllConfiguredConnectionEnvMock>) =>
augmentAllConfiguredConnectionEnvMock(...args),
augmentConfiguredConnectionEnv: (
...args: Parameters<typeof augmentConfiguredConnectionEnvMock>
) => augmentConfiguredConnectionEnvMock(...args),
augmentAllConfiguredConnectionEnv: (
...args: Parameters<typeof augmentAllConfiguredConnectionEnvMock>
) => augmentAllConfiguredConnectionEnvMock(...args),
applyConfiguredConnectionEnv: (...args: Parameters<typeof applyConfiguredConnectionEnvMock>) =>
applyConfiguredConnectionEnvMock(...args),
applyAllConfiguredConnectionEnv: (...args: Parameters<typeof applyAllConfiguredConnectionEnvMock>) =>
applyAllConfiguredConnectionEnvMock(...args),
applyAllConfiguredConnectionEnv: (
...args: Parameters<typeof applyAllConfiguredConnectionEnvMock>
) => applyAllConfiguredConnectionEnvMock(...args),
getConfiguredConnectionLaunchArgs: (
...args: Parameters<typeof getConfiguredConnectionLaunchArgsMock>
) => getConfiguredConnectionLaunchArgsMock(...args),
getConfiguredConnectionIssues: (...args: Parameters<typeof getConfiguredConnectionIssuesMock>) =>
getConfiguredConnectionIssuesMock(...args),
getConfiguredConnectionIssues: (
...args: Parameters<typeof getConfiguredConnectionIssuesMock>
) => getConfiguredConnectionIssuesMock(...args),
},
}));
vi.mock('../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService', () => ({
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () =>
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock(),
}));
describe('buildProviderAwareCliEnv', () => {
beforeEach(() => {
vi.resetModules();
@ -76,6 +87,7 @@ describe('buildProviderAwareCliEnv', () => {
);
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]);
getConfiguredConnectionIssuesMock.mockResolvedValue({});
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null);
});
it('builds provider-pinned CLI env and returns provider-specific issues', async () => {
@ -83,9 +95,8 @@ describe('buildProviderAwareCliEnv', () => {
anthropic: 'missing key',
});
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
binaryPath: '/mock/claude',
providerId: 'anthropic',
@ -112,10 +123,27 @@ describe('buildProviderAwareCliEnv', () => {
expect(result.providerArgs).toEqual([]);
});
it('builds shared env for generic CLI launches when no provider is specified', async () => {
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
it('passes metadata-only stored API key access through provider env building', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
await buildProviderAwareCliEnv({
providerId: 'anthropic',
allowStoredApiKeyDecryption: false,
});
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
expect.objectContaining({
CLAUDE_CODE_ENTRY_PROVIDER: 'anthropic',
}),
'anthropic',
undefined,
{ allowStoredApiKeyDecryption: false }
);
});
it('builds shared env for generic CLI launches when no provider is specified', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv();
expect(applyAllConfiguredConnectionEnvMock).toHaveBeenCalledWith(
@ -140,9 +168,8 @@ describe('buildProviderAwareCliEnv', () => {
PATH: '/usr/bin',
OPENCODE_DISABLE_AUTOUPDATE: '1',
});
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
env: {
@ -155,9 +182,8 @@ describe('buildProviderAwareCliEnv', () => {
});
it('uses non-destructive credential augmentation for PTY-style envs', async () => {
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
connectionMode: 'augment',
env: {
@ -176,9 +202,8 @@ describe('buildProviderAwareCliEnv', () => {
});
it('preserves caller-provided HOME and USERPROFILE overrides', async () => {
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
providerId: 'anthropic',
env: {
@ -201,9 +226,8 @@ describe('buildProviderAwareCliEnv', () => {
});
it('preserves explicit backend overrides passed by the caller', async () => {
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
connectionMode: 'augment',
env: {
@ -227,9 +251,8 @@ describe('buildProviderAwareCliEnv', () => {
PATH: '/usr/bin',
});
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
providerId: 'codex',
});
@ -251,9 +274,8 @@ describe('buildProviderAwareCliEnv', () => {
'{"codex":{"forced_login_method":"chatgpt"}}',
]);
const { buildProviderAwareCliEnv } = await import(
'../../../../src/main/services/runtime/providerAwareCliEnv'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
binaryPath: '/mock/claude-multimodel',
providerId: 'codex',
@ -272,4 +294,42 @@ describe('buildProviderAwareCliEnv', () => {
'{"codex":{"forced_login_method":"chatgpt"}}',
]);
});
it('injects the verified app-managed OpenCode binary for OpenCode launches', async () => {
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(
'/Users/tester/App Support/runtimes/opencode/current/opencode'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
providerId: 'opencode',
});
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
expect.objectContaining({
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH:
'/Users/tester/App Support/runtimes/opencode/current/opencode',
}),
'opencode',
undefined
);
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(
'/Users/tester/App Support/runtimes/opencode/current/opencode'
);
});
it('does not inject the app-managed OpenCode binary into non-OpenCode provider launches', async () => {
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(
'/Users/tester/App Support/runtimes/opencode/current/opencode'
);
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
const result = await buildProviderAwareCliEnv({
providerId: 'anthropic',
});
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
});
});

View file

@ -308,9 +308,7 @@ describe('OpenCodeReadinessBridge', () => {
);
});
it('recovers accepted OpenCode sendMessage after bridge timeout through commandStatus when enabled', async () => {
const previous = process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY;
process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = '1';
it('recovers accepted OpenCode sendMessage after bridge timeout through commandStatus by default', async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeCommandSuccess({
@ -328,34 +326,26 @@ describe('OpenCodeReadinessBridge', () => {
]);
const bridge = new OpenCodeReadinessBridge(executor);
try {
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: true,
sessionId: 'session-bob',
diagnostics: expect.arrayContaining([
expect.objectContaining({
code: 'opencode_send_recovered_after_bridge_timeout',
}),
]),
});
} finally {
if (previous === undefined) {
delete process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY;
} else {
process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = previous;
}
}
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: true,
sessionId: 'session-bob',
diagnostics: expect.arrayContaining([
expect.objectContaining({
code: 'opencode_send_recovered_after_bridge_timeout',
}),
]),
});
expect(executor.execute).toHaveBeenCalledTimes(2);
const sendOptions = executor.execute.mock.calls[0]?.[2] as { requestId?: string } | undefined;
@ -375,152 +365,142 @@ describe('OpenCodeReadinessBridge', () => {
});
it('does not query commandStatus for non-timeout OpenCode sendMessage failures', async () => {
await withCommandStatusRecoveryEnabled(async () => {
const executor = fakeExecutor(
bridgeFailure('provider_error', 'OpenCode send failed', [])
);
const bridge = new OpenCodeReadinessBridge(executor);
const executor = fakeExecutor(bridgeFailure('provider_error', 'OpenCode send failed', []));
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'provider_error',
}),
],
});
expect(executor.execute).toHaveBeenCalledOnce();
});
});
it('keeps the old send failure path when timeout commandStatus is unknown', async () => {
await withCommandStatusRecoveryEnabled(async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeCommandSuccess({
command: 'opencode.commandStatus',
requestId: 'status-req-1',
data: {
status: 'unknown',
safeToRetry: false,
accepted: false,
diagnostics: ['No orchestrator-side command outcome record matched the requested OpenCode command.'],
},
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'provider_error',
}),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
],
});
expect(executor.execute).toHaveBeenCalledOnce();
});
it('keeps the old send failure path when timeout commandStatus is unavailable', async () => {
await withCommandStatusRecoveryEnabled(async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeFailure('timeout', 'OpenCode commandStatus timed out', []),
]);
const bridge = new OpenCodeReadinessBridge(executor);
it('keeps the timeout failure path when timeout commandStatus is unknown', async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeCommandSuccess({
command: 'opencode.commandStatus',
requestId: 'status-req-1',
data: {
status: 'unknown',
safeToRetry: false,
accepted: false,
diagnostics: ['No orchestrator-side command outcome record matched the requested OpenCode command.'],
},
}),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
});
});
it('keeps the old send failure path when timeout commandStatus reports precondition mismatch', async () => {
await withCommandStatusRecoveryEnabled(async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeCommandSuccess({
command: 'opencode.commandStatus',
requestId: 'status-req-1',
data: {
status: 'precondition_mismatch',
safeToRetry: false,
accepted: false,
diagnostics: ['OpenCode command status payloadHash mismatch.'],
},
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
});
it('keeps the timeout failure path when timeout commandStatus is unavailable', async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeFailure('timeout', 'OpenCode commandStatus timed out', []),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
});
it('keeps the timeout failure path when timeout commandStatus reports precondition mismatch', async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeCommandSuccess({
command: 'opencode.commandStatus',
requestId: 'status-req-1',
data: {
status: 'precondition_mismatch',
safeToRetry: false,
accepted: false,
diagnostics: ['OpenCode command status payloadHash mismatch.'],
},
}),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
});
it('routes state-changing launch commands through the guarded command service when configured', async () => {
@ -610,20 +590,6 @@ function fakeSequenceExecutor(
};
}
async function withCommandStatusRecoveryEnabled<T>(callback: () => Promise<T>): Promise<T> {
const previous = process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY;
process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = '1';
try {
return await callback();
} finally {
if (previous === undefined) {
delete process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY;
} else {
process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = previous;
}
}
}
function bridgeSuccess(
data: OpenCodeTeamLaunchReadiness
): OpenCodeBridgeSuccess<OpenCodeTeamLaunchReadiness> {

View file

@ -187,6 +187,29 @@ describe('TeamLaunchFailureArtifactPack', () => {
).toBe('provider_quota');
});
it('classifies Claude Code workspace trust failures separately', () => {
const reason =
'Teammate "Gayani" cannot start in headless process runtime because workspace trust is not accepted for "C:\\Users\\vilok\\OneDrive\\Desktop\\Safar 0.1". Open that workspace once interactively and accept trust, then launch the team again.';
const classification = classifyLaunchFailureArtifact({
teamName: 'artifact-team',
runId: 'run-workspace-trust',
reason: 'Deterministic bootstrap failed',
memberSpawnStatuses: {
Gayani: {
status: 'error',
launchState: 'failed_to_start',
hardFailureReason: reason,
updatedAt: '2026-05-12T00:00:00.000Z',
},
},
progressTraceLines: [reason],
});
expect(classification.code).toBe('workspace_trust_required');
expect(classification.evidence.join('\n')).toContain('workspace trust is not accepted');
});
it.each([
{
name: 'stdin warning',

View file

@ -207,6 +207,53 @@ describe('TeamMemberLogsFinder', () => {
expect(projectResolver.getContext).toHaveBeenCalledTimes(2);
});
it('dedupes concurrent forceRefresh log source discovery for the same team', async () => {
const teamName = 'dedupe-force-refresh-context-team';
let resolveContext!: (value: unknown) => void;
const contextPromise = new Promise((resolve) => {
resolveContext = resolve;
});
const projectResolver = {
getContext: vi.fn(() => contextPromise),
getLiveBaseContext: vi.fn(),
};
const inboxReader = { listInboxNames: vi.fn(async () => []) };
const membersMetaStore = { getMembers: vi.fn(async () => []) };
const finder = new TeamMemberLogsFinder(
undefined,
inboxReader as never,
membersMetaStore as never,
projectResolver as never
);
const first = finder.getLogSourceWatchContext(teamName, { forceRefresh: true });
const second = finder.getLogSourceWatchContext(teamName, { forceRefresh: true });
await Promise.resolve();
expect(projectResolver.getContext).toHaveBeenCalledTimes(1);
resolveContext({
projectDir: '/tmp/project',
projectId: 'project',
sessionIds: ['session-1'],
config: { name: teamName, projectPath: '/repo', members: [] },
});
await expect(Promise.all([first, second])).resolves.toEqual([
{
projectDir: '/tmp/project',
projectPath: '/repo',
leadSessionId: undefined,
sessionIds: ['session-1'],
},
{
projectDir: '/tmp/project',
projectPath: '/repo',
leadSessionId: undefined,
sessionIds: ['session-1'],
},
]);
});
it('returns subagent logs for a member and lead session for team-lead', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
@ -531,6 +578,186 @@ describe('TeamMemberLogsFinder', () => {
expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false);
});
it('does not leak old same-workspace subagent logs into a newly created team', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-scope-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'aurora-room-scope';
const projectPath = '/Users/test/shared-workspace';
const projectId = '-Users-test-shared-workspace';
const leadSessionId = 'fresh-lead-session';
const unrelatedSessionId = 'old-other-team-session';
const now = new Date();
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify(
{
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'Alice', agentType: 'general-purpose' },
],
},
null,
2
),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
const currentSubagentsDir = path.join(projectRoot, leadSessionId, 'subagents');
const unrelatedSubagentsDir = path.join(projectRoot, unrelatedSessionId, 'subagents');
await fs.mkdir(currentSubagentsDir, { recursive: true });
await fs.mkdir(unrelatedSubagentsDir, { recursive: true });
const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`);
await fs.writeFile(
leadPath,
JSON.stringify({
timestamp: now.toISOString(),
type: 'user',
message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` },
}) + '\n',
'utf8'
);
const currentAlicePath = path.join(currentSubagentsDir, 'agent-alice.jsonl');
await fs.writeFile(
currentAlicePath,
JSON.stringify({
timestamp: now.toISOString(),
type: 'user',
message: {
role: 'user',
content: `You are Alice, a developer on team "${teamName}" (${teamName}).`,
},
}) + '\n',
'utf8'
);
const unrelatedAlicePath = path.join(unrelatedSubagentsDir, 'agent-alice.jsonl');
await fs.writeFile(
unrelatedAlicePath,
JSON.stringify({
timestamp: now.toISOString(),
type: 'user',
message: {
role: 'user',
content: 'You are Alice, a developer on team "old-team" (old-team).',
},
}) + '\n',
'utf8'
);
const refs = await new TeamMemberLogsFinder().findRecentMemberLogFileRefsByMember(
teamName,
['team-lead', 'Alice'],
{ forceRefresh: true }
);
expect(refs).toEqual(
expect.arrayContaining([
expect.objectContaining({ memberName: 'team-lead', filePath: leadPath }),
expect.objectContaining({ memberName: 'Alice', filePath: currentAlicePath }),
])
);
expect(refs.some((ref) => ref.filePath === unrelatedAlicePath)).toBe(false);
});
it('can skip untracked team subagent session discovery for graph previews', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-preview-scope-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'preview-known-session-scope';
const projectPath = '/Users/test/preview-known-session-scope';
const projectId = '-Users-test-preview-known-session-scope';
const leadSessionId = 'known-lead-session';
const untrackedSessionId = 'team-subagent-only-session';
const now = new Date();
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify(
{
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'Alice', agentType: 'general-purpose' },
{ name: 'Bob', agentType: 'general-purpose' },
],
},
null,
2
),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
const knownSubagentsDir = path.join(projectRoot, leadSessionId, 'subagents');
const untrackedSubagentsDir = path.join(projectRoot, untrackedSessionId, 'subagents');
await fs.mkdir(knownSubagentsDir, { recursive: true });
await fs.mkdir(untrackedSubagentsDir, { recursive: true });
await fs.writeFile(
path.join(projectRoot, `${leadSessionId}.jsonl`),
JSON.stringify({
timestamp: now.toISOString(),
type: 'user',
message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` },
}) + '\n',
'utf8'
);
const knownAlicePath = path.join(knownSubagentsDir, 'agent-alice.jsonl');
await fs.writeFile(
knownAlicePath,
JSON.stringify({
timestamp: now.toISOString(),
type: 'user',
message: {
role: 'user',
content: `You are Alice, a developer on team "${teamName}" (${teamName}).`,
},
}) + '\n',
'utf8'
);
const untrackedBobPath = path.join(untrackedSubagentsDir, 'agent-bob.jsonl');
await fs.writeFile(
untrackedBobPath,
JSON.stringify({
timestamp: now.toISOString(),
type: 'user',
message: {
role: 'user',
content: `You are Bob, a developer on team "${teamName}" (${teamName}).`,
},
}) + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
const fastRefs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['Alice', 'Bob'], {
forceRefresh: true,
includeTeamSubagentSessionDiscovery: false,
});
expect(fastRefs.some((ref) => ref.filePath === knownAlicePath)).toBe(true);
expect(fastRefs.some((ref) => ref.filePath === untrackedBobPath)).toBe(false);
const fullRefs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['Alice', 'Bob'], {
forceRefresh: true,
});
expect(fullRefs.some((ref) => ref.filePath === knownAlicePath)).toBe(true);
expect(fullRefs.some((ref) => ref.filePath === untrackedBobPath)).toBe(true);
});
it('applies recent-ref object options to discovery, lead refs, metadata, and requested-member attribution', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -703,6 +703,87 @@ describe('TeamProvisioningService', () => {
});
describe('team launch notifications', () => {
it('fires team launched when the last pending teammate joins after ready', async () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
NotificationManager.setInstance({ addTeamNotification } as never);
try {
const svc = new TeamProvisioningService();
const run = {
runId: 'run-late-all-joined',
teamName: 'late-all-joined-team',
isLaunch: true,
provisioningComplete: true,
processKilled: false,
cancelRequested: false,
progress: { state: 'ready' },
request: {
cwd: tempClaudeRoot,
displayName: 'late-all-joined-team',
},
expectedMembers: ['alice', 'bob'],
allEffectiveMembers: [{ name: 'alice' }, { name: 'bob' }],
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
}),
],
[
'bob',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
}),
],
]),
};
(svc as any).runs.set(run.runId, run);
(svc as any).aliveRunByTeam.set(run.teamName, run.runId);
(svc as any).emitMemberSpawnChange(run, 'bob');
expect(addTeamNotification).not.toHaveBeenCalled();
run.memberSpawnStatuses.set(
'bob',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
})
);
(svc as any).emitMemberSpawnChange(run, 'bob');
await Promise.resolve();
expect(addTeamNotification).toHaveBeenCalledTimes(1);
expect(addTeamNotification).toHaveBeenCalledWith(
expect.objectContaining({
teamEventType: 'team_launched',
teamName: 'late-all-joined-team',
dedupeKey: 'team_launched:late-all-joined-team:run-late-all-joined',
body:
'Team "late-all-joined-team" has been launched - all 2 teammates joined and are ready for tasks.',
})
);
(svc as any).emitMemberSpawnChange(run, 'bob');
await Promise.resolve();
expect(addTeamNotification).toHaveBeenCalledTimes(1);
} finally {
NotificationManager.resetInstance();
}
});
it('does not fire incomplete notification for pending-only teammates still joining', async () => {
const { NotificationManager } =
await import('@main/services/infrastructure/NotificationManager');
@ -17476,6 +17557,58 @@ describe('TeamProvisioningService', () => {
});
});
it('reports workspace trust failures with a specific deterministic bootstrap title', () => {
const reason =
'Teammate "Gayani" cannot start in headless process runtime because workspace trust is not accepted for "C:\\Users\\vilok\\OneDrive\\Desktop\\Safar 0.1". Open that workspace once interactively and accept trust, then launch the team again.';
const progressUpdates: any[] = [];
const run = createMemberSpawnRun({
runId: 'run-workspace-trust-bootstrap',
teamName: 'workspace-trust-bootstrap-team',
expectedMembers: ['Gayani'],
});
Object.assign(run, {
cancelRequested: false,
isLaunch: false,
lastDeterministicBootstrapSeq: 0,
progress: {
runId: run.runId,
teamName: run.teamName,
state: 'assembling',
message: 'Spawning teammate runtimes',
startedAt: '2026-05-12T10:00:00.000Z',
updatedAt: '2026-05-12T10:00:00.000Z',
},
onProgress: (progress: any) => {
progressUpdates.push(progress);
},
});
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null);
vi.spyOn(svc as any, 'cleanupRun').mockImplementation(() => {});
const handled = (svc as any).handleDeterministicBootstrapEvent(run, {
type: 'system',
subtype: 'team_bootstrap',
event: 'failed',
reason,
run_id: run.runId,
team_name: run.teamName,
seq: 1,
});
expect(handled).toBe(true);
expect(progressUpdates.at(-1)).toMatchObject({
state: 'failed',
message: 'Workspace trust required',
error: reason,
});
expect(run.memberSpawnStatuses.get('Gayani')).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
hardFailureReason: reason,
});
});
it('clears stale failed_to_start state when live runtime metadata proves the teammate is alive', async () => {
const svc = new TeamProvisioningService();
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(

View file

@ -136,7 +136,11 @@ describe('TeamTranscriptProjectResolver', () => {
},
];
await fs.writeFile(jsonlPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
await fs.writeFile(
jsonlPath,
`${lines.map((line) => JSON.stringify(line)).join('\n')}\n`,
'utf8'
);
return { projectDir, jsonlPath };
}
@ -257,7 +261,9 @@ describe('TeamTranscriptProjectResolver', () => {
const staleProjectPath = '/Users/test/hookplex';
const repairedProjectPath = '/Users/test/plugin-kit-ai';
const historicalSessionId = 'lead-old';
await fs.mkdir(path.join(tmpDir!, 'projects', encodePath(staleProjectPath)), { recursive: true });
await fs.mkdir(path.join(tmpDir!, 'projects', encodePath(staleProjectPath)), {
recursive: true,
});
const repaired = await createSessionFile(repairedProjectPath, historicalSessionId);
await writeTeamConfig(teamName, {
@ -391,7 +397,9 @@ describe('TeamTranscriptProjectResolver', () => {
expect(warnSpy.mock.calls).toEqual(
expect.arrayContaining([
expect.arrayContaining([
expect.stringContaining('Transcript project resolution ambiguous across exact-session candidates'),
expect.stringContaining(
'Transcript project resolution ambiguous across exact-session candidates'
),
]),
])
);
@ -427,7 +435,9 @@ describe('TeamTranscriptProjectResolver', () => {
expect(warnSpy.mock.calls).toEqual(
expect.arrayContaining([
expect.arrayContaining([
expect.stringContaining('Transcript project resolution ambiguous across exact-session candidates'),
expect.stringContaining(
'Transcript project resolution ambiguous across exact-session candidates'
),
]),
])
);
@ -510,4 +520,61 @@ describe('TeamTranscriptProjectResolver', () => {
expect(context?.projectDir).toBe(repaired.projectDir);
expect(context?.config.projectPath).toBe(repairedProjectPath);
});
it('bounds root session discovery by team lifecycle in fast preview context', async () => {
await setupClaudeRoot();
const teamName = 'fast-preview-team';
const projectPath = '/Users/test/fast-preview';
const createdAt = Date.parse('2026-04-18T12:00:00.000Z');
const leadSessionId = 'lead-fast';
const lead = await createTeamAwareSessionFile(projectPath, leadSessionId, teamName, 'text');
const recent = await createTeamAwareSessionFile(
projectPath,
'recent-member-session',
teamName,
'text'
);
const old = await createTeamAwareSessionFile(
projectPath,
'old-member-session',
teamName,
'text'
);
await fs.utimes(lead.jsonlPath, new Date(createdAt + 60_000), new Date(createdAt + 60_000));
await fs.utimes(
recent.jsonlPath,
new Date(createdAt + 5 * 60_000),
new Date(createdAt + 5 * 60_000)
);
await fs.utimes(
old.jsonlPath,
new Date(createdAt - 25 * 60 * 60_000),
new Date(createdAt - 25 * 60 * 60_000)
);
await writeTeamConfig(teamName, {
name: 'Fast Preview Team',
createdAt,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead', joinedAt: createdAt, cwd: projectPath },
{ name: 'alice', agentType: 'general-purpose', joinedAt: createdAt + 5 * 60_000 },
],
} as TeamConfig);
const resolver = new TeamTranscriptProjectResolver();
const fastContext = await resolver.getContext(teamName, {
forceRefresh: true,
includeTeamSubagentSessionDiscovery: false,
});
const fullContext = await resolver.getContext(teamName, { forceRefresh: true });
expect(fastContext?.projectDir).toBe(lead.projectDir);
expect(fastContext?.sessionIds).toEqual(expect.arrayContaining([leadSessionId]));
expect(fastContext?.sessionIds).toContain('recent-member-session');
expect(fastContext?.sessionIds).not.toContain('old-member-session');
expect(fullContext?.sessionIds).toContain('old-member-session');
});
});

View file

@ -107,6 +107,25 @@ describe('TeamTranscriptSourceLocator', () => {
}) + '\n',
'utf8'
);
const unrelatedSubagentPath = path.join(
projectRoot,
'unrelated-session-dir',
'subagents',
'agent-bob.jsonl'
);
await fs.mkdir(path.dirname(unrelatedSubagentPath), { recursive: true });
await fs.writeFile(
unrelatedSubagentPath,
JSON.stringify({
timestamp: '2026-04-15T14:02:02.000Z',
type: 'user',
message: {
role: 'user',
content: 'You are bob, a developer on team "other-team" (other-team).',
},
}) + '\n',
'utf8'
);
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'),
JSON.stringify({
@ -126,6 +145,7 @@ describe('TeamTranscriptSourceLocator', () => {
expect(context?.config.projectPath).toBe(projectPath);
expect(context?.sessionIds).toEqual(expect.arrayContaining([leadSessionId, memberSessionId]));
expect(context?.sessionIds).not.toContain('unrelated-session');
expect(context?.sessionIds).not.toContain('unrelated-session-dir');
expect(transcriptFiles).toEqual(
expect.arrayContaining([
path.join(projectRoot, `${leadSessionId}.jsonl`),
@ -134,6 +154,7 @@ describe('TeamTranscriptSourceLocator', () => {
])
);
expect(transcriptFiles).not.toContain(path.join(projectRoot, 'unrelated-session.jsonl'));
expect(transcriptFiles).not.toContain(unrelatedSubagentPath);
});
it('returns the same sorted transcript set across multiple session directories', async () => {
@ -193,11 +214,7 @@ describe('TeamTranscriptSourceLocator', () => {
}) + '\n',
'utf8'
);
await fs.writeFile(
path.join(subagentsDir, 'agent-acompact-ignore.jsonl'),
'{}\n',
'utf8'
);
await fs.writeFile(path.join(subagentsDir, 'agent-acompact-ignore.jsonl'), '{}\n', 'utf8');
expectedFiles.push(rootTranscript, subagentTranscript);
}

View file

@ -437,9 +437,8 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('shows an OpenCode download action on the dashboard when the OpenCode CLI is missing', async () => {
it('shows an OpenCode install action on the dashboard when the OpenCode CLI is missing', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const { api } = await import('@renderer/api');
storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
@ -478,19 +477,137 @@ describe('CLI status visibility during completed install state', () => {
});
expect(host.textContent).toContain('OpenCode (75+ LLM providers)');
expect(host.textContent).toContain('Download');
expect(host.textContent).toContain('Install');
const downloadButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Download'
const installButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Install'
);
expect(downloadButton).not.toBeUndefined();
expect(installButton).not.toBeUndefined();
await act(async () => {
downloadButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
installButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(api.openExternal).toHaveBeenCalledWith('https://opencode.ai/download');
expect(storeState.installOpenCodeRuntime).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows OpenCode app-managed install progress on the dashboard provider card', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.openCodeRuntimeStatus = {
installed: false,
source: 'missing',
state: 'downloading',
progress: {
phase: 'downloading',
downloadedBytes: 42,
totalBytes: 100,
percent: 42,
detail: 'Downloading OpenCode 42%',
},
};
storeState.openCodeRuntimeStatusLoading = true;
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: false,
providers: [
{
providerId: 'opencode',
displayName: 'OpenCode (75+ LLM providers)',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'error',
statusMessage: 'OpenCode CLI is not installed.',
models: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: false,
oneShot: false,
},
backend: null,
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Downloading 42%');
const progressButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Downloading 42%'
);
expect(progressButton).not.toBeUndefined();
expect(progressButton).toHaveProperty('disabled', true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('always shows a provider-level Free models badge on the OpenCode dashboard card', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
providers: [
{
providerId: 'opencode',
displayName: 'OpenCode (75+ LLM providers)',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
statusMessage: 'Ready',
models: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: false,
},
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
modelCatalog: null,
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
const providerFreeBadge = host.querySelector('[title*="Big Pickle"]');
expect(providerFreeBadge?.textContent).toBe('Free models');
expect(providerFreeBadge?.getAttribute('title')).toContain('OpenRouter');
expect(providerFreeBadge?.getAttribute('title')).toContain(
'not every OpenCode/OpenRouter model is free'
);
await act(async () => {
root.unmount();

View file

@ -348,6 +348,7 @@ describe('ExtensionStoreView provider loading placeholders', () => {
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true });
expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
expect(storeState.fetchApiKeys).not.toHaveBeenCalled();
expect(host.textContent).toContain('Multimodel runtime capabilities');
expect(host.textContent).toContain('Anthropic');

View file

@ -7,7 +7,7 @@ import type { CliInstallationStatus } from '@shared/types';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
interface StoreState {
apiKeys: Array<{
apiKeys: {
id: string;
providerId: string;
displayName: string;
@ -15,13 +15,14 @@ interface StoreState {
scope: 'user';
createdAt: number;
updatedAt: number;
}>;
}[];
apiKeysLoading: boolean;
apiKeysError: string | null;
apiKeyStorageStatus: {
encryptionMethod: 'os-keychain' | 'local-aes';
backend: string;
} | null;
fetchApiKeys: ReturnType<typeof vi.fn>;
fetchApiKeyStorageStatus: ReturnType<typeof vi.fn>;
cliStatus: CliInstallationStatus | null;
cliStatusLoading: boolean;
@ -187,6 +188,7 @@ describe('ApiKeysPanel', () => {
encryptionMethod: 'os-keychain',
backend: 'Keychain Access',
};
storeState.fetchApiKeys = vi.fn().mockResolvedValue(undefined);
storeState.fetchApiKeyStorageStatus = vi.fn().mockResolvedValue(undefined);
storeState.cliStatus = createCliStatus();
storeState.cliStatusLoading = false;
@ -253,6 +255,8 @@ describe('ApiKeysPanel', () => {
expect(host.textContent).toContain('Connected');
expect(host.textContent).toContain('Current source: Detected from OPENAI_API_KEY.');
expect(host.textContent).toContain('ChatGPT account ready');
expect(storeState.fetchApiKeys).toHaveBeenCalledTimes(1);
expect(storeState.fetchApiKeyStorageStatus).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();

View file

@ -109,7 +109,115 @@ describe('ProviderModelBadges', () => {
expect(host.textContent).toContain('Free');
});
it('collapses long model lists and expands them into a bounded scroll area', () => {
it('renders paid and free OpenCode models together without marking every model free', () => {
const host = render(
<ProviderModelBadges
providerId="opencode"
models={['opencode/big-pickle', 'openai/gpt-5.4']}
providerStatus={{
providerId: 'opencode',
authMethod: 'opencode_managed',
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-12T00:00:00.000Z',
staleAt: '2026-05-12T00:10:00.000Z',
defaultModelId: 'opencode/big-pickle',
defaultLaunchModel: 'opencode/big-pickle',
models: [
{
id: 'opencode/big-pickle',
launchModel: 'opencode/big-pickle',
displayName: 'opencode/big-pickle',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'app-server',
badgeLabel: 'Free',
},
{
id: 'openai/gpt-5.4',
launchModel: 'openai/gpt-5.4',
displayName: 'openai/gpt-5.4',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: false,
upgrade: false,
source: 'app-server',
badgeLabel: null,
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
}}
/>
);
expect(host.textContent).toContain('big-pickle');
expect(host.textContent).toContain('GPT-5.4');
expect(host.textContent?.match(/Free/g)).toHaveLength(1);
});
it('does not duplicate a catalog badge that matches the displayed model label', () => {
const host = render(
<ProviderModelBadges
providerId="anthropic"
models={['claude-opus-4-6']}
providerStatus={{
providerId: 'anthropic',
authMethod: 'oauth_token',
backend: { kind: 'anthropic', label: 'Anthropic' },
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-models-api',
status: 'ready',
fetchedAt: '2026-05-12T00:00:00.000Z',
staleAt: '2026-05-12T00:10:00.000Z',
defaultModelId: 'claude-opus-4-6',
defaultLaunchModel: 'claude-opus-4-6',
models: [
{
id: 'claude-opus-4-6',
launchModel: 'claude-opus-4-6',
displayName: 'Opus 4.6',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'anthropic-models-api',
badgeLabel: 'Opus 4.6',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
}}
/>
);
expect(host.textContent?.match(/Opus 4\.6/g)).toHaveLength(1);
});
it('collapses long model lists and expands them inline without an internal scroll area', () => {
const models = Array.from(
{ length: 18 },
(_, index) => `model-${String(index + 1).padStart(2, '0')}`
@ -134,8 +242,8 @@ describe('ProviderModelBadges', () => {
expect(host.textContent).toContain('model-18');
expect(host.textContent).toContain('Hide');
const list = host.firstElementChild?.firstElementChild as HTMLElement | null;
expect(list?.style.maxHeight).toBe('200px');
expect(list?.style.overflowY).toBe('auto');
expect(list?.style.maxHeight).toBe('');
expect(list?.style.overflowY).toBe('');
const hideButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Hide')
@ -149,4 +257,41 @@ describe('ProviderModelBadges', () => {
expect(host.textContent).not.toContain('model-16');
expect(host.textContent).toContain('+3 more');
});
it('limits collapsed model badges by rendered rows when requested', () => {
const originalOffsetTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop');
Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
configurable: true,
get() {
const siblings = Array.from(this.parentElement?.children ?? []);
const index = Math.max(0, siblings.indexOf(this));
return Math.floor(index / 3) * 20;
},
});
try {
const models = Array.from(
{ length: 18 },
(_, index) => `model-${String(index + 1).padStart(2, '0')}`
);
const host = render(
<ProviderModelBadges
providerId="codex"
models={models}
collapseAfter={15}
maxCollapsedRows={2}
/>
);
expect(host.textContent).toContain('model-05');
expect(host.textContent).not.toContain('model-06');
expect(host.textContent).toContain('+13 more');
} finally {
if (originalOffsetTop) {
Object.defineProperty(HTMLElement.prototype, 'offsetTop', originalOffsetTop);
} else {
delete (HTMLElement.prototype as { offsetTop?: number }).offsetTop;
}
}
});
});

View file

@ -123,6 +123,66 @@ describe('MemberList spawn-status memoization', () => {
document.body.innerHTML = '';
});
it('does not label an empty roster as solo when the team summary still expects teammates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberList, {
members: [],
expectedTeammateCount: 2,
isTeamAlive: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Team members are loading');
expect(host.textContent).not.toContain('Solo team');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not render a lead-only roster while expected teammates are still loading', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberList, {
members: [
{
...member,
name: 'team-lead',
agentType: 'team-lead',
role: 'Team Lead',
},
],
expectedTeammateCount: 2,
isTeamAlive: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Team members are loading');
expect(host.querySelector('[data-testid="member-team-lead"]')).toBeNull();
expect(host.textContent).not.toContain('Solo team');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('rerenders cards when only the hard failure reason changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -178,8 +178,8 @@ describe('GraphView pan interactions', () => {
kind: 'task',
label: 'Task 1',
state: 'idle',
x: 160,
y: 90,
x: 300,
y: 180,
domainRef: { kind: 'task', teamName: 'demo-team', taskId: 'task:1' },
};
const edge: GraphEdge = {

View file

@ -6,6 +6,7 @@ import type { GraphNode } from '@claude-teams/agent-graph';
function createMockContext() {
const arcCalls: Array<{ x: number; y: number; radius: number }> = [];
const fillTextCalls: Array<{ text: string; x: number; y: number }> = [];
const gradient = { addColorStop: vi.fn() };
let fillStyle: string | CanvasGradient | CanvasPattern = '';
let globalAlpha = 1;
@ -34,7 +35,9 @@ function createMockContext() {
createRadialGradient: vi.fn(() => gradient),
createLinearGradient: vi.fn(() => gradient),
measureText: vi.fn((text: string) => ({ width: text.length * 4.5 })),
fillText: vi.fn(),
fillText: vi.fn((text: string, x: number, y: number) => {
fillTextCalls.push({ text: String(text), x, y });
}),
strokeText: vi.fn(),
shadowColor: '',
shadowBlur: 0,
@ -59,7 +62,7 @@ function createMockContext() {
},
} as unknown as CanvasRenderingContext2D;
return { ctx, arcCalls };
return { ctx, arcCalls, fillTextCalls };
}
function createTaskNode(hasLiveTaskLogs: boolean): GraphNode {
@ -90,4 +93,37 @@ describe('drawTasks', () => {
expect(active.arcCalls.length).toBeGreaterThanOrEqual(3);
expect(inactive.arcCalls).toHaveLength(0);
});
it('wraps long task subjects into two canvas lines', () => {
const { ctx, fillTextCalls } = createMockContext();
const node = {
...createTaskNode(false),
displayId: '0f505654',
sublabel:
'Review VitePress docs: Get missing developer page ready for publishing search analytics routing navigation validation checklist and docs',
};
drawTasks(ctx, [node], 1, null, null, null, 1);
const firstTitleLine = fillTextCalls.find((call) => call.y === -16);
const secondTitleLine = fillTextCalls.find((call) => call.y === 2);
const displayId = fillTextCalls.find((call) => call.text === '0f505654');
expect(firstTitleLine?.text).toContain('Review VitePress docs');
expect(secondTitleLine?.text).toContain('...');
expect(displayId?.y).toBe(23);
});
it('keeps the compact single-line subject layout for short titles', () => {
const { ctx, fillTextCalls } = createMockContext();
drawTasks(ctx, [createTaskNode(false)], 1, null, null, null, 1);
const titleLine = fillTextCalls.find((call) => call.text === 'Live log task');
const secondTitleLine = fillTextCalls.find((call) => call.y === 2);
const displayId = fillTextCalls.find((call) => call.text === '#1');
expect(titleLine?.y).toBe(-12);
expect(secondTitleLine).toBeUndefined();
expect(displayId?.y).toBe(12);
});
});

View file

@ -344,6 +344,70 @@ describe('cliInstallerSlice', () => {
});
});
describe('OpenCode runtime installer actions', () => {
it('refreshes OpenCode provider status after a successful app-managed install', async () => {
const placeholder = createMultimodelProvider({
providerId: 'opencode',
displayName: 'OpenCode',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'error',
statusMessage: 'OpenCode CLI is not installed.',
models: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
backend: null,
});
const refreshed = createMultimodelProvider({
providerId: 'opencode',
displayName: 'OpenCode',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
models: ['opencode/big-pickle'],
canLoginFromUi: false,
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
});
useStore.setState({
cliStatus: createMultimodelStatus([placeholder]),
});
vi.mocked(api.openCodeRuntime.install).mockResolvedValue({
installed: true,
binaryPath: '/Users/tester/App Support/runtimes/opencode/current/opencode',
version: '1.14.48',
source: 'app-managed',
state: 'ready',
});
vi.mocked(api.cliInstaller.getProviderStatus).mockResolvedValue(refreshed);
await useStore.getState().installOpenCodeRuntime();
expect(api.openCodeRuntime.invalidateStatus).toHaveBeenCalledTimes(1);
expect(api.cliInstaller.invalidateStatus).toHaveBeenCalledTimes(1);
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledWith('opencode');
expect(useStore.getState().openCodeRuntimeStatus).toMatchObject({
installed: true,
source: 'app-managed',
state: 'ready',
});
expect(
useStore
.getState()
.cliStatus?.providers.find((provider) => provider.providerId === 'opencode')
).toMatchObject({
supported: true,
authenticated: true,
models: ['opencode/big-pickle'],
});
});
});
describe('fetchCliStatus', () => {
it('updates cliStatus from API', async () => {
const mockStatus: CliInstallationStatus = {

View file

@ -1796,6 +1796,90 @@ describe('teamSlice actions', () => {
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(0);
});
it('preserves a cached roster when full launch failure metadata confirms the member names', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [
{ name: 'alice', role: 'developer', currentTaskId: null },
{ name: 'bob', role: 'reviewer', currentTaskId: null },
],
});
const leadOnlySnapshot = createTeamSnapshot({
config: { name: 'Lead Only Team' },
members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }],
});
store.setState({
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 0,
expectedMemberCount: 2,
partialLaunchFailure: true,
missingMembers: ['alice', 'bob'],
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData.mockResolvedValueOnce(leadOnlySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(cachedSnapshot);
expect(
selectResolvedMembersForTeamName(store.getState(), 'my-team').map((member) => member.name)
).toEqual(['alice', 'bob']);
});
it('does not preserve a cached roster when launch failure metadata only names part of the team', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [
{ name: 'alice', role: 'developer', currentTaskId: null },
{ name: 'bob', role: 'reviewer', currentTaskId: null },
],
});
const leadOnlySnapshot = createTeamSnapshot({
config: { name: 'Lead Only Team' },
members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }],
});
store.setState({
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 0,
expectedMemberCount: 2,
partialLaunchFailure: true,
missingMembers: ['bob'],
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData.mockResolvedValueOnce(leadOnlySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(leadOnlySnapshot);
expect(
selectResolvedMembersForTeamName(store.getState(), 'my-team').map((member) => member.name)
).toEqual(['team-lead']);
});
it('commits a late selectTeam snapshot that explicitly marks members as removed', async () => {
const store = createSliceStore();
const selectRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
@ -3077,6 +3161,61 @@ describe('teamSlice actions', () => {
});
});
it('does not synthesize member cards from launch failure names when summary roster is missing', () => {
const store = createSliceStore();
const leadOnlySnapshot = createTeamSnapshot({
config: {
name: 'My Team',
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
currentTaskId: null,
role: 'Lead from detail',
color: 'purple',
},
],
tasks: [
{
id: 'task-1',
subject: 'Build',
status: 'in_progress',
owner: 'Alice',
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: leadOnlySnapshot,
teamDataCacheByName: {
'my-team': leadOnlySnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 0,
expectedMemberCount: 2,
leadName: 'Lead',
partialLaunchFailure: true,
missingMembers: ['Lead', 'Alice', 'bob'],
taskCount: 1,
lastActivity: null,
},
},
memberActivityMetaByTeam: {},
});
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(members.map((m) => m.name)).toEqual(['team-lead']);
expect(selectResolvedMemberForTeamName(store.getState(), 'my-team', 'Alice')).toBeNull();
});
it('memoizes team-scoped member messages selectors over the merged message feed', () => {
const store = createSliceStore();
store.setState({

View file

@ -162,24 +162,24 @@ describe('teamModelAvailability', () => {
]);
expect(getAvailableTeamProviderModels('opencode', providerStatus)).toEqual([
'openai/gpt-5.4',
'opencode/big-pickle',
'openai/gpt-5.4',
'openrouter/moonshotai/kimi-k2',
]);
expect(getAvailableTeamProviderModelOptions('opencode', providerStatus)).toEqual([
{ value: '', label: 'Default', badgeLabel: 'Default' },
{
value: 'openai/gpt-5.4',
label: 'GPT-5.4',
badgeLabel: 'OpenAI',
value: 'opencode/big-pickle',
label: 'big-pickle',
badgeLabel: 'OpenCode',
availabilityStatus: 'available',
availabilityReason: null,
},
{
value: 'opencode/big-pickle',
label: 'big-pickle',
badgeLabel: 'OpenCode',
value: 'openai/gpt-5.4',
label: 'GPT-5.4',
badgeLabel: 'OpenAI',
availabilityStatus: 'available',
availabilityReason: null,
},

View file

@ -43,6 +43,102 @@ describe('teamModelCatalog', () => {
]);
});
it('orders OpenCode free models before paid models', () => {
expect(
getVisibleTeamProviderModels(
'opencode',
[
'openrouter/deepseek/deepseek-r1',
'openai/gpt-5.4',
'openrouter/openai/gpt-oss-20b:free',
'opencode/big-pickle',
],
{
providerId: 'opencode',
authMethod: 'opencode_managed',
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-12T00:00:00.000Z',
staleAt: '2026-05-12T00:10:00.000Z',
defaultModelId: 'opencode/big-pickle',
defaultLaunchModel: 'opencode/big-pickle',
models: [
{
id: 'opencode/big-pickle',
launchModel: 'opencode/big-pickle',
displayName: 'opencode/big-pickle',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'app-server',
badgeLabel: 'Free',
},
{
id: 'openrouter/openai/gpt-oss-20b:free',
launchModel: 'openrouter/openai/gpt-oss-20b:free',
displayName: 'openrouter/openai/gpt-oss-20b:free',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: false,
upgrade: false,
source: 'app-server',
badgeLabel: 'Free',
},
{
id: 'openai/gpt-5.4',
launchModel: 'openai/gpt-5.4',
displayName: 'openai/gpt-5.4',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: false,
upgrade: false,
source: 'app-server',
badgeLabel: null,
},
{
id: 'openrouter/deepseek/deepseek-r1',
launchModel: 'openrouter/deepseek/deepseek-r1',
displayName: 'openrouter/deepseek/deepseek-r1',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: false,
upgrade: false,
source: 'app-server',
badgeLabel: null,
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
}
)
).toEqual([
'opencode/big-pickle',
'openrouter/openai/gpt-oss-20b:free',
'openai/gpt-5.4',
'openrouter/deepseek/deepseek-r1',
]);
});
it('detects Sonnet aliases with or without 1M suffix', () => {
expect(isAnthropicSonnetTeamModel('sonnet')).toBe(true);
expect(isAnthropicSonnetTeamModel('sonnet[1m]')).toBe(true);