chore: checkpoint frontend workspace updates

This commit is contained in:
777genius 2026-05-18 01:54:05 +03:00
parent 90795a25e6
commit 4a8cec9dc2
84 changed files with 7300 additions and 669 deletions

View file

@ -42,14 +42,32 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/v}"
pnpm pkg set version="$VERSION"
- name: Verify Sentry release env
if: startsWith(github.ref, 'refs/tags/v')
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
- name: Build app
env:
NODE_OPTIONS: '--max-old-space-size=8192'
SENTRY_DSN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: pnpm build
- name: Verify Sentry source map upload
if: startsWith(github.ref, 'refs/tags/v')
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: quant-jump-pro
SENTRY_PROJECT: electron
run: pnpm build
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
@ -282,14 +300,32 @@ jobs:
node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}"
fi
- name: Verify Sentry release env (macOS ${{ matrix.arch }})
if: startsWith(github.ref, 'refs/tags/v')
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
- name: Build app (macOS ${{ matrix.arch }})
env:
NODE_OPTIONS: '--max-old-space-size=8192'
SENTRY_DSN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: pnpm build
- name: Verify Sentry source map upload (macOS ${{ matrix.arch }})
if: startsWith(github.ref, 'refs/tags/v')
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: quant-jump-pro
SENTRY_PROJECT: electron
run: pnpm build
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
- name: Verify packaged inputs (macOS ${{ matrix.arch }})
run: |
@ -381,14 +417,32 @@ jobs:
node ./scripts/stage-runtime.mjs --platform win32-x64
}
- name: Verify Sentry release env (Windows)
if: startsWith(github.ref, 'refs/tags/v')
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
- name: Build app (Windows)
env:
NODE_OPTIONS: '--max-old-space-size=8192'
SENTRY_DSN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: pnpm build
- name: Verify Sentry source map upload (Windows)
if: startsWith(github.ref, 'refs/tags/v')
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: quant-jump-pro
SENTRY_PROJECT: electron
run: pnpm build
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
- name: Verify packaged inputs (Windows)
shell: bash
@ -483,14 +537,32 @@ jobs:
node ./scripts/stage-runtime.mjs --platform linux-x64
fi
- name: Verify Sentry release env (Linux)
if: startsWith(github.ref, 'refs/tags/v')
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
- name: Build app (Linux)
env:
NODE_OPTIONS: '--max-old-space-size=8192'
SENTRY_DSN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: pnpm build
- name: Verify Sentry source map upload (Linux)
if: startsWith(github.ref, 'refs/tags/v')
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: quant-jump-pro
SENTRY_PROJECT: electron
run: pnpm build
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
- name: Verify packaged inputs (Linux)
run: |

1
.gitignore vendored
View file

@ -65,3 +65,4 @@ remotion/*
# Local reference captures
/agent-teams-reference-fix-*.png
/.tmp-*

View file

@ -44,30 +44,42 @@ function nativeModuleStub(): Plugin {
}
}
// Sentry source map upload — only active in CI when SENTRY_AUTH_TOKEN is set.
const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
? [
sentryVitePlugin({
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
project: process.env.SENTRY_PROJECT ?? 'electron',
authToken: process.env.SENTRY_AUTH_TOKEN,
release: { name: `agent-teams-ai@${pkg.version}` },
sourcemaps: {
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'],
},
}),
]
: []
const sentrySourceMapTargets = {
main: {
assets: ['./dist-electron/main/**/*.{js,cjs,mjs,map}'],
filesToDeleteAfterUpload: ['./dist-electron/main/**/*.map'],
},
renderer: {
assets: ['./out/renderer/**/*.{js,cjs,mjs,map}'],
filesToDeleteAfterUpload: ['./out/renderer/**/*.map'],
},
} as const
// Sentry source map upload - only active in CI when SENTRY_AUTH_TOKEN is set.
function createSentryPlugins(target: keyof typeof sentrySourceMapTargets): Plugin[] {
if (!process.env.SENTRY_AUTH_TOKEN) return []
return [
sentryVitePlugin({
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
project: process.env.SENTRY_PROJECT ?? 'electron',
authToken: process.env.SENTRY_AUTH_TOKEN,
telemetry: false,
release: { name: `agent-teams-ai@${pkg.version}` },
sourcemaps: sentrySourceMapTargets[target],
}) as Plugin,
]
}
export default defineConfig({
main: {
plugins: [
nativeModuleStub(),
...sentryPlugins,
...createSentryPlugins('main'),
],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
// Inject DSN at compile time — process.env.SENTRY_DSN is NOT available
// Inject DSN at compile time - process.env.SENTRY_DSN is NOT available
// at runtime in packaged Electron apps (only during CI build).
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''),
},
@ -148,10 +160,14 @@ export default defineConfig({
'@renderer': resolve(__dirname, 'src/renderer'),
'@shared': resolve(__dirname, 'src/shared'),
'@main': resolve(__dirname, 'src/main'),
'@radix-ui/react-compose-refs': resolve(
__dirname,
'src/renderer/vendor/radixComposeRefs.ts'
),
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts')
}
},
plugins: [react(), ...sentryPlugins],
plugins: [react(), ...createSentryPlugins('renderer')],
build: {
sourcemap: 'hidden',
rollupOptions: {

View file

@ -1259,17 +1259,17 @@
.cyber-feature-rail__reviewer-bubble {
--reviewer-bubble-center-shift: 3px;
--robot-bubble-position: absolute;
--robot-bubble-min-width: 112px;
--robot-bubble-max-width: 184px;
--robot-bubble-min-height: 46px;
--robot-bubble-font-size: 0.64rem;
--robot-bubble-padding: 8px 14px 16px;
left: auto;
top: auto;
right: calc(var(--reviewer-robot-width) / 2);
bottom: calc(100% + 10px);
z-index: 6;
width: max-content;
max-width: 158px;
white-space: normal;
overflow-wrap: anywhere;
text-wrap: balance;
transform: translateX(calc(50% + var(--reviewer-bubble-center-shift))) translate3d(0, 0, 0) rotate(-4deg);
transform-origin: center bottom;
animation: cyberFeatureReviewerSpeechFloat 2.8s ease-in-out 0.45s infinite;

View file

@ -0,0 +1,99 @@
<script setup lang="ts">
type RobotSpeechBubbleTail = "down" | "right";
const props = withDefaults(defineProps<{
tail?: RobotSpeechBubbleTail;
}>(), {
tail: "down",
});
const bubblePath = computed(() => {
if (props.tail === "right") {
return "M18 6H79C94 6 104 16 104 30C104 32 104 34 103 35L118 35L99 44C94 50 87 53 79 53H18C9 53 4 44 4 30C4 16 9 6 18 6Z";
}
return "M18 6H76C94 6 108 16 108 30C108 44 94 52 78 52H65L76 66L48 52H18C9 52 4 43 4 29C4 15 9 6 18 6Z";
});
</script>
<template>
<span
class="robot-speech-bubble"
:class="`robot-speech-bubble--tail-${tail}`"
>
<svg
class="robot-speech-bubble__shape"
viewBox="0 0 120 70"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
<path
class="robot-speech-bubble__fill"
:d="bubblePath"
/>
</svg>
<span class="robot-speech-bubble__text">
<slot />
</span>
</span>
</template>
<style scoped>
.robot-speech-bubble {
position: var(--robot-bubble-position, relative);
z-index: var(--robot-bubble-z-index, auto);
display: inline-grid;
min-width: var(--robot-bubble-min-width, 86px);
max-width: var(--robot-bubble-max-width, 184px);
min-height: var(--robot-bubble-min-height, 42px);
box-sizing: border-box;
color: var(--robot-bubble-color, #07111d);
font-family: var(--at-font-mono);
font-size: var(--robot-bubble-font-size, 0.66rem);
font-weight: 900;
line-height: 1.05;
letter-spacing: 0;
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
pointer-events: none;
filter:
drop-shadow(0 3px 0 rgba(0, 0, 0, 0.18))
drop-shadow(0 0 11px rgba(255, 215, 0, 0.16));
}
.robot-speech-bubble__shape {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.robot-speech-bubble__fill {
fill: var(--robot-bubble-fill, #fff09a);
stroke: var(--robot-bubble-stroke, #050816);
stroke-width: var(--robot-bubble-stroke-width, 4.8);
stroke-linejoin: round;
stroke-linecap: round;
}
.robot-speech-bubble__text {
position: relative;
z-index: 1;
display: block;
align-self: center;
justify-self: stretch;
box-sizing: border-box;
min-width: 0;
padding: var(--robot-bubble-padding, 8px 16px 16px);
text-align: center;
white-space: normal;
overflow-wrap: anywhere;
hyphens: auto;
text-wrap: balance;
}
.robot-speech-bubble--tail-right .robot-speech-bubble__text {
padding: var(--robot-bubble-padding, 8px 24px 8px 13px);
}
</style>

View file

@ -67,13 +67,13 @@ const reviewerBubbleText = computed(() => {
aria-hidden="true"
>
<Transition name="cyber-feature-bubble">
<CyberHeroSpeechBubble
<RobotSpeechBubble
v-if="reviewerBubbleText"
class="cyber-feature-rail__reviewer-bubble"
role="reviewer"
tail="down"
>
{{ reviewerBubbleText }}
</CyberHeroSpeechBubble>
</RobotSpeechBubble>
</Transition>
<div class="cyber-feature-rail__reviewer-card cyber-panel">
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>

View file

@ -14,20 +14,9 @@ const docsHref = computed(() => {
<template>
<footer class="app-footer">
<div class="app-footer__robot-stage">
<span class="app-footer__robot-bubble">
<svg
class="app-footer__robot-bubble-shape"
viewBox="0 0 92 62"
aria-hidden="true"
focusable="false"
>
<path
class="app-footer__robot-bubble-fill"
d="M18 5H58C73 5 84 14 84 27C84 40 73 47 59 47H52L61 58L39 47H18C9 47 4 38 4 26C4 14 9 5 18 5Z"
/>
</svg>
<span class="app-footer__robot-bubble-text">{{ t('footer.robotBubble') }}</span>
</span>
<RobotSpeechBubble class="app-footer__robot-bubble" tail="down">
{{ t('footer.robotBubble') }}
</RobotSpeechBubble>
<img
class="app-footer__robot"
:src="robotLeadLounge"
@ -82,51 +71,17 @@ const docsHref = computed(() => {
}
.app-footer__robot-bubble {
position: absolute;
--robot-bubble-position: absolute;
--robot-bubble-min-width: 82px;
--robot-bubble-max-width: 116px;
--robot-bubble-min-height: 50px;
--robot-bubble-font-size: 0.62rem;
--robot-bubble-padding: 9px 13px 16px;
top: -28px;
left: -18px;
z-index: 3;
display: block;
width: 72px;
height: 49px;
color: #07111d;
font-family: var(--at-font-mono);
font-size: 0.62rem;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
transform: rotate(-2deg);
transform-origin: 72% 74%;
filter:
drop-shadow(0 3px 0 rgba(0, 0, 0, 0.18))
drop-shadow(0 0 9px rgba(255, 215, 0, 0.14));
}
.app-footer__robot-bubble-shape {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.app-footer__robot-bubble-fill {
fill: #fff09a;
stroke: #050816;
stroke-width: 4.6;
stroke-linejoin: round;
stroke-linecap: round;
}
.app-footer__robot-bubble-text {
position: absolute;
top: 11px;
left: 0;
z-index: 3;
width: 54px;
text-align: center;
}
.app-footer__inner {

View file

@ -317,12 +317,13 @@ function getStatusIcon(status: string): string {
aria-hidden="true"
>
<Transition name="comparison-robot-bubble">
<span
<RobotSpeechBubble
v-if="showComparisonRobotBubble"
class="comparison-table__robot-bubble"
tail="right"
>
{{ t("comparison.robotBubble") }}
</span>
</RobotSpeechBubble>
</Transition>
<img
class="comparison-table__robot-image"
@ -487,59 +488,20 @@ function getStatusIcon(status: string): string {
}
.comparison-table__robot-bubble {
position: absolute;
--robot-bubble-position: absolute;
--robot-bubble-min-width: 96px;
--robot-bubble-max-width: 190px;
--robot-bubble-min-height: 42px;
--robot-bubble-font-size: 0.66rem;
--robot-bubble-padding: 8px 26px 8px 13px;
top: 10px;
right: calc(100% + 12px);
z-index: 5;
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 6px 10px;
color: #07111d;
font-family: var(--at-font-mono);
font-size: 0.66rem;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
background:
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.88), rgba(255, 244, 168, 0.86) 66%, rgba(255, 215, 0, 0.84) 100%);
border: 2px solid #050816;
border-radius: 999px;
box-shadow:
0 0 0 1px rgba(255, 215, 0, 0.28),
0 5px 0 rgba(0, 0, 0, 0.2),
0 0 12px rgba(255, 215, 0, 0.14);
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
transform: rotate(-5deg);
transform-origin: right bottom;
animation: comparisonRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
}
.comparison-table__robot-bubble::before {
position: absolute;
top: 52%;
right: -30px;
width: 32px;
height: 18px;
content: "";
background: #050816;
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
.comparison-table__robot-bubble::after {
position: absolute;
top: 52%;
right: -24px;
width: 26px;
height: 12px;
content: "";
background: rgba(255, 226, 78, 0.96);
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
.comparison-robot-bubble-enter-active,
.comparison-robot-bubble-leave-active {
transition:

View file

@ -314,12 +314,13 @@ const releaseDate = computed(() => {
aria-hidden="true"
>
<Transition name="download-robot-bubble">
<span
<RobotSpeechBubble
v-if="showLinuxRobotMessage"
class="download-section__card-robot-bubble"
tail="right"
>
Готов начать!
</span>
</RobotSpeechBubble>
</Transition>
<img
class="download-section__card-robot"
@ -617,60 +618,20 @@ const releaseDate = computed(() => {
}
.download-section__card-robot-bubble {
position: absolute;
--robot-bubble-position: absolute;
--robot-bubble-min-width: 98px;
--robot-bubble-max-width: 170px;
--robot-bubble-min-height: 42px;
--robot-bubble-font-size: 0.66rem;
--robot-bubble-padding: 8px 26px 8px 13px;
top: 12px;
right: calc(100% - 18px);
z-index: 5;
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 6px 10px;
color: #0b1020;
font-family: var(--at-font-mono);
font-size: 0.66rem;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
background:
radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.84), rgba(255, 244, 168, 0.84) 66%, rgba(255, 215, 0, 0.82) 100%);
border: 2px solid #050816;
border-radius: 999px;
box-shadow:
0 0 0 1px rgba(255, 215, 0, 0.28),
0 5px 0 rgba(0, 0, 0, 0.2),
0 0 12px rgba(255, 215, 0, 0.14);
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
pointer-events: none;
transform: rotate(-5deg);
transform-origin: right bottom;
animation: downloadRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
}
.download-section__card-robot-bubble::after {
position: absolute;
top: 52%;
right: -28px;
width: 30px;
height: 12px;
content: "";
background: rgba(255, 226, 78, 0.96);
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
.download-section__card-robot-bubble::before {
position: absolute;
top: 52%;
right: -34px;
width: 36px;
height: 18px;
content: "";
background: #050816;
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
.download-robot-bubble-enter-active,
.download-robot-bubble-leave-active {
transition:
@ -970,9 +931,9 @@ const releaseDate = computed(() => {
.download-section__card-robot-bubble {
top: 8px;
right: calc(100% - 14px);
min-height: 28px;
padding: 6px 9px;
font-size: 0.6rem;
--robot-bubble-min-width: 88px;
--robot-bubble-font-size: 0.6rem;
--robot-bubble-padding: 7px 23px 7px 11px;
}
.download-section__card-robot {

View file

@ -0,0 +1,131 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const repoRoot = path.resolve(__dirname, '..', '..');
const pkg = require(path.join(repoRoot, 'package.json'));
const REQUIRED_ENV = ['SENTRY_DSN', 'SENTRY_AUTH_TOKEN', 'SENTRY_ORG', 'SENTRY_PROJECT'];
const OUTPUT_DIRS = ['dist-electron/main', 'out/renderer'];
const SENTRY_DEBUG_ID_RE = /\/\/# debugId=[a-fA-F0-9-]+/;
function fail(message) {
console.error(`[sentry-release] ${message}`);
process.exit(1);
}
function isTaggedRelease() {
return /^refs\/tags\/v/.test(process.env.GITHUB_REF ?? '');
}
function assertTaggedReleaseEnv() {
if (!isTaggedRelease()) {
console.log('[sentry-release] skipped: not a tag release');
return false;
}
const missing = REQUIRED_ENV.filter((name) => !String(process.env[name] ?? '').trim());
if (missing.length > 0) {
fail(`missing required env for source map upload: ${missing.join(', ')}`);
}
if (!String(process.env.SENTRY_DSN).startsWith('https://')) {
fail('SENTRY_DSN must be an https DSN');
}
const tagVersion = String(process.env.GITHUB_REF).replace(/^refs\/tags\/v/, '');
if (pkg.version !== tagVersion) {
fail(`package version ${pkg.version} does not match release tag v${tagVersion}`);
}
return true;
}
function walkFiles(relativeDir) {
const absoluteDir = path.join(repoRoot, relativeDir);
if (!fs.existsSync(absoluteDir)) {
return [];
}
const files = [];
const stack = [absoluteDir];
while (stack.length > 0) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const entryPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
} else if (entry.isFile()) {
files.push(entryPath);
}
}
}
return files;
}
function prebuild() {
if (!assertTaggedReleaseEnv()) return;
console.log(
`[sentry-release] prebuild ok: release=agent-teams-ai@${pkg.version}, project=${process.env.SENTRY_ORG}/${process.env.SENTRY_PROJECT}`
);
}
function postbuild() {
if (!assertTaggedReleaseEnv()) return;
const jsFilesByOutputDir = new Map();
for (const outputDir of OUTPUT_DIRS) {
const jsFiles = walkFiles(outputDir).filter((file) => /\.(?:js|cjs|mjs)$/.test(file));
if (jsFiles.length === 0) {
fail(`no built JavaScript files found in ${outputDir}`);
}
jsFilesByOutputDir.set(outputDir, jsFiles);
}
const jsFiles = [...jsFilesByOutputDir.values()].flat();
if (jsFiles.length === 0) {
fail(`no built JavaScript files found in ${OUTPUT_DIRS.join(', ')}`);
}
const missingDebugIdDirs = [];
for (const [outputDir, files] of jsFilesByOutputDir.entries()) {
const hasDebugId = files.some((file) => SENTRY_DEBUG_ID_RE.test(fs.readFileSync(file, 'utf8')));
if (!hasDebugId) {
missingDebugIdDirs.push(outputDir);
}
}
if (missingDebugIdDirs.length > 0) {
fail(
[
'Sentry debug IDs were not injected into built JavaScript artifacts',
...missingDebugIdDirs.map((dir) => ` - ${dir}`),
].join('\n')
);
}
const mapFiles = OUTPUT_DIRS.flatMap(walkFiles).filter((file) => file.endsWith('.map'));
if (mapFiles.length > 0) {
fail(
[
'source maps still exist after build; expected Sentry upload to delete them',
...mapFiles.slice(0, 20).map((file) => ` - ${path.relative(repoRoot, file)}`),
].join('\n')
);
}
console.log(
`[sentry-release] postbuild ok: ${jsFiles.length} JS artifacts built, debug IDs were injected, and source maps were removed after upload`
);
}
const command = process.argv[2] ?? 'prebuild';
if (command === 'prebuild') {
prebuild();
} else if (command === 'postbuild') {
postbuild();
} else {
fail(`unknown command: ${command}`);
}

View file

@ -34,7 +34,7 @@ export async function listTmuxPaneRuntimeInfoForCurrentPlatform(
return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
}
export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
export async function listRuntimeProcessTableForCurrentPlatform(): Promise<
RuntimeProcessTableRow[]
> {
return runtimeCommandExecutor.listRuntimeProcesses();

View file

@ -9,7 +9,7 @@ export {
isTmuxRuntimeReadyForCurrentPlatform,
killTmuxPaneForCurrentPlatform,
killTmuxPaneForCurrentPlatformSync,
listRuntimeProcessesForCurrentTmuxPlatform,
listRuntimeProcessTableForCurrentPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
sendKeysToTmuxPaneForCurrentPlatform,

View file

@ -36,6 +36,21 @@ let statusInFlight: Promise<CliInstallationStatus> | null = null;
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
const STATUS_CACHE_TTL_MS = 5_000;
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId);
}
function getCachedStatusAuthenticatedProvider(
providers: CliProviderStatus[]
): CliProviderStatus | null {
return (
providers.find(
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
) ?? null
);
}
/**
* Initializes CLI installer handlers with the service instance.
@ -122,6 +137,13 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo
return;
}
if (
cachedStatus.value.flavor === 'agent_teams_orchestrator' &&
!isFrontendMultimodelProviderId(providerStatus.providerId)
) {
return;
}
const hasProvider = cachedStatus.value.providers.some(
(provider) => provider.providerId === providerStatus.providerId
);
@ -130,13 +152,19 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo
provider.providerId === providerStatus.providerId ? providerStatus : provider
)
: [...cachedStatus.value.providers, providerStatus];
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
const authenticatedProvider =
cachedStatus.value.flavor === 'agent_teams_orchestrator'
? getCachedStatusAuthenticatedProvider(nextProviders)
: (nextProviders.find((provider) => provider.authenticated) ?? null);
cachedStatus = {
value: {
...cachedStatus.value,
providers: nextProviders,
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
authLoggedIn:
cachedStatus.value.flavor === 'agent_teams_orchestrator'
? authenticatedProvider !== null
: nextProviders.some((provider) => provider.authenticated),
authMethod: authenticatedProvider?.authMethod ?? null,
},
at: Date.now(),

View file

@ -84,6 +84,7 @@ import {
registerTerminalHandlers,
removeTerminalHandlers,
} from './terminal';
import { registerTelemetryHandlers, removeTelemetryHandlers } from './telemetry';
import { registerTmuxHandlers, removeTmuxHandlers } from './tmux';
import {
initializeUpdaterHandlers,
@ -268,6 +269,7 @@ export function initializeIpcHandlers(
registerWindowHandlers(ipcMain);
registerRendererLogHandlers(ipcMain);
registerScheduleHandlers(ipcMain);
registerTelemetryHandlers(ipcMain);
if (cliInstaller) {
registerCliInstallerHandlers(ipcMain);
}
@ -315,6 +317,7 @@ export function removeIpcHandlers(): void {
removeWindowHandlers(ipcMain);
removeRendererLogHandlers(ipcMain);
removeScheduleHandlers(ipcMain);
removeTelemetryHandlers(ipcMain);
removeCliInstallerHandlers(ipcMain);
removeOpenCodeRuntimeHandlers(ipcMain);
removeCodexRuntimeHandlers(ipcMain);

24
src/main/ipc/telemetry.ts Normal file
View file

@ -0,0 +1,24 @@
/**
* Telemetry IPC handlers.
*
* Only exposes Sentry-safe anonymous context. Raw app identity stays in main.
*/
import { getCurrentSentryTelemetryContext } from '@main/sentry';
import {
TELEMETRY_GET_SENTRY_CONTEXT,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
} from '@preload/constants/ipcChannels';
import type { SentryTelemetryContext } from '@main/sentry';
import type { IpcMain } from 'electron';
export function registerTelemetryHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TELEMETRY_GET_SENTRY_CONTEXT, async (): Promise<SentryTelemetryContext | null> => {
return getCurrentSentryTelemetryContext();
});
}
export function removeTelemetryHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TELEMETRY_GET_SENTRY_CONTEXT);
}

View file

@ -15,22 +15,69 @@ import {
ensureAgentTeamsClientIdentity,
getSentryAnonymousUserId,
} from '@main/services/identity/AgentTeamsIdentityStore';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import {
filterSafeSentryIntegrations,
isValidDsn,
redactSentryEvent,
SENTRY_ENVIRONMENT,
SENTRY_RELEASE,
TRACES_SAMPLE_RATE,
} from '@shared/utils/sentryConfig';
import * as fs from 'fs';
import * as path from 'path';
// ---------------------------------------------------------------------------
// Telemetry gate
// ---------------------------------------------------------------------------
// Module-level flag that `beforeSend` checks.
// Updated by `syncTelemetryFlag()` once ConfigManager is ready.
// Defaults to `true` so early crash reports are NOT silently dropped;
// if the user later turns telemetry off, the flag flips to `false`.
let telemetryAllowed = true;
const CONFIG_FILENAME = 'agent-teams-config.json';
const LEGACY_CONFIG_FILENAMES = [
'claude-devtools-config.json',
'claude-code-context-config.json',
] as const;
export interface SentryTelemetryContext {
userId: string;
tags: Record<string, string>;
}
function readTelemetryFlagFromConfig(configPath: string): boolean | null {
try {
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as unknown;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null;
}
const general = (parsed as { general?: unknown }).general;
if (typeof general !== 'object' || general === null || Array.isArray(general)) {
return null;
}
const telemetryEnabled = (general as { telemetryEnabled?: unknown }).telemetryEnabled;
return typeof telemetryEnabled === 'boolean' ? telemetryEnabled : null;
} catch {
return null;
}
}
export function readPersistedTelemetryEnabled(basePath = getClaudeBasePath()): boolean {
const currentPath = path.join(basePath, CONFIG_FILENAME);
if (fs.existsSync(currentPath)) {
return readTelemetryFlagFromConfig(currentPath) ?? true;
}
const legacyPaths = LEGACY_CONFIG_FILENAMES.map((filename) => path.join(basePath, filename));
const readableLegacyPath =
legacyPaths.find((candidatePath) => readTelemetryFlagFromConfig(candidatePath) !== null) ??
legacyPaths.find((candidatePath) => fs.existsSync(candidatePath));
return readableLegacyPath ? (readTelemetryFlagFromConfig(readableLegacyPath) ?? true) : true;
}
// Module-level flag that `beforeSend` checks. Read persisted config before init
// so telemetry-disabled users do not start Sentry sessions on app startup.
let telemetryAllowed = readPersistedTelemetryEnabled();
let telemetryIdentitySyncToken = 0;
export function getSafeSentryTelemetryTags(
@ -50,21 +97,29 @@ export function getSafeSentryTelemetryTags(
*/
export function syncTelemetryFlag(enabled: boolean): void {
telemetryAllowed = enabled;
if (!enabled) {
telemetryIdentitySyncToken++;
shutdownSentry();
return;
}
initializeSentryIfAllowed();
void syncTelemetryIdentity();
}
export function filterSentryEventForTelemetry(event: unknown): unknown {
return telemetryAllowed ? event : null;
return telemetryAllowed ? redactSentryEvent(event) : null;
}
// ---------------------------------------------------------------------------
// Lazy Sentry import safe in non-Electron environments
// Lazy Sentry import - safe in non-Electron environments
// ---------------------------------------------------------------------------
interface SentryMainApi {
init?: (options: SentryInitOptions) => void;
setUser?: (user: { id: string } | null) => void;
setTags?: (tags: Record<string, string>) => void;
close?: (timeout?: number) => PromiseLike<boolean> | boolean;
addBreadcrumb?: (breadcrumb: {
category: string;
message: string;
@ -82,6 +137,9 @@ interface SentryInitOptions {
sendDefaultPii: false;
beforeSend: (event: unknown) => unknown;
beforeSendTransaction: (event: unknown) => unknown;
integrations: <TIntegration extends { name?: string }>(
integrations: TIntegration[]
) => TIntegration[];
}
let Sentry: SentryMainApi | null = null;
@ -98,6 +156,41 @@ function clearSentryUser(): void {
Sentry.setUser?.(null);
}
function shutdownSentry(): void {
const sentry = Sentry;
if (initialized && sentry) {
sentry.setUser?.(null);
try {
void Promise.resolve(sentry.close?.(2000)).catch(() => undefined);
} catch {
// Best effort only. The telemetry gate still blocks later events.
}
}
initialized = false;
Sentry = null;
}
export async function getCurrentSentryTelemetryContext(): Promise<SentryTelemetryContext | null> {
if (!telemetryAllowed) {
return null;
}
try {
const identity = await ensureAgentTeamsClientIdentity();
if (!telemetryAllowed) {
return null;
}
return {
userId: getSentryAnonymousUserId(identity.clientId),
tags: getSafeSentryTelemetryTags(identity.source),
};
} catch {
return null;
}
}
async function syncTelemetryIdentity(): Promise<void> {
const syncToken = ++telemetryIdentitySyncToken;
if (!initialized || !Sentry) {
@ -110,13 +203,18 @@ async function syncTelemetryIdentity(): Promise<void> {
}
try {
const identity = await ensureAgentTeamsClientIdentity();
const context = await getCurrentSentryTelemetryContext();
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
return;
}
Sentry.setUser?.({ id: getSentryAnonymousUserId(identity.clientId) });
Sentry.setTags?.(getSafeSentryTelemetryTags(identity.source));
if (!context) {
clearSentryUser();
return;
}
Sentry.setUser?.({ id: context.userId });
Sentry.setTags?.(context.tags);
} catch {
if (syncToken === telemetryIdentitySyncToken) {
clearSentryUser();
@ -124,13 +222,20 @@ async function syncTelemetryIdentity(): Promise<void> {
}
}
const dsn = process.env.SENTRY_DSN;
function initializeSentryIfAllowed(): void {
if (initialized || !telemetryAllowed) {
return;
}
const dsn = process.env.SENTRY_DSN;
if (!isValidDsn(dsn)) {
return;
}
if (isValidDsn(dsn)) {
try {
// Dynamic import would be cleaner but top-level await is not available
// in all contexts. require() is synchronous and works in both Electron
// and Node.js — it simply throws in standalone mode where the electron
// and Node.js - it simply throws in standalone mode where the electron
// module is not resolvable.
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
Sentry = require('@sentry/electron/main') as SentryMainApi;
@ -143,15 +248,21 @@ if (isValidDsn(dsn)) {
beforeSend: filterSentryEventForTelemetry,
beforeSendTransaction: filterSentryEventForTelemetry,
integrations: filterSafeSentryIntegrations,
});
initialized = true;
void syncTelemetryIdentity();
} catch {
// @sentry/electron/main requires Electron runtime — not available in
Sentry = null;
initialized = false;
// @sentry/electron/main requires Electron runtime - not available in
// standalone (pure Node.js) mode. All exported helpers are no-ops when
// initialized is false, so this is safe to swallow.
}
}
initializeSentryIfAllowed();
// ---------------------------------------------------------------------------
// Public helpers (no-op when Sentry is not configured)
// ---------------------------------------------------------------------------
@ -160,10 +271,10 @@ if (isValidDsn(dsn)) {
export function addMainBreadcrumb(
category: string,
message: string,
data?: Record<string, unknown>
_data?: Record<string, unknown>
): void {
if (!initialized) return;
Sentry?.addBreadcrumb?.({ category, message, data, level: 'info' });
Sentry?.addBreadcrumb?.({ category, message, level: 'info' });
}
/**

View file

@ -26,7 +26,7 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import {
getCachedShellEnv,
getShellPreferredHome,
resolveInteractiveShellEnv,
resolveInteractiveShellEnvBestEffort,
} from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
@ -68,6 +68,47 @@ const GCS_BASE =
'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases';
const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress';
const FRONTEND_MULTIMODEL_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'opencode'];
const FRONTEND_MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(
FRONTEND_MULTIMODEL_PROVIDER_IDS
);
function getProviderDisplayName(providerId: CliProviderId): string {
switch (providerId) {
case 'anthropic':
return 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'opencode':
return 'OpenCode (200+ models)';
}
}
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
return FRONTEND_MULTIMODEL_PROVIDER_ID_SET.has(providerId);
}
function getFrontendAuthenticatedProvider(
providers: CliProviderStatus[]
): CliProviderStatus | null {
return (
providers.find(
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
) ?? null
);
}
function hasFrontendAuthenticatedProvider(providers: CliProviderStatus[]): boolean {
return providers.some(
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
);
}
function filterFrontendMultimodelProviders(providers: CliProviderStatus[]): CliProviderStatus[] {
return providers.filter((provider) => isFrontendMultimodelProviderId(provider.providerId));
}
/** Timeout for `claude --version` (ms) */
const VERSION_TIMEOUT_MS = 10_000;
@ -152,6 +193,7 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
providers: status.providers.map((provider) => ({
...provider,
modelVerificationState: provider.modelVerificationState ?? 'idle',
modelCatalogRefreshState: provider.modelCatalogRefreshState ?? 'idle',
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
detailMessage: provider.detailMessage ?? null,
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
@ -176,6 +218,26 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
};
}
function mergeProviderStatusCatalogCache(
incomingProvider: CliProviderStatus,
currentProvider: CliProviderStatus
): CliProviderStatus {
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
return {
...incomingProvider,
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
modelCatalog,
modelCatalogRefreshState:
modelCatalog && incomingRefreshState !== 'error'
? 'ready'
: (incomingRefreshState ?? currentProvider.modelCatalogRefreshState ?? 'idle'),
runtimeCapabilities:
incomingProvider.runtimeCapabilities ?? currentProvider.runtimeCapabilities ?? null,
};
}
function cloneProviderModelAvailability(
modelAvailability: CliProviderModelAvailability[] | undefined
): CliProviderModelAvailability[] {
@ -485,27 +547,9 @@ export class CliInstallerService {
const ui = getCliFlavorUiOptions(flavor);
const providers =
flavor === 'agent_teams_orchestrator'
? (
[
{
providerId: 'anthropic',
displayName: 'Anthropic',
},
{
providerId: 'codex',
displayName: 'Codex',
},
{
providerId: 'gemini',
displayName: 'Gemini',
},
{
providerId: 'opencode',
displayName: 'OpenCode (200+ models)',
},
] as const
).map((provider) => ({
...provider,
? FRONTEND_MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
providerId,
displayName: getProviderDisplayName(providerId),
supported: false,
authenticated: false,
authMethod: null,
@ -514,7 +558,7 @@ export class CliInstallerService {
statusMessage: 'Checking...',
models: [],
modelAvailability: [],
canLoginFromUi: provider.providerId !== 'opencode',
canLoginFromUi: providerId !== 'opencode',
capabilities: {
teamLaunch: false,
oneShot: false,
@ -652,6 +696,13 @@ export class CliInstallerService {
}
private updateLatestProviderStatus(providerStatus: CliProviderStatus): void {
if (
this.latestStatusSnapshot?.flavor === 'agent_teams_orchestrator' &&
!isFrontendMultimodelProviderId(providerStatus.providerId)
) {
return;
}
if (
providerStatus.modelVerificationState !== 'verifying' &&
(providerStatus.modelAvailability?.length ?? 0) <= 0
@ -668,15 +719,17 @@ export class CliInstallerService {
);
const nextProviders = hasProvider
? this.latestStatusSnapshot.providers.map((provider) =>
provider.providerId === providerStatus.providerId ? providerStatus : provider
provider.providerId === providerStatus.providerId
? mergeProviderStatusCatalogCache(providerStatus, provider)
: provider
)
: [...this.latestStatusSnapshot.providers, providerStatus];
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
const authenticatedProvider = getFrontendAuthenticatedProvider(nextProviders);
this.latestStatusSnapshot = {
...this.latestStatusSnapshot,
providers: nextProviders,
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
authLoggedIn: hasFrontendAuthenticatedProvider(nextProviders),
authMethod: authenticatedProvider?.authMethod ?? null,
};
}
@ -724,7 +777,7 @@ export class CliInstallerService {
}
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
await resolveInteractiveShellEnv();
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
@ -744,14 +797,20 @@ export class CliInstallerService {
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
binaryPath,
providerId
providerId,
(hydratedProviderStatus) => {
this.updateLatestProviderStatus(hydratedProviderStatus);
if (this.latestStatusSnapshot) {
this.publishStatusSnapshot(this.latestStatusSnapshot);
}
}
);
this.updateLatestProviderStatus(providerStatus);
return providerStatus;
}
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
await resolveInteractiveShellEnv();
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
@ -813,7 +872,7 @@ export class CliInstallerService {
diag: CliInstallerStatusRunDiag
): Promise<void> {
resetGatherDiag(diag);
await resolveInteractiveShellEnv();
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
const r = ref.current;
const binaryPath = await ClaudeBinaryResolver.resolve();
@ -952,6 +1011,7 @@ export class CliInstallerService {
authMethod: null,
verificationState: 'error',
modelVerificationState: 'idle',
modelCatalogRefreshState: 'error',
statusMessage: message,
models: [],
modelAvailability: [],
@ -979,17 +1039,18 @@ export class CliInstallerService {
const providers = await this.multimodelBridgeService.getProviderStatuses(
binaryPath,
(providersSnapshot) => {
result.providers = providersSnapshot;
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
result.providers = frontendProviders;
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
result.authMethod =
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
this.publishStatusSnapshot(result);
}
);
result.providers = providers;
result.authLoggedIn = providers.some((provider) => provider.authenticated);
result.authMethod =
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
const frontendProviders = filterFrontendMultimodelProviders(providers);
result.providers = frontendProviders;
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
result.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
result.authStatusChecking = false;
this.publishStatusSnapshot(result);
} catch (error) {

View file

@ -1,5 +1,5 @@
import { execCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import {
createDefaultCliExtensionCapabilities,
@ -316,6 +316,7 @@ export interface OpenCodeRuntimeTranscriptLogMessage {
}
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'];
const DEFAULT_PROVIDER_STATUS_IDS: CliProviderId[] = ['anthropic', 'codex', 'opencode'];
function getProviderDisplayName(providerId: CliProviderId): string {
switch (providerId) {
@ -353,6 +354,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
modelCatalogRefreshState: 'idle',
statusMessage: null,
detailMessage: null,
models: [],
@ -582,6 +584,17 @@ function mapRuntimeProviderModelCatalog(
};
}
function getRuntimeModelCatalogRefreshState(
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined,
modelCatalog: CliProviderStatus['modelCatalog']
): NonNullable<CliProviderStatus['modelCatalogRefreshState']> {
if (modelCatalog) {
return 'ready';
}
return runtimeStatus?.runtimeCapabilities?.modelCatalog?.dynamic === true ? 'loading' : 'idle';
}
function mapRuntimeSubscriptionRateLimitWindow(
window: RuntimeSubscriptionRateLimitWindowResponse | null | undefined
): NonNullable<CliProviderSubscriptionRateLimitSnapshot['primary']> | null {
@ -620,7 +633,64 @@ function mapRuntimeSubscriptionRateLimits(
return primary || secondary ? { primary, secondary } : null;
}
function mergeRuntimeCapabilitiesForCatalogHydration(
live: CliProviderStatus['runtimeCapabilities'],
hydrated: CliProviderStatus['runtimeCapabilities']
): CliProviderStatus['runtimeCapabilities'] {
if (!hydrated) {
return live ?? null;
}
if (!live) {
return hydrated;
}
return {
...live,
modelCatalog: hydrated.modelCatalog ?? live.modelCatalog,
reasoningEffort: hydrated.reasoningEffort ?? live.reasoningEffort,
fastMode: hydrated.fastMode ?? live.fastMode,
};
}
function mergeProviderCatalogFields(
liveProvider: CliProviderStatus,
hydratedProvider: CliProviderStatus
): CliProviderStatus {
const modelCatalog = hydratedProvider.modelCatalog ?? liveProvider.modelCatalog ?? null;
return {
...liveProvider,
models: hydratedProvider.models.length > 0 ? hydratedProvider.models : liveProvider.models,
modelCatalog,
modelCatalogRefreshState: modelCatalog
? 'ready'
: hydratedProvider.modelCatalogRefreshState === 'error'
? 'error'
: liveProvider.modelCatalogRefreshState,
runtimeCapabilities: mergeRuntimeCapabilitiesForCatalogHydration(
liveProvider.runtimeCapabilities,
hydratedProvider.runtimeCapabilities
),
subscriptionRateLimits:
hydratedProvider.subscriptionRateLimits ?? liveProvider.subscriptionRateLimits ?? null,
};
}
export class ClaudeMultimodelBridgeService {
private providerStatusHydrationGeneration = 0;
private readonly providerStatusHydrationGenerations = new Map<CliProviderId, number>();
private beginProviderStatusHydration(providerIds: readonly CliProviderId[]): number {
const generation = ++this.providerStatusHydrationGeneration;
for (const providerId of providerIds) {
this.providerStatusHydrationGenerations.set(providerId, generation);
}
return generation;
}
private isProviderStatusHydrationCurrent(providerId: CliProviderId, generation: number): boolean {
return this.providerStatusHydrationGenerations.get(providerId) === generation;
}
private async buildCliEnv(
binaryPath: string
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
@ -658,6 +728,7 @@ export class ClaudeMultimodelBridgeService {
if (!runtimeStatus) {
return provider;
}
const modelCatalog = mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog);
return {
...provider,
@ -700,7 +771,8 @@ export class ClaudeMultimodelBridgeService {
detailMessage: diagnostic.detailMessage ?? null,
})) ?? [],
models: extractModelIds(runtimeStatus.models),
modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog),
modelCatalog,
modelCatalogRefreshState: getRuntimeModelCatalogRefreshState(runtimeStatus, modelCatalog),
subscriptionRateLimits: mapRuntimeSubscriptionRateLimits(
providerId,
runtimeStatus.authMethod,
@ -774,9 +846,10 @@ export class ClaudeMultimodelBridgeService {
}
private buildProviderStatusesSnapshot(
providers: Map<CliProviderId, CliProviderStatus>
providers: Map<CliProviderId, CliProviderStatus>,
providerIds: readonly CliProviderId[] = ORDERED_PROVIDER_IDS
): CliProviderStatus[] {
return ORDERED_PROVIDER_IDS.map(
return providerIds.map(
(providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId)
);
}
@ -785,59 +858,62 @@ export class ClaudeMultimodelBridgeService {
binaryPath: string,
providerId: CliProviderId,
env: NodeJS.ProcessEnv,
connectionIssues: Partial<Record<CliProviderId, string>>
connectionIssues: Partial<Record<CliProviderId, string>>,
options: { summary?: boolean } = {}
): Promise<CliProviderStatus> {
const { stdout } = await execCli(
binaryPath,
['runtime', 'status', '--json', '--provider', providerId],
{
timeout: PROVIDER_STATUS_TIMEOUT_MS,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
}
);
const args = ['runtime', 'status', '--json', '--provider', providerId];
if (options.summary) {
args.push('--summary');
}
const { stdout } = await execCli(binaryPath, args, {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
});
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
return providerConnectionService.enrichProviderStatus(
this.applyConnectionIssue(
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
connectionIssues
)
),
{ hydrateModelCatalog: options.summary !== true }
);
}
private async getProviderStatusFromScopedRuntimeStatus(
binaryPath: string,
providerId: CliProviderId
providerId: CliProviderId,
options: { summary?: boolean } = {}
): Promise<CliProviderStatus> {
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
return this.getProviderStatusFromRuntimeStatusCommand(
binaryPath,
providerId,
env,
connectionIssues
connectionIssues,
options
);
}
private async getProviderStatusesFromScopedRuntimeStatus(
binaryPath: string,
onUpdate?: (providers: CliProviderStatus[]) => void
onUpdate?: (providers: CliProviderStatus[]) => void,
options: { summary?: boolean; providerIds?: readonly CliProviderId[] } = {}
): Promise<CliProviderStatus[] | null> {
const providerIds = options.providerIds ?? ORDERED_PROVIDER_IDS;
const providers = new Map<CliProviderId, CliProviderStatus>(
ORDERED_PROVIDER_IDS.map((providerId) => [
providerId,
createPendingProviderStatus(providerId),
])
providerIds.map((providerId) => [providerId, createPendingProviderStatus(providerId)])
);
const failures: { providerId: CliProviderId; error: unknown }[] = [];
await Promise.all(
ORDERED_PROVIDER_IDS.map(async (providerId) => {
providerIds.map(async (providerId) => {
try {
providers.set(
providerId,
await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId)
await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId, options)
);
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
} catch (error) {
failures.push({ providerId, error });
}
@ -845,10 +921,10 @@ export class ClaudeMultimodelBridgeService {
);
if (failures.length === 0) {
return this.buildProviderStatusesSnapshot(providers);
return this.buildProviderStatusesSnapshot(providers, providerIds);
}
if (failures.length === ORDERED_PROVIDER_IDS.length) {
if (failures.length === providerIds.length) {
return null;
}
@ -861,8 +937,65 @@ export class ClaudeMultimodelBridgeService {
for (const { providerId, error } of failures) {
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
}
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
return this.buildProviderStatusesSnapshot(providers);
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
return this.buildProviderStatusesSnapshot(providers, providerIds);
}
private hydrateProviderCatalogs(
binaryPath: string,
liveProviders: CliProviderStatus[],
generation: number,
onUpdate?: (providers: CliProviderStatus[]) => void
): void {
if (!onUpdate) {
return;
}
const providers = new Map<CliProviderId, CliProviderStatus>(
liveProviders.map((provider) => [provider.providerId, provider])
);
const providerIds = liveProviders.map((provider) => provider.providerId);
for (const liveProvider of liveProviders) {
if (liveProvider.runtimeCapabilities?.modelCatalog?.dynamic !== true) {
continue;
}
void this.getProviderStatusFromScopedRuntimeStatus(binaryPath, liveProvider.providerId)
.then((hydratedProvider) => {
if (!this.isProviderStatusHydrationCurrent(liveProvider.providerId, generation)) {
return;
}
const currentProvider = providers.get(liveProvider.providerId);
if (!currentProvider) {
return;
}
providers.set(
liveProvider.providerId,
mergeProviderCatalogFields(currentProvider, hydratedProvider)
);
onUpdate(this.buildProviderStatusesSnapshot(providers, providerIds));
})
.catch((error) => {
if (!this.isProviderStatusHydrationCurrent(liveProvider.providerId, generation)) {
return;
}
const currentProvider = providers.get(liveProvider.providerId);
if (!currentProvider) {
return;
}
providers.set(liveProvider.providerId, {
...currentProvider,
modelCatalogRefreshState: 'error',
});
logger.warn(
`Provider catalog hydration failed for ${liveProvider.providerId}: ${
error instanceof Error ? error.message : String(error)
}`
);
onUpdate(this.buildProviderStatusesSnapshot(providers, providerIds));
});
}
}
private async getOpenCodeVerifySnapshot(
@ -956,22 +1089,54 @@ export class ClaudeMultimodelBridgeService {
async getProviderStatus(
binaryPath: string,
providerId: CliProviderId
providerId: CliProviderId,
onCatalogUpdate?: (provider: CliProviderStatus) => void
): Promise<CliProviderStatus> {
await resolveInteractiveShellEnv();
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
try {
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
const generation = this.beginProviderStatusHydration([providerId]);
const provider = await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId, {
summary: true,
});
if (provider.runtimeCapabilities?.modelCatalog?.dynamic === true && onCatalogUpdate) {
void this.getProviderStatusFromScopedRuntimeStatus(binaryPath, provider.providerId)
.then((hydratedProvider) => {
if (!this.isProviderStatusHydrationCurrent(provider.providerId, generation)) {
return;
}
onCatalogUpdate(mergeProviderCatalogFields(provider, hydratedProvider));
})
.catch((error) => {
if (!this.isProviderStatusHydrationCurrent(provider.providerId, generation)) {
return;
}
logger.warn(
`Provider catalog hydration failed for ${provider.providerId}: ${
error instanceof Error ? error.message : String(error)
}`
);
onCatalogUpdate({
...provider,
modelCatalogRefreshState: 'error',
});
});
}
return provider;
} catch (error) {
if (!this.isUnifiedRuntimeUnsupported(error)) {
logger.warn(
`Provider-scoped runtime status unavailable for ${providerId}, falling back to full probe: ${
`Provider-scoped summary runtime status unavailable for ${providerId}, falling back to full probe: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
if (providerId === 'gemini') {
return this.buildGeminiStatus(binaryPath);
}
const providers = await this.getProviderStatuses(binaryPath);
return (
providers.find((provider) => provider.providerId === providerId) ??
@ -1129,16 +1294,39 @@ export class ClaudeMultimodelBridgeService {
binaryPath: string,
onUpdate?: (providers: CliProviderStatus[]) => void
): Promise<CliProviderStatus[]> {
await resolveInteractiveShellEnv();
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
try {
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(binaryPath, onUpdate);
const generation = this.beginProviderStatusHydration(DEFAULT_PROVIDER_STATUS_IDS);
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
binaryPath,
onUpdate,
{ summary: true, providerIds: DEFAULT_PROVIDER_STATUS_IDS }
);
if (providers) {
this.hydrateProviderCatalogs(binaryPath, providers, generation, onUpdate);
return providers;
}
} catch (error) {
logger.warn(
`Provider-scoped summary runtime status unavailable, falling back to full probe: ${
error instanceof Error ? error.message : String(error)
}`
);
}
try {
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(
binaryPath,
onUpdate,
{ providerIds: DEFAULT_PROVIDER_STATUS_IDS }
);
if (providers) {
return providers;
}
} catch (error) {
logger.warn(
`Provider-scoped runtime status unavailable, falling back to full probe: ${
`Provider-scoped full runtime status unavailable, falling back to legacy probes: ${
error instanceof Error ? error.message : String(error)
}`
);
@ -1155,7 +1343,7 @@ export class ClaudeMultimodelBridgeService {
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
const providers = await providerConnectionService.enrichProviderStatuses(
this.applyConnectionIssues(
ORDERED_PROVIDER_IDS.map((providerId) =>
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) =>
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
),
connectionIssues
@ -1187,7 +1375,7 @@ export class ClaudeMultimodelBridgeService {
]);
const providers = new Map<CliProviderId, CliProviderStatus>(
ORDERED_PROVIDER_IDS.map((providerId) => [
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) => [
providerId,
createDefaultProviderStatus(providerId),
])
@ -1196,7 +1384,7 @@ export class ClaudeMultimodelBridgeService {
if (statusResult.status === 'fulfilled') {
try {
const parsed = extractJsonObject<ProviderStatusCommandResponse>(statusResult.value.stdout);
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
for (const providerId of DEFAULT_PROVIDER_STATUS_IDS) {
const runtimeStatus = parsed.providers?.[providerId];
if (!runtimeStatus) continue;
providers.set(providerId, {
@ -1226,7 +1414,7 @@ export class ClaudeMultimodelBridgeService {
}
: null,
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
onUpdate?.(DEFAULT_PROVIDER_STATUS_IDS.map((id) => providers.get(id)!));
}
} catch (error) {
logger.warn(
@ -1241,26 +1429,26 @@ export class ClaudeMultimodelBridgeService {
? statusResult.reason.message
: String(statusResult.reason);
logger.warn(`Provider auth status unavailable: ${message}`);
for (const providerId of ORDERED_PROVIDER_IDS) {
for (const providerId of DEFAULT_PROVIDER_STATUS_IDS) {
providers.set(providerId, {
...providers.get(providerId)!,
statusMessage: 'Provider status not supported by current claude-multimodel build',
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
onUpdate?.(DEFAULT_PROVIDER_STATUS_IDS.map((id) => providers.get(id)!));
}
}
if (modelsResult.status === 'fulfilled') {
try {
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelsResult.value.stdout);
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
for (const providerId of DEFAULT_PROVIDER_STATUS_IDS) {
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
if (runtimeModels.length === 0) continue;
providers.set(providerId, {
...providers.get(providerId)!,
models: runtimeModels,
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
onUpdate?.(DEFAULT_PROVIDER_STATUS_IDS.map((id) => providers.get(id)!));
}
} catch (error) {
logger.warn(
@ -1271,12 +1459,9 @@ export class ClaudeMultimodelBridgeService {
}
}
providers.set('gemini', await this.buildGeminiStatus(binaryPath));
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
const enrichedProviders = await providerConnectionService.enrichProviderStatuses(
this.applyConnectionIssues(
ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!),
DEFAULT_PROVIDER_STATUS_IDS.map((providerId) => providers.get(providerId)!),
connectionIssues
)
);

View file

@ -107,6 +107,10 @@ type AnthropicApiKeyVerifier = (
baseUrl?: string | null
) => Promise<AnthropicApiKeyVerificationResult>;
interface ProviderStatusEnrichmentOptions {
hydrateModelCatalog?: boolean;
}
function hashCredentialForCache(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
}
@ -698,7 +702,10 @@ export class ProviderConnectionService {
return [];
}
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
async enrichProviderStatus(
provider: CliProviderStatus,
options: ProviderStatusEnrichmentOptions = {}
): Promise<CliProviderStatus> {
const withConnection = {
...provider,
connection: await this.getConnectionInfo(provider.providerId),
@ -713,6 +720,13 @@ export class ProviderConnectionService {
}
try {
if (
options.hydrateModelCatalog === false &&
!isUsableCodexModelCatalog(withConnection.modelCatalog)
) {
return withConnection;
}
const orchestratorCatalog = isUsableCodexModelCatalog(withConnection.modelCatalog)
? withConnection.modelCatalog
: null;

View file

@ -926,6 +926,9 @@ export function snapshotToMemberSpawnStatuses(
bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed,
hardFailure: skippedForLaunch ? false : entry.hardFailure,
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
bootstrapEvidenceSource: entry.bootstrapEvidenceSource,
bootstrapMode: entry.bootstrapMode,
appManagedBootstrapCandidate: entry.appManagedBootstrapCandidate,
livenessKind: entry.livenessKind,
runtimeDiagnostic: entry.runtimeDiagnostic,
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,

View file

@ -438,16 +438,7 @@ export class TeamMemberRuntimeAdvisoryService {
const memberKeysWithRecentErrors = new Set<string>();
for (const [memberKey, records] of recordsByMember) {
if (
records.some((record) => {
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
return (
isPotentialOpenCodeRuntimeDeliveryError(record) &&
Number.isFinite(observedAt) &&
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
);
})
) {
if (records.some((record) => this.isOpenCodeDeliveryAdvisoryCandidate(record, now))) {
memberKeysWithRecentErrors.add(memberKey);
}
}
@ -509,12 +500,7 @@ export class TeamMemberRuntimeAdvisoryService {
);
const latestSuccess = ordered.find(isTerminalSuccessfulOpenCodeDeliveryRecord);
const latestError = ordered.find((record) => {
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
return (
isPotentialOpenCodeRuntimeDeliveryError(record) &&
Number.isFinite(observedAt) &&
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
);
return this.isOpenCodeDeliveryAdvisoryCandidate(record, now);
});
if (!latestError) {
return null;
@ -540,14 +526,87 @@ export class TeamMemberRuntimeAdvisoryService {
if (!message || !decision.observedAt) {
return null;
}
const retryWindow = this.extractOpenCodeDeliveryRetryWindow(latestError, now);
return {
kind: 'api_error',
observedAt: decision.observedAt,
reasonCode: decision.reasonCode,
message,
...(retryWindow ? retryWindow : {}),
};
}
private extractOpenCodeDeliveryRetryWindow(
record: OpenCodePromptDeliveryLedgerRecord,
now: number
): Pick<MemberRuntimeAdvisory, 'retryUntil' | 'retryDelayMs'> | null {
const candidates = [
...record.diagnostics.slice().reverse(),
record.lastReason,
record.nextAttemptAt,
];
for (const candidate of candidates) {
const retryAt = this.parseOpenCodeRetryAt(candidate);
if (!retryAt || retryAt <= now) {
continue;
}
return {
retryUntil: new Date(retryAt).toISOString(),
retryDelayMs: retryAt - now,
};
}
return null;
}
private parseOpenCodeRetryAt(value: string | null | undefined): number | null {
const text = value?.trim();
if (!text) {
return null;
}
const lowerText = text.toLowerCase();
const nextMarker = 'next=';
const tokenStart = lowerText.indexOf(nextMarker);
const valueStart = tokenStart >= 0 ? tokenStart + nextMarker.length : 0;
let valueEnd = valueStart;
while (valueEnd < text.length) {
const char = text[valueEnd];
if (
char === ' ' ||
char === '\t' ||
char === '\n' ||
char === '\r' ||
char === ',' ||
char === ';'
) {
break;
}
valueEnd += 1;
}
let cleaned = text.slice(valueStart, valueEnd);
while (cleaned.endsWith('.') || cleaned.endsWith(')') || cleaned.endsWith(']')) {
cleaned = cleaned.slice(0, -1);
}
const parsed = Date.parse(cleaned);
return Number.isFinite(parsed) ? parsed : null;
}
private isOpenCodeDeliveryAdvisoryCandidate(
record: OpenCodePromptDeliveryLedgerRecord,
now: number
): boolean {
if (!isPotentialOpenCodeRuntimeDeliveryError(record)) {
return false;
}
if (
!isTerminalSuccessfulOpenCodeDeliveryRecord(record) &&
record.status !== 'failed_terminal'
) {
return true;
}
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
return Number.isFinite(observedAt) && now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS;
}
private async findRecentMemberAdvisoriesFromBatchRefs(
teamName: string,
memberNames: readonly string[]

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import {
listRuntimeProcessesForCurrentTmuxPlatform,
listRuntimeProcessTableForCurrentPlatform,
type RuntimeProcessTableRow,
} from '@features/tmux-installer/main';
import { killProcessByPid } from '@main/utils/processKill';
@ -62,7 +62,7 @@ export async function cleanupManagedOpenCodeServeProcesses(
const rows = await (
options.listProcessRows ??
(platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessesForCurrentTmuxPlatform)
(platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessTableForCurrentPlatform)
)();
const excludePids = options.excludePids ?? new Set<number>();
const requiredDetailsMarkers = options.requiredDetailsMarkers ?? [];
@ -99,7 +99,8 @@ export async function cleanupManagedOpenCodeServeProcesses(
const isManagedByWindowsCommand =
platform === 'win32' && isAppManagedWindowsOpenCodeServeCommand(row.command);
const isManaged =
isManagedByWindowsCommand || Boolean(details && isManagedOpenCodeServeProcessDetails(details));
isManagedByWindowsCommand ||
Boolean(details && isManagedOpenCodeServeProcessDetails(details));
const hasRequiredDetailsMarkers =
requiredDetailsMarkers.length === 0 ||
Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers));

View file

@ -90,6 +90,15 @@ export function isTerminalSuccessfulOpenCodeDeliveryRecord(
export function isPotentialOpenCodeRuntimeDeliveryError(
record: OpenCodePromptDeliveryLedgerRecord
): boolean {
const terminalSuccess =
record.status === 'responded' &&
Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId);
if (
!terminalSuccess &&
isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record))
) {
return true;
}
if (record.status === 'failed_terminal') {
return true;
}

View file

@ -18,7 +18,9 @@ export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boo
export function selectOpenCodeRuntimeDeliveryReason(
record: OpenCodePromptDeliveryLedgerRecord
): string | null {
const candidates = [...record.diagnostics.slice().reverse(), record.lastReason];
const candidates = [...record.diagnostics.slice().reverse(), record.lastReason].filter(
(diagnostic) => !isInformationalOpenCodeRuntimeDeliveryDiagnostic(diagnostic)
);
const selected = selectRuntimeDiagnosticClassification(candidates);
if (selected && !selected.generic && selected.normalizedMessage) {
@ -33,6 +35,19 @@ export function selectOpenCodeRuntimeDeliveryReason(
return selected ? 'OpenCode runtime delivery did not complete.' : null;
}
function isInformationalOpenCodeRuntimeDeliveryDiagnostic(
message: string | null | undefined
): boolean {
const normalized = message?.trim().toLowerCase();
return (
normalized === 'opencode app mcp is connected for message delivery.' ||
normalized ===
'opencode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.' ||
normalized === 'opencode session status busy' ||
normalized === 'opencode_delivery_response_pending'
);
}
export function isActionRequiredOpenCodeRuntimeDeliveryReason(
message: string | null | undefined
): boolean {

View file

@ -43,9 +43,12 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
'capacity exceeded',
'quota exceeded',
'quota exhausted',
'usage exceeded',
'free usage exceeded',
'insufficient credits',
'key limit exceeded',
'total limit',
'subscribe to go',
],
priority: 95,
actionRequired: true,

View file

@ -23,6 +23,13 @@ export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
/** Main -> renderer startup progress update */
export const APP_STARTUP_PROGRESS = 'appStartup:progress';
// =============================================================================
// Telemetry Channels
// =============================================================================
/** Get Sentry-safe anonymous telemetry context */
export const TELEMETRY_GET_SENTRY_CONTEXT = 'telemetry:getSentryContext';
// =============================================================================
// Config API Channels
// =============================================================================

View file

@ -205,6 +205,7 @@ import {
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
TEAM_VALIDATE_CLI_ARGS,
TELEMETRY_GET_SENTRY_CONTEXT,
TERMINAL_DATA,
TERMINAL_EXIT,
TERMINAL_KILL,
@ -495,6 +496,9 @@ const electronAPI: ElectronAPI = {
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
memberLogStream: createMemberLogStreamBridge(),
telemetry: {
getSentryContext: () => ipcRenderer.invoke(TELEMETRY_GET_SENTRY_CONTEXT),
},
startup: {
getStatus: () => ipcRenderer.invoke(APP_STARTUP_GET_STATUS) as Promise<AppStartupStatus>,
onProgress: (callback: (status: AppStartupStatus) => void): (() => void) => {

View file

@ -109,6 +109,9 @@ export class HttpAPIClient implements ElectronAPI {
private eventSource: EventSource | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
telemetry = {
getSentryContext: async () => null,
};
constructor(baseUrl: string) {
this.baseUrl = baseUrl;

View file

@ -855,6 +855,9 @@ const InstalledBanner = ({
isProviderCardLoading(provider, providerLoading) ||
isCodexSnapshotPending(provider, codexSnapshotPending) ||
maskNegativeBootstrapState;
const anthropicRateLimitsLoading =
provider.providerId === 'anthropic' &&
(anthropicRateLimitsRefreshing || provider.modelCatalogRefreshState === 'loading');
const showRateLimitSkeleton =
(showSkeleton &&
shouldShowDashboardRateLimitSkeleton({
@ -865,8 +868,9 @@ const InstalledBanner = ({
(isSubscriptionRateLimitMode &&
!hasDashboardRateLimits &&
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
(provider.providerId === 'anthropic' && anthropicRateLimitsRefreshing)));
anthropicRateLimitsLoading));
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading';
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
@ -934,7 +938,10 @@ const InstalledBanner = ({
) : null}
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
{credentialSummary ? <span>{credentialSummary}</span> : null}
{provider.models.length === 0 && (
{provider.models.length === 0 && modelCatalogLoading ? (
<span>Loading models...</span>
) : null}
{provider.models.length === 0 && !modelCatalogLoading && (
<span>Models unavailable for this runtime build</span>
)}
</div>

View file

@ -32,6 +32,7 @@ import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { formatBytes } from '@renderer/utils/formatters';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
@ -249,10 +250,17 @@ export const CliStatusSection = (): React.JSX.Element | null => {
: loadingCliStatus,
[codexAccount.snapshot, loadingCliStatus]
);
const visibleEffectiveProviders = useMemo(
() => filterMainScreenCliProviders(effectiveCliStatus?.providers ?? []),
[effectiveCliStatus?.providers]
);
const loadingCliProviderMap = useMemo(
() =>
new Map(
(loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider])
filterMainScreenCliProviders(loadingCliStatus?.providers ?? []).map((provider) => [
provider.providerId,
provider,
])
),
[loadingCliStatus?.providers]
);
@ -485,9 +493,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
</span>
</div>
)}
{effectiveCliStatus.providers.length > 0 && (
{visibleEffectiveProviders.length > 0 && (
<div className="ml-6 mt-3 space-y-2">
{effectiveCliStatus.providers.map((provider) => (
{visibleEffectiveProviders.map((provider) => (
<div
key={provider.providerId}
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
@ -515,6 +523,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const statusText = effectiveShowSkeleton
? 'Checking...'
: formatProviderStatusText(provider);
const modelCatalogLoading =
provider.modelCatalogRefreshState === 'loading';
const connectionModeSummary = getProviderConnectionModeSummary(provider);
const credentialSummary = getProviderCredentialSummary(provider);
const disconnectAction = getProviderDisconnectAction(provider);
@ -575,7 +585,10 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<span>{connectionModeSummary}</span>
) : null}
{credentialSummary ? <span>{credentialSummary}</span> : null}
{provider.models.length === 0 && (
{provider.models.length === 0 && modelCatalogLoading ? (
<span>Loading models...</span>
) : null}
{provider.models.length === 0 && !modelCatalogLoading && (
<span>Models unavailable for this runtime build</span>
)}
</div>

View file

@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
@ -7,6 +8,7 @@ import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
import { cn } from '@renderer/lib/utils';
import { markTaskUnread } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor';
import {
@ -16,6 +18,7 @@ import {
NO_PROJECT_KEY,
sortTasksByFreshness,
} from '@renderer/utils/taskGrouping';
import { resolveTeamStatus } from '@renderer/utils/teamListStatus';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import {
Archive,
@ -191,6 +194,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
viewMode,
repositoryGroups,
teams,
provisioningRuns,
currentProvisioningRunIdByTeam,
leadActivityByTeam,
} = useStore(
useShallow((s) => ({
globalTasks: s.globalTasks,
@ -202,6 +208,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups,
teams: s.teams,
provisioningRuns: s.provisioningRuns,
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
leadActivityByTeam: s.leadActivityByTeam,
}))
);
@ -217,6 +226,8 @@ export const GlobalTaskList = memo(function GlobalTaskList({
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [aliveTeamsInitialized, setAliveTeamsInitialized] = useState(false);
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
Record<string, number>
>({});
@ -224,6 +235,21 @@ export const GlobalTaskList = memo(function GlobalTaskList({
const hasFetchedRef = useRef(false);
const readState = useReadStateSnapshot();
const taskLocalState = useTaskLocalState();
const electronMode = isElectronMode();
const provisioningState = useMemo(
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
[currentProvisioningRunIdByTeam, provisioningRuns]
);
const fetchAliveTeams = useCallback(async (): Promise<string[] | null> => {
if (!electronMode || !api.teams?.aliveList) return null;
try {
return await api.teams.aliveList();
} catch {
return null;
}
}, [electronMode]);
// --- New-task animation tracking (same pattern as ChatHistory) ---
const knownTaskIdsRef = useRef<Set<string>>(new Set());
@ -262,6 +288,70 @@ export const GlobalTaskList = memo(function GlobalTaskList({
[newTaskIds]
);
useEffect(() => {
let cancelled = false;
void fetchAliveTeams().then((list) => {
if (!cancelled && list) {
setAliveTeams(list);
setAliveTeamsInitialized(true);
}
});
return () => {
cancelled = true;
};
}, [fetchAliveTeams, teams]);
const readyProgressRefreshKey = useMemo(() => {
return Object.entries(currentProvisioningRunIdByTeam)
.map(([teamName, runId]) => {
if (!runId) return null;
const progress = provisioningRuns[runId];
return progress?.state === 'ready'
? `${teamName}:${progress.runId}:${progress.updatedAt}`
: null;
})
.filter((item): item is string => Boolean(item))
.join('|');
}, [currentProvisioningRunIdByTeam, provisioningRuns]);
useEffect(() => {
if (!readyProgressRefreshKey) return;
let cancelled = false;
void fetchAliveTeams().then((list) => {
if (!cancelled && list) {
setAliveTeams(list);
setAliveTeamsInitialized(true);
}
});
return () => {
cancelled = true;
};
}, [fetchAliveTeams, readyProgressRefreshKey]);
const offlineTeamNames = useMemo(() => {
const result = new Set<string>();
if (aliveTeamsInitialized) {
for (const team of teams) {
const status = resolveTeamStatus(
team,
team.teamName,
aliveTeams,
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
leadActivityByTeam
);
if (status === 'offline') {
result.add(team.teamName);
}
}
}
for (const [teamName, activity] of Object.entries(leadActivityByTeam)) {
if (activity === 'offline') {
result.add(teamName);
}
}
return result;
}, [aliveTeams, aliveTeamsInitialized, leadActivityByTeam, provisioningState, teams]);
const setGroupingMode = (mode: TaskGroupingMode): void => {
setGroupingModeState(mode);
saveGroupingMode(mode);
@ -561,6 +651,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<SidebarTaskItem
task={task}
showTeamName
teamOffline={offlineTeamNames.has(task.teamName)}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
@ -655,6 +746,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<SidebarTaskItem
task={task}
showTeamName
teamOffline={offlineTeamNames.has(task.teamName)}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
@ -742,6 +834,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
task={task}
hideTeamName
hideProjectName
teamOffline={offlineTeamNames.has(task.teamName)}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
@ -848,6 +941,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<AnimatedHeightReveal animate={isNewTask(task)}>
<SidebarTaskItem
task={task}
teamOffline={offlineTeamNames.has(task.teamName)}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}

View file

@ -65,6 +65,8 @@ interface SidebarTaskItemProps {
hideTeamName?: boolean;
hideProjectName?: boolean;
showTeamName?: boolean;
/** Pauses the in-progress spinner when the parent team is offline. */
teamOffline?: boolean;
/** The composite key "teamName:taskId" of the task being renamed, or null */
renamingKey?: string | null;
/** Called when rename is completed with Enter or blur */
@ -80,6 +82,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
hideTeamName,
hideProjectName,
showTeamName,
teamOffline = false,
renamingKey,
onRenameComplete,
onRenameCancel,
@ -120,10 +123,11 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon;
const shouldAnimateStatusIcon = cfg.label === 'in progress' && !teamOffline;
const statusIconClassName = cn(
'size-3 shrink-0',
cfg.color,
cfg.label === 'in progress' && 'animate-spin'
shouldAnimateStatusIcon && 'animate-spin'
);
const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);

View file

@ -32,6 +32,10 @@ interface RenderedTeamChangeSummary {
}
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
const COMPACT_HIDDEN_INTERVAL_SCOPE_WARNINGS = new Set([
'Task boundaries missing - scoped by workIntervals timestamps.',
'Task start boundary missing - scoped by persisted workIntervals timestamps.',
]);
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
if (!Array.isArray(changeSet?.files)) {
@ -111,6 +115,23 @@ function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefi
return undefined;
}
function isWorkIntervalScopedFileChange(changeSet: TaskChangeSetV2): boolean {
const reason = changeSet.scope?.confidence?.reason;
return (
getChangeSetFiles(changeSet).length > 0 &&
changeSet.confidence === 'medium' &&
typeof reason === 'string' &&
reason.toLowerCase().includes('workinterval')
);
}
function shouldHideCompactDiagnostic(changeSet: TaskChangeSetV2, message: string): boolean {
return (
isWorkIntervalScopedFileChange(changeSet) &&
COMPACT_HIDDEN_INTERVAL_SCOPE_WARNINGS.has(message.trim())
);
}
function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
const status = classifyTaskChangeReviewability(changeSet);
if (status.reviewability === 'unknown' || status.reviewability === 'none') {
@ -120,7 +141,13 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
status.diagnostics.length > 0
? status.diagnostics.map((diagnostic) => diagnostic.message)
: getChangeSetWarnings(changeSet);
return [...new Set(messages.filter((message) => message.trim().length > 0))];
return [
...new Set(
messages.filter(
(message) => message.trim().length > 0 && !shouldHideCompactDiagnostic(changeSet, message)
)
),
];
}
export const TeamChangesSection = memo(function TeamChangesSection({

View file

@ -1397,6 +1397,7 @@ export const TeamDetailView = memo(function TeamDetailView({
});
const [creatingTask, setCreatingTask] = useState(false);
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
const [runtimeTelemetryPreviewVisible, setRuntimeTelemetryPreviewVisible] = useState(false);
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
@ -2877,20 +2878,35 @@ export const TeamDetailView = memo(function TeamDetailView({
icon={<Users size={14} />}
badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
defaultOpen
afterBadge={
<Button
variant="ghost"
size="sm"
className="pointer-events-auto h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setAddMemberDialogOpen(true);
}}
>
<UserPlus size={12} />
Add
</Button>
}
action={
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setAddMemberDialogOpen(true);
}}
>
<UserPlus size={12} />
Member
</Button>
<div
className={cn(
'flex items-center gap-3 pr-3 text-[11px] font-medium leading-none text-[var(--color-text-muted)] transition-opacity duration-150',
runtimeTelemetryPreviewVisible ? 'opacity-100' : 'opacity-0'
)}
>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(34,197,94,0.3)]" />
Memory
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-blue-500 shadow-[0_0_6px_rgba(59,130,246,0.3)]" />
CPU
</span>
</div>
}
contentWrapperClassName="-mx-[calc(1rem-5px)] w-[calc(100%+2rem-10px)]"
@ -2907,6 +2923,8 @@ export const TeamDetailView = memo(function TeamDetailView({
isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning}
launchParams={launchParams}
runtimeTelemetryVisible={runtimeTelemetryPreviewVisible}
onRuntimeTelemetryHoverChange={setRuntimeTelemetryPreviewVisible}
onMemberClick={handleSelectMember}
onSendMessage={handleSendMessageToMember}
onAssignTask={handleAssignTaskToMember}

View file

@ -234,6 +234,40 @@ function lowConfidenceFileResponse(): TeamTaskChangeSummariesResponse {
});
}
function intervalScopedFileResponse(): TeamTaskChangeSummariesResponse {
return response({
...changeSet(),
confidence: 'medium',
files: [
fileChange({
filePath: '/repo/791/calculator.js',
relativePath: '791/calculator.js',
}),
],
totalFiles: 1,
totalLinesAdded: 162,
scope: {
...changeSet().scope,
confidence: {
tier: 2,
label: 'medium',
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
},
},
warnings: ['Task start boundary missing - scoped by persisted workIntervals timestamps.'],
});
}
function warningFileResponse(): TeamTaskChangeSummariesResponse {
return response({
...changeSet(),
files: [fileChange()],
totalFiles: 1,
totalLinesAdded: 1,
warnings: ['Unexpected ledger warning.'],
});
}
function invalidFileSummaryResponse(): TeamTaskChangeSummariesResponse {
return response({
...changeSet(),
@ -751,6 +785,114 @@ describe('useTeamChangesSummaries', () => {
}
});
it('hides work-interval scoping advisories in the compact Changes list when files are present', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(intervalScopedFileResponse());
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView'
);
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task({ status: 'completed', owner: 'jack' })],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('791/calculator.js');
expect(container.textContent).not.toContain(
'Task start boundary missing - scoped by persisted workIntervals timestamps.'
);
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
it('keeps unrelated file warnings visible in the compact Changes list', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(warningFileResponse());
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollIntoView'
);
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
try {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task({ status: 'completed' })],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('src/app.ts');
expect(container.textContent).toContain('Unexpected ledger warning.');
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
it('does not clear completed task presence from an uncertain empty summary', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));

View file

@ -147,6 +147,7 @@ type ProvisioningDetailSummary =
| 'Selected model unavailable'
| 'Selected model verification timed out'
| 'Selected model check failed'
| 'Selected model verification deferred'
| 'Selected model ping not confirmed'
| 'Ready with notes'
| 'Needs attention';
@ -163,6 +164,7 @@ function isFormattedModelDetail(lower: string): boolean {
lower.includes(' - compatible, deep verification pending') ||
lower.includes(' - unavailable') ||
lower.includes(' - check failed') ||
lower.includes(' - verification deferred') ||
lower.includes(' - ping not confirmed')
);
}
@ -244,6 +246,9 @@ function summarizeDetail(
if (isSelectedModelDetail(lower) && lower.includes('could not be verified')) {
return 'Selected model check failed';
}
if (isSelectedModelDetail(lower) && lower.includes('verification deferred')) {
return 'Selected model verification deferred';
}
if (lower.includes(' - verified')) {
return 'Selected model verified';
}
@ -259,6 +264,9 @@ function summarizeDetail(
if (lower.includes(' - check failed -')) {
return 'Selected model check failed';
}
if (lower.includes(' - verification deferred')) {
return 'Selected model verification deferred';
}
if (lower.includes(' - ping not confirmed')) {
return 'Selected model ping not confirmed';
}
@ -279,6 +287,7 @@ function getModelDetailSummary(details: string[]): string | null {
let unavailableCount = 0;
let timedOutCount = 0;
let checkFailedCount = 0;
let deferredCount = 0;
let pingNotConfirmedCount = 0;
let checkingCount = 0;
@ -327,6 +336,13 @@ function getModelDetailSummary(details: string[]): string | null {
checkFailedCount += 1;
continue;
}
if (
lower.includes(' - verification deferred') ||
(isSelectedModelDetail(lower) && lower.includes('verification deferred'))
) {
deferredCount += 1;
continue;
}
if (lower.includes(' - ping not confirmed')) {
pingNotConfirmedCount += 1;
continue;
@ -346,6 +362,9 @@ function getModelDetailSummary(details: string[]): string | null {
if (timedOutCount > 0) {
parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
}
if (deferredCount > 0) {
parts.push(`${deferredCount} verification deferred`);
}
if (pingNotConfirmedCount > 0) {
parts.push(`${pingNotConfirmedCount} ping not confirmed`);
}

View file

@ -513,6 +513,48 @@ const OpenCodeVirtualizedModelGrid = ({
);
};
const OpenCodeModelCatalogLoadingSkeleton = (): React.JSX.Element => (
<div
data-testid="team-model-selector-opencode-loading-skeleton"
role="status"
aria-live="polite"
className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3"
>
<div className="mb-3 flex items-center gap-2">
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-blue-400" />
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
Loading OpenCode models...
</span>
</div>
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{[0, 1, 2].map((index) => (
<div
key={index}
className="min-h-[44px] rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-2"
>
<div
className="skeleton-shimmer mx-auto mb-1.5 h-3 rounded-sm"
style={{
width: index === 1 ? '64%' : '76%',
backgroundColor: 'var(--skeleton-base)',
}}
/>
<div
className="skeleton-shimmer mx-auto h-2 rounded-sm"
style={{
width: index === 2 ? '44%' : '52%',
backgroundColor: 'var(--skeleton-base-dim)',
}}
/>
</div>
))}
</div>
</div>
);
export interface TeamModelSelectorProps {
providerId: TeamProviderId;
onProviderChange: (providerId: TeamProviderId) => void;
@ -957,11 +999,18 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const visibleConcreteModelOptionCount =
visibleModelOptions.length - visibleDefaultModelOptions.length;
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length;
const shouldShowModelSearch = concreteModelOptionCount > 8;
const shouldShowOpenCodeCatalogLoading =
effectiveProviderId === 'opencode' &&
runtimeProviderStatus?.modelCatalogRefreshState === 'loading' &&
runtimeProviderStatus.modelCatalog?.providerId !== 'opencode' &&
(runtimeProviderStatus.models.length === 0 ||
runtimeProviderStatus.models.every((model) => model.trim() === 'opencode/big-pickle'));
const shouldShowModelSearch = !shouldShowOpenCodeCatalogLoading && concreteModelOptionCount > 8;
const trimmedModelQuery = modelQuery.trim();
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
const shouldVirtualizeOpenCodeModels =
effectiveProviderId === 'opencode' &&
!shouldShowOpenCodeCatalogLoading &&
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
@ -1270,8 +1319,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
/>
</div>
) : null}
{(effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
hasRecommendedOpenCodeModels ? (
{!shouldShowOpenCodeCatalogLoading &&
((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
hasRecommendedOpenCodeModels) ? (
<div className="mb-2 flex flex-wrap items-center gap-2">
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
<Popover
@ -1370,7 +1420,22 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</div>
) : null}
{effectiveProviderId === 'opencode' ? (
shouldVirtualizeOpenCodeModels ? (
shouldShowOpenCodeCatalogLoading ? (
<div
data-testid="team-model-selector-model-grid"
className="space-y-3 rounded-md bg-[var(--color-surface)]"
>
{visibleDefaultModelOptions.length > 0 ? (
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{visibleDefaultModelOptions.map(renderModelOption)}
</div>
) : null}
<OpenCodeModelCatalogLoadingSkeleton />
</div>
) : shouldVirtualizeOpenCodeModels ? (
<OpenCodeVirtualizedModelGrid
defaultOptions={visibleDefaultModelOptions}
groups={visibleOpenCodeModelGroups}
@ -1437,7 +1502,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
{visibleModelOptions.map(renderModelOption)}
</div>
)}
{visibleModelOptions.length === 0 ? (
{visibleModelOptions.length === 0 && !shouldShowOpenCodeCatalogLoading ? (
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
{trimmedModelQuery
? 'No models match this search.'

View file

@ -169,6 +169,7 @@ function stripSelectedModelPrefix(modelId: string, message: string): string {
const patterns = [
new RegExp(`^Selected model ${escapeRegExp(modelId)} is unavailable\\.\\s*`, 'i'),
new RegExp(`^Selected model ${escapeRegExp(modelId)} could not be verified\\.\\s*`, 'i'),
new RegExp(`^Selected model ${escapeRegExp(modelId)} verification deferred\\.\\s*`, 'i'),
new RegExp(`^Selected model ${escapeRegExp(modelId)} verified for launch\\.\\s*`, 'i'),
new RegExp(`^Selected model ${escapeRegExp(modelId)} is available for launch\\.\\s*`, 'i'),
new RegExp(
@ -420,6 +421,17 @@ function buildModelFailureLine(
return reason ? `${label} - ${kind} - ${reason}` : `${label} - ${kind}`;
}
function buildModelVerificationDeferredLine(
providerId: TeamProviderId,
modelId: string,
reason: string | null
): string {
const label = getModelLabel(providerId, modelId);
return reason
? `${label} - verification deferred - ${reason}`
: `${label} - verification deferred`;
}
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]);
}
@ -574,6 +586,18 @@ function resolveModelResultFromBatch(
};
}
const hasVerificationDeferredLine = modelScopedEntries.some((entry) =>
/selected model .* verification deferred\./i.test(entry)
);
if (hasVerificationDeferredLine) {
const line = buildModelVerificationDeferredLine(providerId, modelId, scopedReason);
return {
status: 'notes',
line,
warningLine: line,
};
}
const hasUnavailableLine = modelScopedEntries.some((entry) =>
/selected model .* is unavailable\./i.test(entry)
);

View file

@ -105,7 +105,11 @@ export const CurrentTaskIndicator = memo(
return (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
<SyncedLoader2
className="size-3 shrink-0"
spinning={isTimerRunning}
style={{ color: borderColor }}
/>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button
type="button"

View file

@ -1,4 +1,4 @@
import { memo, useMemo, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
@ -26,7 +26,20 @@ import {
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { AlertTriangle, Ban, GitBranch, MessageSquare, Plus, RotateCcw } from 'lucide-react';
import {
Activity,
AlertTriangle,
Ban,
Cpu,
GitBranch,
HardDrive,
Info,
Layers3,
MessageSquare,
Plus,
RotateCcw,
Server,
} from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
@ -46,6 +59,11 @@ import type {
TeamTaskWithKanban,
} from '@shared/types';
export interface RuntimeTelemetryScale {
memoryCapBytes?: number;
cpuCapPercent?: number;
}
interface MemberCardProps {
member: ResolvedTeamMember;
memberColor: string;
@ -72,6 +90,8 @@ interface MemberCardProps {
spawnLaunchState?: MemberLaunchState;
spawnRuntimeAlive?: boolean;
isLaunchSettling?: boolean;
runtimeTelemetryVisible?: boolean;
runtimeTelemetryScale?: RuntimeTelemetryScale;
onOpenTask?: () => void;
onOpenReviewTask?: () => void;
onClick?: () => void;
@ -170,6 +190,232 @@ function buildAreaPath(points: readonly TelemetryPoint[]): string | undefined {
].join(' ');
}
function getRelativeTelemetryY(
value: number,
values: readonly number[],
options: {
bottomY: number;
amplitude: number;
fallbackRatio: number;
minimumSpan?: number;
}
): number {
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min;
if (span <= 0) {
return options.bottomY - options.fallbackRatio * options.amplitude;
}
const effectiveSpan = Math.max(span, options.minimumSpan ?? 0);
const ratio = Math.max(0, Math.min(1, (value - min) / effectiveSpan));
return options.bottomY - ratio * options.amplitude;
}
function getCappedTelemetryY(
value: number,
cap: number | undefined,
options: {
bottomY: number;
amplitude: number;
curve?: 'linear' | 'sqrt';
}
): number | undefined {
if (!isFiniteNonNegative(cap) || cap <= 0) {
return undefined;
}
const rawRatio = Math.max(0, Math.min(1, value / cap));
const ratio = options.curve === 'sqrt' ? Math.sqrt(rawRatio) : rawRatio;
return options.bottomY - ratio * options.amplitude;
}
function formatRuntimeTelemetryPercent(value: number | undefined): string | undefined {
if (!isFiniteNonNegative(value)) {
return undefined;
}
return `${value >= 10 ? Math.round(value) : value.toFixed(1)}%`;
}
function formatRuntimeTelemetryBytes(value: number | undefined): string | undefined {
if (!isFiniteNonNegative(value)) {
return undefined;
}
const mib = value / (1024 * 1024);
if (mib < 1024) {
return `${mib.toFixed(mib >= 10 ? 0 : 1)} MB`;
}
return `${(mib / 1024).toFixed(1)} GB`;
}
function isRuntimeTelemetrySampleLike(value: unknown): value is TeamAgentRuntimeResourceSample {
if (!value || typeof value !== 'object') {
return false;
}
const sample = value as Partial<TeamAgentRuntimeResourceSample>;
return (
typeof sample.timestamp === 'string' ||
isFiniteNonNegative(sample.cpuPercent) ||
isFiniteNonNegative(sample.rssBytes)
);
}
function normalizeRuntimeTelemetrySamples(history: unknown): TeamAgentRuntimeResourceSample[] {
return (Array.isArray(history) ? history : []).filter(isRuntimeTelemetrySampleLike);
}
function buildRuntimeTelemetryTitle(
runtimeEntry: TeamAgentRuntimeEntry | undefined
): string | undefined {
if (!runtimeEntry) {
return undefined;
}
if (normalizeRuntimeTelemetrySamples(runtimeEntry?.resourceHistory).length === 0) {
return undefined;
}
const lines = [
'CPU includes parent + child processes.',
'Local CPU excludes remote LLM inference.',
];
if (runtimeEntry.runtimeLoadScope === 'shared-host') {
lines.push('Shared OpenCode host metric; not exclusive to this member.');
}
if (runtimeEntry.runtimeLoadTruncated) {
lines.push('Process tree was capped for this sample.');
}
const detailParts = [
runtimeEntry.pid ? `root PID ${runtimeEntry.pid}` : undefined,
runtimeEntry.processCount ? `${runtimeEntry.processCount} processes` : undefined,
runtimeEntry.runtimeLoadScope ? `scope ${runtimeEntry.runtimeLoadScope}` : undefined,
'sample 5s',
].filter((part): part is string => Boolean(part));
if (detailParts.length > 0) {
lines.push(detailParts.join(' · '));
}
const aggregateCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.cpuPercent);
const primaryCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.primaryCpuPercent);
const childCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.childCpuPercent);
const rssLabel = formatRuntimeTelemetryBytes(runtimeEntry.rssBytes);
const splitParts = [
aggregateCpuLabel ? `CPU ${aggregateCpuLabel}` : undefined,
primaryCpuLabel ? `root ${primaryCpuLabel}` : undefined,
childCpuLabel ? `children ${childCpuLabel}` : undefined,
rssLabel ? `RSS ${rssLabel}` : undefined,
].filter((part): part is string => Boolean(part));
if (splitParts.length > 0) {
lines.push(splitParts.join(' · '));
}
lines.push('RSS is summed process RSS and can include shared pages.');
return lines.join('\n');
}
function RuntimeTelemetryTooltipContent({
runtimeEntry,
}: {
runtimeEntry: TeamAgentRuntimeEntry | undefined;
}): React.JSX.Element | null {
if (!runtimeEntry) {
return null;
}
const aggregateCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.cpuPercent);
const primaryCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.primaryCpuPercent);
const childCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.childCpuPercent);
const rssLabel = formatRuntimeTelemetryBytes(runtimeEntry.rssBytes);
const detailParts = [
runtimeEntry.pid ? `root PID ${runtimeEntry.pid}` : undefined,
runtimeEntry.processCount ? `${runtimeEntry.processCount} processes` : undefined,
runtimeEntry.runtimeLoadScope ? `scope ${runtimeEntry.runtimeLoadScope}` : undefined,
'sample 5s',
].filter((part): part is string => Boolean(part));
const cpuSplit = [
primaryCpuLabel ? `root ${primaryCpuLabel}` : undefined,
childCpuLabel ? `children ${childCpuLabel}` : undefined,
].filter((part): part is string => Boolean(part));
return (
<div className="w-[320px] max-w-[min(320px,var(--radix-tooltip-content-available-width))] space-y-2.5">
<div className="flex items-start gap-2">
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md border border-blue-500/30 bg-blue-500/10 text-blue-300">
<Activity className="size-3.5" />
</span>
<div className="min-w-0">
<div className="text-[12px] font-semibold leading-tight text-[var(--color-text)]">
Local runtime load
</div>
<div className="mt-0.5 text-[10px] leading-snug text-[var(--color-text-muted)]">
Parent and child processes only. Remote LLM inference is not included.
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-1.5">
<div className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-blue-200/80">
<Cpu className="size-3" />
CPU
</div>
<div className="mt-1 text-[14px] font-semibold text-blue-100">
{aggregateCpuLabel ?? 'unknown'}
</div>
{cpuSplit.length > 0 ? (
<div className="mt-0.5 text-[10px] leading-snug text-blue-100/65">
{cpuSplit.join(' · ')}
</div>
) : null}
</div>
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-2 py-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-emerald-200/80">
<HardDrive className="size-3" />
Memory
</div>
<div className="mt-1 text-[14px] font-semibold text-emerald-100">
{rssLabel ?? 'unknown'}
</div>
<div className="mt-0.5 text-[10px] leading-snug text-emerald-100/65">summed RSS</div>
</div>
</div>
{detailParts.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{detailParts.map((part) => (
<span
key={part}
className="inline-flex items-center gap-1 rounded border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-1.5 py-0.5 text-[10px] leading-none text-[var(--color-text-muted)]"
>
<Layers3 className="size-2.5" />
{part}
</span>
))}
</div>
) : null}
{runtimeEntry.runtimeLoadScope === 'shared-host' ? (
<div className="flex gap-1.5 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/80">
<Server className="mt-0.5 size-3 shrink-0" />
Shared OpenCode host metric. It is not exclusive to this member.
</div>
) : null}
{runtimeEntry.runtimeLoadTruncated ? (
<div className="flex gap-1.5 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/80">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
Process tree was capped for this sample.
</div>
) : null}
<div className="flex gap-1.5 border-t border-[var(--color-border)] pt-2 text-[10px] leading-snug text-[var(--color-text-muted)]">
<Info className="mt-0.5 size-3 shrink-0" />
RSS can include shared pages, so it is best read as a load signal, not exclusive memory.
</div>
</div>
);
}
function buildTelemetryPoints(
samples: readonly TeamAgentRuntimeResourceSample[],
getValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined,
@ -194,9 +440,10 @@ function buildTelemetryPoints(
}
function buildRuntimeTelemetryPaths(
history: readonly TeamAgentRuntimeResourceSample[] | undefined
history: readonly TeamAgentRuntimeResourceSample[] | undefined,
scale?: RuntimeTelemetryScale
): RuntimeTelemetryPaths | undefined {
const samples = (history ?? []).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT);
const samples = normalizeRuntimeTelemetrySamples(history).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT);
if (samples.length < 2) {
return undefined;
}
@ -205,19 +452,38 @@ function buildRuntimeTelemetryPaths(
samples,
(sample) => sample.rssBytes,
(value, values) => {
const min = Math.min(...values);
const max = Math.max(...values);
const ratio = max > min ? (value - min) / (max - min) : 0.32;
return 15.25 - ratio * 4.4;
const cappedY = getCappedTelemetryY(value, scale?.memoryCapBytes, {
bottomY: 15.25,
amplitude: 4.4,
});
return (
cappedY ??
getRelativeTelemetryY(value, values, {
bottomY: 15.25,
amplitude: 4.4,
fallbackRatio: 0.32,
})
);
}
);
const cpuPoints = buildTelemetryPoints(
samples,
(sample) => sample.cpuPercent,
(value, values) => {
const max = Math.max(10, ...values);
const ratio = Math.min(1, value / max);
return 8.3 - ratio * 4.6;
const cappedY = getCappedTelemetryY(value, scale?.cpuCapPercent, {
bottomY: 16.1,
amplitude: 5.2,
curve: 'sqrt',
});
return (
cappedY ??
getRelativeTelemetryY(value, values, {
bottomY: 16.1,
amplitude: 5.2,
fallbackRatio: 0,
minimumSpan: 0.5,
})
);
}
);
@ -236,12 +502,16 @@ function buildRuntimeTelemetryPaths(
const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
runtimeEntry,
visible,
scale,
}: {
runtimeEntry?: TeamAgentRuntimeEntry;
visible: boolean;
scale?: RuntimeTelemetryScale;
}): React.JSX.Element | null {
const paths = useMemo(
() => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory),
[runtimeEntry?.resourceHistory]
() => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory, scale),
[runtimeEntry?.resourceHistory, scale]
);
if (!paths) {
return null;
@ -251,7 +521,16 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
<div
aria-hidden="true"
data-testid="member-runtime-telemetry-strip"
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-5 overflow-hidden rounded-b"
className={cn(
'pointer-events-none absolute inset-x-0 bottom-0 z-0 h-5 overflow-hidden rounded-b transition-opacity duration-150',
visible ? 'opacity-100' : 'opacity-0'
)}
style={{
WebkitMaskImage:
'linear-gradient(to right, transparent 0, black 44px, black calc(100% - 44px), transparent 100%)',
maskImage:
'linear-gradient(to right, transparent 0, black 44px, black calc(100% - 44px), transparent 100%)',
}}
>
<svg
className="size-full"
@ -259,7 +538,7 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
preserveAspectRatio="none"
>
{paths.memoryAreaPath ? (
<path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.22" />
<path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.14" />
) : null}
{paths.memoryLinePath ? (
<path
@ -269,7 +548,7 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.55"
opacity="0.68"
opacity="0.45"
/>
) : null}
{paths.cpuLinePath ? (
@ -280,17 +559,19 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.62"
opacity="0.78"
opacity="0.62"
/>
) : null}
</svg>
<div
className="absolute inset-x-0 bottom-0 h-2"
className="absolute inset-x-0 bottom-0 h-1.5"
style={{
background:
'linear-gradient(to top, color-mix(in srgb, var(--color-surface) 35%, transparent), transparent)',
}}
/>
<div className="absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-[var(--color-surface)] to-transparent opacity-80 blur-[1px]" />
<div className="absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-[var(--color-surface)] to-transparent opacity-80 blur-[1px]" />
</div>
);
});
@ -321,6 +602,8 @@ export const MemberCard = memo(function MemberCard({
spawnLaunchState,
spawnRuntimeAlive,
isLaunchSettling,
runtimeTelemetryVisible = false,
runtimeTelemetryScale,
onOpenTask,
onOpenReviewTask,
onClick,
@ -412,6 +695,47 @@ export const MemberCard = memo(function MemberCard({
: reviewTask
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
: undefined;
const runtimeTelemetryTitle = buildRuntimeTelemetryTitle(runtimeEntry);
const showRuntimeTelemetryTooltip = runtimeTelemetryVisible && Boolean(runtimeTelemetryTitle);
const rowTitle = showRuntimeTelemetryTooltip ? undefined : activityTitle;
const runtimeTelemetryTooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [runtimeTelemetryTooltipOpen, setRuntimeTelemetryTooltipOpen] = useState(false);
const clearRuntimeTelemetryTooltipTimer = useCallback(() => {
if (runtimeTelemetryTooltipTimerRef.current == null) {
return;
}
clearTimeout(runtimeTelemetryTooltipTimerRef.current);
runtimeTelemetryTooltipTimerRef.current = null;
}, []);
const handleRuntimeTelemetryTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
clearRuntimeTelemetryTooltipTimer();
if (!nextOpen) {
setRuntimeTelemetryTooltipOpen(false);
return;
}
if (runtimeTelemetryTooltipOpen) {
return;
}
runtimeTelemetryTooltipTimerRef.current = setTimeout(() => {
runtimeTelemetryTooltipTimerRef.current = null;
setRuntimeTelemetryTooltipOpen(true);
}, 1000);
},
[clearRuntimeTelemetryTooltipTimer, runtimeTelemetryTooltipOpen]
);
useEffect(
() => () => {
clearRuntimeTelemetryTooltipTimer();
},
[clearRuntimeTelemetryTooltipTimer]
);
useEffect(() => {
if (!showRuntimeTelemetryTooltip) {
clearRuntimeTelemetryTooltipTimer();
setRuntimeTelemetryTooltipOpen(false);
}
}, [clearRuntimeTelemetryTooltipTimer, showRuntimeTelemetryTooltip]);
const showStartingSkeleton =
!isRemoved &&
presenceLabel === 'starting' &&
@ -439,15 +763,21 @@ export const MemberCard = memo(function MemberCard({
teamName: selectedTeamName,
runId: runtimeRunId,
memberName: member.name,
member,
spawnStatus,
launchState: spawnLaunchState,
livenessSource: spawnLivenessSource,
spawnEntry,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
runtimeAdvisoryLabel,
runtimeAdvisoryTitle,
}),
[
member.name,
member,
runtimeEntry,
runtimeAdvisoryLabel,
runtimeAdvisoryTitle,
runtimeRunId,
selectedTeamName,
spawnEntry,
@ -460,6 +790,11 @@ export const MemberCard = memo(function MemberCard({
!isRemoved &&
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const showRuntimeAdvisoryDiagnostics =
!isRemoved &&
Boolean(runtimeAdvisoryLabel) &&
runtimeAdvisoryTone === 'error' &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
const isSkippedLaunch =
spawnStatus === 'skipped' ||
@ -503,13 +838,26 @@ export const MemberCard = memo(function MemberCard({
!isFailedLaunch &&
!isSkippedLaunch &&
(Boolean(activityTask) || !isAwaitingReply);
const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate';
const restartActionBusyLabel = canRelaunchOpenCode
? 'Relaunching OpenCode teammate'
: 'Retrying teammate';
const restartActionErrorFallback = canRelaunchOpenCode
? 'Failed to relaunch OpenCode teammate'
: 'Failed to retry teammate';
const canRelaunchRuntimeAdvisoryOpenCode =
Boolean(runtimeAdvisoryLabel) &&
runtimeAdvisoryTone === 'error' &&
member.providerId === 'opencode' &&
hasRestartMemberControl &&
!showLaunchBadge &&
!isFailedLaunch &&
!isSkippedLaunch;
const restartActionIdleLabel =
canRelaunchOpenCode || canRelaunchRuntimeAdvisoryOpenCode
? 'Relaunch OpenCode'
: 'Retry teammate';
const restartActionBusyLabel =
canRelaunchOpenCode || canRelaunchRuntimeAdvisoryOpenCode
? 'Relaunching OpenCode teammate'
: 'Retrying teammate';
const restartActionErrorFallback =
canRelaunchOpenCode || canRelaunchRuntimeAdvisoryOpenCode
? 'Failed to relaunch OpenCode teammate'
: 'Failed to retry teammate';
const handleRestartMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
@ -545,7 +893,7 @@ export const MemberCard = memo(function MemberCard({
}
};
return (
const cardContent = (
<div
className={cn(
'rounded transition-opacity duration-300',
@ -560,7 +908,7 @@ export const MemberCard = memo(function MemberCard({
rowSurfaceBleedClass
)}
style={undefined}
title={activityTitle}
title={rowTitle}
role="button"
tabIndex={0}
onClick={onClick}
@ -571,7 +919,13 @@ export const MemberCard = memo(function MemberCard({
}
}}
>
{!isRemoved ? <MemberRuntimeTelemetryStrip runtimeEntry={runtimeEntry} /> : null}
{!isRemoved ? (
<MemberRuntimeTelemetryStrip
runtimeEntry={runtimeEntry}
visible={runtimeTelemetryVisible}
scale={runtimeTelemetryScale}
/>
) : null}
<div className="pointer-events-none absolute inset-0 z-10 rounded transition-colors group-hover:bg-white/5" />
<div className="relative z-20 flex items-center gap-2.5">
<div className="relative shrink-0">
@ -662,6 +1016,39 @@ export const MemberCard = memo(function MemberCard({
>
{runtimeAdvisoryLabel ?? 'awaiting reply'}
</span>
{canRelaunchRuntimeAdvisoryOpenCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch
? `${restartActionBusyLabel}...`
: restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
{showRuntimeAdvisoryDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
</>
) : null}
</div>
@ -869,31 +1256,62 @@ export const MemberCard = memo(function MemberCard({
) : null}
</span>
) : showRuntimeAdvisoryBadge ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle
className={`size-3.5 shrink-0 ${
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
}`}
/>
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
runtimeAdvisoryTone === 'error'
? 'bg-red-500/15 text-red-300'
: 'bg-amber-500/15 text-amber-300'
}`}
title={runtimeAdvisoryTitle}
>
{runtimeAdvisoryLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
</TooltipContent>
</Tooltip>
<span className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle
className={`size-3.5 shrink-0 ${
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
}`}
/>
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
runtimeAdvisoryTone === 'error'
? 'bg-red-500/15 text-red-300'
: 'bg-amber-500/15 text-amber-300'
}`}
title={runtimeAdvisoryTitle}
>
{runtimeAdvisoryLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
</TooltipContent>
</Tooltip>
{canRelaunchRuntimeAdvisoryOpenCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
{showRuntimeAdvisoryDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
</span>
) : !activityTask ? (
<Badge
variant="secondary"
@ -977,4 +1395,26 @@ export const MemberCard = memo(function MemberCard({
</div>
</div>
);
if (!showRuntimeTelemetryTooltip) {
return cardContent;
}
return (
<Tooltip
delayDuration={0}
open={runtimeTelemetryTooltipOpen}
onOpenChange={handleRuntimeTelemetryTooltipOpenChange}
>
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
<TooltipContent
side="top"
align="start"
sideOffset={8}
className="border-blue-400/20 bg-[var(--color-surface)] p-3 shadow-xl shadow-black/30"
>
<RuntimeTelemetryTooltipContent runtimeEntry={runtimeEntry} />
</TooltipContent>
</Tooltip>
);
});

View file

@ -12,7 +12,7 @@ import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { MemberCard } from './MemberCard';
import { MemberCard, type RuntimeTelemetryScale } from './MemberCard';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
@ -25,6 +25,7 @@ import type {
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamAgentRuntimeResourceSample,
TeamTaskWithKanban,
} from '@shared/types';
@ -44,6 +45,8 @@ interface MemberListProps {
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
launchParams?: TeamLaunchParams;
runtimeTelemetryVisible?: boolean;
onRuntimeTelemetryHoverChange?: (visible: boolean) => void;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
@ -264,6 +267,32 @@ function areLaunchParamsEquivalent(
);
}
function isRuntimeResourceSampleLike(value: unknown): value is TeamAgentRuntimeResourceSample {
return Boolean(value) && typeof value === 'object';
}
function areRuntimeResourceSamplesEquivalent(left: unknown, right: unknown): boolean {
if (left === right) return true;
if (!isRuntimeResourceSampleLike(left) || !isRuntimeResourceSampleLike(right)) {
return false;
}
return (
left.timestamp === right.timestamp &&
left.cpuPercent === right.cpuPercent &&
left.rssBytes === right.rssBytes &&
left.primaryCpuPercent === right.primaryCpuPercent &&
left.primaryRssBytes === right.primaryRssBytes &&
left.childCpuPercent === right.childCpuPercent &&
left.childRssBytes === right.childRssBytes &&
left.processCount === right.processCount &&
left.runtimeLoadScope === right.runtimeLoadScope &&
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
left.pidSource === right.pidSource &&
left.pid === right.pid &&
left.runtimePid === right.runtimePid
);
}
function areMemberRuntimeEntriesEquivalent(
left: Map<string, TeamAgentRuntimeEntry> | undefined,
right: Map<string, TeamAgentRuntimeEntry> | undefined
@ -273,10 +302,15 @@ function areMemberRuntimeEntriesEquivalent(
if (left.size !== right.size) return false;
for (const [key, leftEntry] of left) {
const rightEntry = right.get(key);
const leftDiagnostics = leftEntry.diagnostics ?? [];
const rightDiagnostics = rightEntry?.diagnostics ?? [];
const leftResourceHistory = leftEntry.resourceHistory ?? [];
const rightResourceHistory = rightEntry?.resourceHistory ?? [];
const leftDiagnostics = Array.isArray(leftEntry.diagnostics) ? leftEntry.diagnostics : [];
const rightDiagnostics = Array.isArray(rightEntry?.diagnostics) ? rightEntry.diagnostics : [];
const rightResourceHistoryCandidate = rightEntry?.resourceHistory;
const leftResourceHistory = Array.isArray(leftEntry.resourceHistory)
? leftEntry.resourceHistory
: [];
const rightResourceHistory = Array.isArray(rightResourceHistoryCandidate)
? rightResourceHistoryCandidate
: [];
if (
leftEntry.memberName !== rightEntry?.memberName ||
leftEntry.alive !== rightEntry?.alive ||
@ -290,6 +324,13 @@ function areMemberRuntimeEntriesEquivalent(
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
leftEntry.cpuPercent !== rightEntry?.cpuPercent ||
leftEntry.primaryCpuPercent !== rightEntry?.primaryCpuPercent ||
leftEntry.primaryRssBytes !== rightEntry?.primaryRssBytes ||
leftEntry.childCpuPercent !== rightEntry?.childCpuPercent ||
leftEntry.childRssBytes !== rightEntry?.childRssBytes ||
leftEntry.processCount !== rightEntry?.processCount ||
leftEntry.runtimeLoadScope !== rightEntry?.runtimeLoadScope ||
leftEntry.runtimeLoadTruncated !== rightEntry?.runtimeLoadTruncated ||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
leftEntry.pidSource !== rightEntry?.pidSource ||
leftEntry.processCommand !== rightEntry?.processCommand ||
@ -305,17 +346,9 @@ function areMemberRuntimeEntriesEquivalent(
leftDiagnostics.length !== rightDiagnostics.length ||
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ||
leftResourceHistory.length !== rightResourceHistory.length ||
!leftResourceHistory.every((value, index) => {
const other = rightResourceHistory[index];
return (
value.timestamp === other?.timestamp &&
value.cpuPercent === other?.cpuPercent &&
value.rssBytes === other?.rssBytes &&
value.pidSource === other?.pidSource &&
value.pid === other?.pid &&
value.runtimePid === other?.runtimePid
);
})
!leftResourceHistory.every((value, index) =>
areRuntimeResourceSamplesEquivalent(value, rightResourceHistory[index])
)
) {
return false;
}
@ -323,6 +356,95 @@ function areMemberRuntimeEntriesEquivalent(
return true;
}
function isFiniteNonNegative(value: number | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
function percentile(values: readonly number[], percentileValue: number): number | undefined {
if (values.length === 0) {
return undefined;
}
const sorted = [...values].sort((a, b) => a - b);
const rank = (sorted.length - 1) * percentileValue;
const lowerIndex = Math.floor(rank);
const upperIndex = Math.ceil(rank);
const lower = sorted[lowerIndex];
const upper = sorted[upperIndex];
if (lower == null || upper == null) {
return sorted[sorted.length - 1];
}
if (lowerIndex === upperIndex) {
return lower;
}
return lower + (upper - lower) * (rank - lowerIndex);
}
function collectRuntimeTelemetryValues(
entry: TeamAgentRuntimeEntry | undefined,
getSampleValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined,
currentValue: number | undefined
): { historyValues: number[]; currentValues: number[] } {
const history = Array.isArray(entry?.resourceHistory) ? entry.resourceHistory : [];
const historyValues = history.flatMap((sample) => {
if (!isRuntimeResourceSampleLike(sample)) {
return [];
}
const value = getSampleValue(sample);
return isFiniteNonNegative(value) ? [value] : [];
});
const currentValues = isFiniteNonNegative(currentValue) ? [currentValue] : [];
return { historyValues, currentValues };
}
function buildRuntimeTelemetryScale(
members: readonly ResolvedTeamMember[],
runtimeEntries: Map<string, TeamAgentRuntimeEntry> | undefined
): RuntimeTelemetryScale | undefined {
if (!runtimeEntries || members.length === 0) {
return undefined;
}
const memoryHistoryValues: number[] = [];
const memoryCurrentValues: number[] = [];
const cpuHistoryValues: number[] = [];
const cpuCurrentValues: number[] = [];
for (const member of members) {
const runtimeEntry = runtimeEntries.get(member.name);
const memoryValues = collectRuntimeTelemetryValues(
runtimeEntry,
(sample) => sample.rssBytes,
runtimeEntry?.rssBytes
);
memoryHistoryValues.push(...memoryValues.historyValues);
memoryCurrentValues.push(...memoryValues.currentValues);
const cpuValues = collectRuntimeTelemetryValues(
runtimeEntry,
(sample) => sample.cpuPercent,
runtimeEntry?.cpuPercent
);
cpuHistoryValues.push(...cpuValues.historyValues);
cpuCurrentValues.push(...cpuValues.currentValues);
}
const memoryP95 = percentile(memoryHistoryValues, 0.95);
const memoryCurrentMax =
memoryCurrentValues.length > 0 ? Math.max(...memoryCurrentValues) : undefined;
const memoryReference = Math.max(memoryP95 ?? 0, memoryCurrentMax ?? 0);
const cpuP95 = percentile(cpuHistoryValues, 0.95);
const cpuCurrentMax = cpuCurrentValues.length > 0 ? Math.max(...cpuCurrentValues) : undefined;
const cpuReference = Math.max(cpuP95 ?? 0, cpuCurrentMax ?? 0);
const hasCpuValues = cpuHistoryValues.length > 0 || cpuCurrentValues.length > 0;
const scale: RuntimeTelemetryScale = {
...(memoryReference > 0 ? { memoryCapBytes: memoryReference * 1.1 } : {}),
...(hasCpuValues ? { cpuCapPercent: Math.max(25, cpuReference) } : {}),
};
return scale.memoryCapBytes != null || scale.cpuCapPercent != null ? scale : undefined;
}
function areMemberListPropsEqual(
prev: Readonly<MemberListProps>,
next: Readonly<MemberListProps>
@ -342,6 +464,8 @@ function areMemberListPropsEqual(
prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning &&
prev.leadActivity === next.leadActivity &&
prev.runtimeTelemetryVisible === next.runtimeTelemetryVisible &&
prev.onRuntimeTelemetryHoverChange === next.onRuntimeTelemetryHoverChange &&
prev.onRestartMember === next.onRestartMember &&
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
@ -378,6 +502,8 @@ interface MemberCardRowProps {
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
isLaunchSettling?: boolean;
runtimeTelemetryVisible?: boolean;
runtimeTelemetryScale?: RuntimeTelemetryScale;
onOpenTask?: (taskId: string) => void;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
@ -412,6 +538,8 @@ const MemberCardRow = memo(function MemberCardRow({
isTeamProvisioning,
leadActivity,
isLaunchSettling,
runtimeTelemetryVisible,
runtimeTelemetryScale,
onOpenTask,
onMemberClick,
onSendMessage,
@ -461,6 +589,8 @@ const MemberCardRow = memo(function MemberCardRow({
spawnLaunchState={spawnLaunchState}
spawnRuntimeAlive={spawnRuntimeAlive}
isLaunchSettling={isLaunchSettling}
runtimeTelemetryVisible={runtimeTelemetryVisible}
runtimeTelemetryScale={runtimeTelemetryScale}
onOpenTask={currentTask ? handleOpenTask : undefined}
onOpenReviewTask={reviewTask ? handleOpenReviewTask : undefined}
onClick={handleClick}
@ -584,6 +714,8 @@ export const MemberList = memo(function MemberList({
isTeamProvisioning,
leadActivity,
launchParams,
runtimeTelemetryVisible = false,
onRuntimeTelemetryHoverChange,
onMemberClick,
onSendMessage,
onAssignTask,
@ -610,6 +742,14 @@ export const MemberList = memo(function MemberList({
return () => observer.disconnect();
}, [handleResize]);
const handleRuntimeTelemetryMouseEnter = useCallback(() => {
onRuntimeTelemetryHoverChange?.(true);
}, [onRuntimeTelemetryHoverChange]);
const handleRuntimeTelemetryMouseLeave = useCallback(() => {
onRuntimeTelemetryHoverChange?.(false);
}, [onRuntimeTelemetryHoverChange]);
const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
const activeMembers = useMemo(
() =>
@ -628,6 +768,10 @@ export const MemberList = memo(function MemberList({
[activeMembers]
);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const runtimeTelemetryScale = useMemo(
() => buildRuntimeTelemetryScale(activeMembers, memberRuntimeEntries),
[activeMembers, memberRuntimeEntries]
);
// Pre-compute reviewer->task map to avoid O(n*n) scan per member.
const reviewTaskByMember = useMemo(() => {
const result = new Map<string, TeamTaskWithKanban>();
@ -797,7 +941,12 @@ export const MemberList = memo(function MemberList({
}
return (
<div ref={containerRef} className="flex flex-col gap-1">
<div
ref={containerRef}
className="flex flex-col gap-1"
onMouseEnter={handleRuntimeTelemetryMouseEnter}
onMouseLeave={handleRuntimeTelemetryMouseLeave}
>
<div className={gridClass}>
{activeMembers.map((member) => {
const spawnEntry = memberSpawnStatuses?.get(member.name);
@ -868,6 +1017,8 @@ export const MemberList = memo(function MemberList({
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity}
isLaunchSettling={isLaunchSettling}
runtimeTelemetryVisible={runtimeTelemetryVisible}
runtimeTelemetryScale={runtimeTelemetryScale}
onOpenTask={onOpenTask}
onMemberClick={onMemberClick}
onSendMessage={onSendMessage}
@ -912,6 +1063,8 @@ export const MemberList = memo(function MemberList({
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity}
isLaunchSettling={false}
runtimeTelemetryVisible={runtimeTelemetryVisible}
runtimeTelemetryScale={runtimeTelemetryScale}
onOpenTask={onOpenTask}
onMemberClick={onMemberClick}
onSendMessage={onSendMessage}

View file

@ -178,7 +178,9 @@ export function reconcilePendingRepliesByMember(
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
const latestReplyAt = latestReplyToUserByMember.get(memberName);
const latestDurableSendAt = latestUserSentByMember.get(memberName);
const threshold = latestDurableSendAt ?? sentAtMs;
// Do not let an older persisted send make a previous reply clear a fresh optimistic wait.
const threshold =
latestDurableSendAt == null ? sentAtMs : Math.max(latestDurableSendAt, sentAtMs);
if (latestReplyAt != null && latestReplyAt > threshold) {
changed = true;
continue;

View file

@ -109,7 +109,21 @@ export const ScopeWarningBanner = ({
: 'Needs review',
}
: null;
const config = ledgerConfig ?? TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
const workIntervalConfig: TierConfig | null =
sourceKind !== 'ledger' && confidence.reason.toLowerCase().includes('workinterval')
? {
Icon: Info,
border: 'border-blue-500/15',
bg: 'bg-blue-500/5',
accentColor: 'text-blue-400',
title: 'Scoped by persisted work interval',
detail:
'The task start marker was not available in the session log, so the diff is scoped by the task work interval stored on the board.',
badgeLabel: 'Interval scoped',
}
: null;
const config =
ledgerConfig ?? workIntervalConfig ?? TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
const { Icon } = config;
return (

View file

@ -8,20 +8,22 @@ const DEFAULT_SPIN_DURATION_MS = 1000;
export type SyncedLoader2Props = ComponentProps<typeof Loader2> & {
spinDurationMs?: number;
spinning?: boolean;
};
export const SyncedLoader2 = ({
className,
style,
spinDurationMs = DEFAULT_SPIN_DURATION_MS,
spinning = true,
...props
}: SyncedLoader2Props): React.JSX.Element => {
const syncedStyle = useSyncedAnimationStyle(true, spinDurationMs);
const syncedStyle = useSyncedAnimationStyle(spinning, spinDurationMs);
return (
<Loader2
{...props}
className={cn('animate-spin', className)}
className={cn(spinning && 'animate-spin', className)}
style={{ ...syncedStyle, ...style }}
/>
);

View file

@ -18,7 +18,7 @@ declare global {
}
}
// Sentry must be initialised before React renders.
// Prepare Sentry before React renders. Actual init waits for telemetry config.
initSentryRenderer();
let root: ReactDOM.Root | null = null;

View file

@ -10,20 +10,78 @@
import * as SentryElectron from '@sentry/electron/renderer';
import { browserTracingIntegration as reactBrowserTracing, init as reactInit } from '@sentry/react';
import {
filterSafeSentryIntegrations,
isValidDsn,
redactSentryEvent,
SENTRY_ENVIRONMENT,
SENTRY_RELEASE,
TRACES_SAMPLE_RATE,
} from '@shared/utils/sentryConfig';
import type { ElectronAPI } from '@shared/types/api';
// ---------------------------------------------------------------------------
// Telemetry gate (mirrors src/main/sentry.ts pattern)
// ---------------------------------------------------------------------------
// Defaults to `true` so early renderer crashes are captured.
// Synced to user's telemetryEnabled preference via syncRendererTelemetry().
let telemetryAllowed = true;
// Start closed until persisted config is loaded through the store.
let telemetryAllowed = false;
let initialized = false;
let telemetryIdentitySyncToken = 0;
function getElectronApi(): ElectronAPI | undefined {
return (window as Window & { electronAPI?: ElectronAPI }).electronAPI;
}
function clearRendererSentryUser(): void {
if (!initialized) return;
SentryElectron.setUser?.(null);
}
async function syncRendererTelemetryIdentity(): Promise<void> {
const syncToken = ++telemetryIdentitySyncToken;
if (!initialized || !telemetryAllowed) {
return;
}
const getSentryContext = getElectronApi()?.telemetry?.getSentryContext;
if (!getSentryContext) {
return;
}
try {
const context = await getSentryContext();
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
return;
}
if (!context) {
SentryElectron.setUser?.(null);
return;
}
SentryElectron.setUser?.({ id: context.userId });
SentryElectron.setTags?.(context.tags);
} catch {
if (syncToken === telemetryIdentitySyncToken) {
SentryElectron.setUser?.(null);
}
}
}
function getSafeRendererErrorContext(
context?: Record<string, unknown>
): Record<string, unknown> | null {
if (!context) {
return null;
}
return {
activeTabType: typeof context.activeTabType === 'string' ? context.activeTabType : null,
hasComponentStack:
typeof context.componentStack === 'string' && context.componentStack.length > 0,
};
}
/**
* Sync the opt-in flag from config. Call after config is loaded
@ -31,13 +89,18 @@ let initialized = false;
*/
export function syncRendererTelemetry(enabled: boolean): void {
telemetryAllowed = enabled;
if (!enabled && initialized && typeof SentryElectron.setUser === 'function') {
SentryElectron.setUser(null);
if (!enabled) {
telemetryIdentitySyncToken++;
clearRendererSentryUser();
return;
}
initSentryRenderer();
void syncRendererTelemetryIdentity();
}
export function initSentryRenderer(): void {
if (initialized) return;
if (initialized || !telemetryAllowed) return;
const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined;
if (!isValidDsn(dsn)) return;
@ -51,31 +114,39 @@ export function initSentryRenderer(): void {
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cross-version @sentry/core type mismatch
const beforeSend = (event: any): any => (telemetryAllowed ? event : null);
const beforeSend = (event: any): any => (telemetryAllowed ? redactSentryEvent(event) : null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cross-version @sentry/core type mismatch
const beforeSendTransaction = (event: any): any => (telemetryAllowed ? event : null);
const beforeSendTransaction = (event: any): any =>
telemetryAllowed ? redactSentryEvent(event) : null;
if (window.electronAPI) {
// Electron renderer uses IPC transport to main process.
if (getElectronApi()) {
// Electron renderer - uses IPC transport to main process.
// browserTracingIntegration from @sentry/electron/renderer to avoid
// @sentry/core version mismatch with @sentry/react.
SentryElectron.init({
...baseOptions,
beforeSend,
beforeSendTransaction,
integrations: [SentryElectron.browserTracingIntegration()],
integrations: (integrations) => [
...filterSafeSentryIntegrations(integrations),
SentryElectron.browserTracingIntegration(),
],
});
} else {
// Standalone browser mode — direct HTTP transport
// Standalone browser mode - direct HTTP transport
reactInit({
...baseOptions,
beforeSend,
beforeSendTransaction,
integrations: [reactBrowserTracing()],
integrations: (integrations) => [
...filterSafeSentryIntegrations(integrations),
reactBrowserTracing(),
],
});
}
initialized = true;
void syncRendererTelemetryIdentity();
}
/** Whether the renderer SDK was successfully initialised. */
@ -88,11 +159,11 @@ export function isSentryRendererActive(): boolean {
// ---------------------------------------------------------------------------
/** Record a navigation breadcrumb (tab switches). */
export function addNavigationBreadcrumb(from: string, to: string): void {
export function addNavigationBreadcrumb(_from: string, _to: string): void {
if (!initialized) return;
SentryElectron.addBreadcrumb({
category: 'navigation',
message: `Tab: ${from}${to}`,
message: 'tab-change',
level: 'info',
});
}
@ -101,17 +172,18 @@ export function addNavigationBreadcrumb(from: string, to: string): void {
export function addRendererBreadcrumb(
category: string,
message: string,
data?: Record<string, unknown>
_data?: Record<string, unknown>
): void {
if (!initialized) return;
SentryElectron.addBreadcrumb({ category, message, data, level: 'info' });
SentryElectron.addBreadcrumb({ category, message, level: 'info' });
}
/** Capture an exception with optional extra context. */
export function captureRendererException(error: Error, context?: Record<string, unknown>): void {
if (!initialized) return;
SentryElectron.withScope((scope) => {
if (context) scope.setContext('react', context);
const safeContext = getSafeRendererErrorContext(context);
if (safeContext) scope.setContext('react', safeContext);
SentryElectron.captureException(error);
});
}

View file

@ -3,6 +3,7 @@
*/
import { api } from '@renderer/api';
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
@ -25,32 +26,29 @@ const OPENCODE_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
const CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3;
const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = [
'anthropic',
'codex',
'gemini',
'opencode',
];
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = isGeminiUiFrozen()
? ['anthropic', 'codex', 'opencode']
: ['anthropic', 'codex', 'gemini', 'opencode'];
const MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(MULTIMODEL_PROVIDER_IDS);
function isActiveMultimodelProviderId(providerId: CliProviderId): boolean {
return MULTIMODEL_PROVIDER_ID_SET.has(providerId);
}
export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
const providers: CliProviderStatus[] = (
[
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
{ providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
] as const
).map((provider) => ({
...provider,
const providers: CliProviderStatus[] = MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
providerId,
displayName: getProviderDisplayName(providerId),
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown' as const,
modelVerificationState: 'idle' as const,
modelCatalogRefreshState: 'idle' as const,
statusMessage: 'Checking...',
models: [],
modelAvailability: [],
canLoginFromUi: provider.providerId !== 'opencode',
canLoginFromUi: providerId !== 'opencode',
capabilities: {
teamLaunch: false,
oneShot: false,
@ -78,7 +76,11 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
};
}
function isModelOnlyFallbackProviderStatus(provider: CliProviderStatus): boolean {
function isModelOnlyFallbackProviderStatus(provider: CliProviderStatus | undefined): boolean {
if (!provider) {
return false;
}
return (
provider.supported === false &&
provider.authenticated === false &&
@ -172,6 +174,25 @@ function shouldPreserveCurrentProviderStatus(
);
}
function mergeProviderCatalogCache(
incomingProvider: CliProviderStatus,
currentProvider: CliProviderStatus
): CliProviderStatus {
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
return {
...incomingProvider,
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
modelCatalog,
modelCatalogRefreshState:
modelCatalog && incomingRefreshState !== 'error'
? 'ready'
: (incomingRefreshState ?? currentProvider.modelCatalogRefreshState),
runtimeCapabilities:
incomingProvider.runtimeCapabilities ?? currentProvider.runtimeCapabilities ?? null,
};
}
export function getIncompleteMultimodelProviderIds(
status: CliInstallationStatus | null
): CliProviderId[] {
@ -180,7 +201,11 @@ export function getIncompleteMultimodelProviderIds(
}
return status.providers
.filter((provider) => !isHydratedMultimodelProviderStatus(provider))
.filter(
(provider) =>
isActiveMultimodelProviderId(provider.providerId) &&
!isHydratedMultimodelProviderStatus(provider)
)
.map((provider) => provider.providerId);
}
@ -192,7 +217,11 @@ export function getModelOnlyFallbackProviderIds(
}
return status.providers
.filter((provider) => isModelOnlyFallbackProviderStatus(provider))
.filter(
(provider) =>
isActiveMultimodelProviderId(provider.providerId) &&
isModelOnlyFallbackProviderStatus(provider)
)
.map((provider) => provider.providerId);
}
@ -205,12 +234,20 @@ export function reconcileMultimodelProviderLoading(
}
const incompleteProviderIds = new Set(getIncompleteMultimodelProviderIds(status));
return status.providers.reduce<Partial<Record<CliProviderId, boolean>>>(
(nextLoading, provider) => ({
...nextLoading,
[provider.providerId]: incompleteProviderIds.has(provider.providerId),
}),
{ ...currentLoading }
const providersById = new Map(
status.providers.map((provider) => [provider.providerId, provider])
);
return MULTIMODEL_PROVIDER_IDS.reduce<Partial<Record<CliProviderId, boolean>>>(
(nextLoading, providerId) => {
const provider = providersById.get(providerId);
return {
...nextLoading,
[providerId]: provider
? incompleteProviderIds.has(providerId)
: currentLoading[providerId] === true,
};
},
{}
);
}
@ -309,6 +346,7 @@ function areProviderStatusContentEqual(a: CliProviderStatus, b: CliProviderStatu
a.authMethod === b.authMethod &&
a.verificationState === b.verificationState &&
(a.modelVerificationState ?? null) === (b.modelVerificationState ?? null) &&
(a.modelCatalogRefreshState ?? null) === (b.modelCatalogRefreshState ?? null) &&
(a.statusMessage ?? null) === (b.statusMessage ?? null) &&
(a.detailMessage ?? null) === (b.detailMessage ?? null) &&
a.canLoginFromUi === b.canLoginFromUi &&
@ -374,7 +412,7 @@ export function mergeCliStatusPreservingHydratedProviders(
return incomingProvider;
}
if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
return currentProvider;
return mergeProviderCatalogCache(incomingProvider, currentProvider);
}
// Preserve the current reference when content is identical so the
// providers array stays reference-stable across steady-state IPC polls.
@ -387,13 +425,14 @@ export function mergeCliStatusPreservingHydratedProviders(
for (const currentProvider of current.providers) {
if (
!incomingProviderIds.has(currentProvider.providerId) &&
isActiveMultimodelProviderId(currentProvider.providerId) &&
isHydratedMultimodelProviderStatus(currentProvider)
) {
providers.push(currentProvider);
}
}
const authenticatedProvider = providers.find((provider) => provider.authenticated) ?? null;
const authenticatedProvider = getAuthenticatedProvider(providers);
const mergedProviders = areArraysEqual(providers, current.providers, Object.is)
? current.providers
@ -402,7 +441,9 @@ export function mergeCliStatusPreservingHydratedProviders(
const merged: CliInstallationStatus = {
...incoming,
providers: mergedProviders,
authLoggedIn: mergedProviders.some((provider) => provider.authenticated),
authLoggedIn: mergedProviders.some(
(provider) => isActiveMultimodelProviderId(provider.providerId) && provider.authenticated
),
authMethod: authenticatedProvider?.authMethod ?? null,
};
@ -468,11 +509,15 @@ function isMultimodelCliStatus(
function hasActiveProviderStatusLoading(
providerLoading: Partial<Record<CliProviderId, boolean>>
): boolean {
return Object.values(providerLoading).some((loading) => loading === true);
return MULTIMODEL_PROVIDER_IDS.some((providerId) => providerLoading[providerId] === true);
}
function getAuthenticatedProvider(providers: CliProviderStatus[]): CliProviderStatus | null {
return providers.find((provider) => provider.authenticated) ?? null;
return (
providers.find(
(provider) => isActiveMultimodelProviderId(provider.providerId) && provider.authenticated
) ?? null
);
}
function buildMultimodelCliAuthState(params: {
@ -485,7 +530,9 @@ function buildMultimodelCliAuthState(params: {
const authenticatedProvider = getAuthenticatedProvider(providers);
return {
authLoggedIn: providers.some((provider) => provider.authenticated),
authLoggedIn: providers.some(
(provider) => isActiveMultimodelProviderId(provider.providerId) && provider.authenticated
),
authMethod: authenticatedProvider?.authMethod ?? null,
authStatusChecking: params.status.installed && hasActiveProviderStatusLoading(providerLoading),
};
@ -513,7 +560,27 @@ function createProviderStatusErrorSnapshot(params: {
params.currentProvider ??
createLoadingMultimodelCliStatus().providers.find(
(provider) => provider.providerId === params.providerId
)!;
) ??
({
providerId: params.providerId,
displayName: getProviderDisplayName(params.providerId),
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
modelCatalogRefreshState: 'idle',
statusMessage: 'Checking...',
models: [],
modelAvailability: [],
canLoginFromUi: params.providerId !== 'opencode',
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
backend: null,
} satisfies CliProviderStatus);
return {
...currentProvider,
@ -522,6 +589,7 @@ function createProviderStatusErrorSnapshot(params: {
authenticated: false,
authMethod: null,
verificationState: 'error',
modelCatalogRefreshState: 'error',
statusMessage: params.message,
detailMessage: null,
};
@ -764,6 +832,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
});
if (status.installed) {
for (const provider of status.providers) {
if (!isActiveMultimodelProviderId(provider.providerId)) {
continue;
}
void get().fetchCliProviderStatus(provider.providerId, {
silent: true,
epoch,
@ -848,12 +919,30 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
}
const settledCliStatus: CliInstallationStatus = currentCliStatus;
if (
isMultimodelCliStatus(settledCliStatus) &&
!isActiveMultimodelProviderId(providerId)
) {
return {
cliProviderStatusLoading: nextLoading,
cliStatus: {
...settledCliStatus,
...buildMultimodelCliAuthState({
status: settledCliStatus,
providerLoading: nextLoading,
}),
},
};
}
const hasProvider = settledCliStatus.providers.some(
(provider) => provider.providerId === providerId
);
const nextProviders = hasProvider
? settledCliStatus.providers.map((provider) =>
provider.providerId === providerId ? providerStatus : provider
provider.providerId === providerId
? mergeProviderCatalogCache(providerStatus, provider)
: provider
)
: [...settledCliStatus.providers, providerStatus];
const nextCliStatus = isMultimodelCliStatus(settledCliStatus)
@ -906,6 +995,22 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
}
const settledCliStatus: CliInstallationStatus = currentCliStatus;
if (
isMultimodelCliStatus(settledCliStatus) &&
!isActiveMultimodelProviderId(providerId)
) {
return {
cliProviderStatusLoading: nextLoading,
cliStatus: {
...settledCliStatus,
...buildMultimodelCliAuthState({
status: settledCliStatus,
providerLoading: nextLoading,
}),
},
};
}
const currentProvider =
settledCliStatus.providers.find((provider) => provider.providerId === providerId) ??
undefined;

View file

@ -62,6 +62,7 @@ import type {
TaskChangePresenceState,
TaskComment,
TeamAgentRuntimeEntry,
TeamAgentRuntimeResourceSample,
TeamAgentRuntimeSnapshot,
TeamCreateRequest,
TeamGetDataOptions,
@ -978,16 +979,44 @@ function maybeLogMemberSpawnUiEqualSuppressed(
);
}
function isTeamAgentRuntimeResourceSampleLike(
value: unknown
): value is TeamAgentRuntimeResourceSample {
return Boolean(value) && typeof value === 'object';
}
function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) {
return false;
}
return (
left.timestamp === right.timestamp &&
left.cpuPercent === right.cpuPercent &&
left.rssBytes === right.rssBytes &&
left.primaryCpuPercent === right.primaryCpuPercent &&
left.primaryRssBytes === right.primaryRssBytes &&
left.childCpuPercent === right.childCpuPercent &&
left.childRssBytes === right.childRssBytes &&
left.processCount === right.processCount &&
left.runtimeLoadScope === right.runtimeLoadScope &&
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
left.pidSource === right.pidSource &&
left.pid === right.pid &&
left.runtimePid === right.runtimePid
);
}
function areTeamAgentRuntimeEntriesEqual(
left: TeamAgentRuntimeEntry | undefined,
right: TeamAgentRuntimeEntry | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftDiagnostics = left.diagnostics ?? [];
const rightDiagnostics = right.diagnostics ?? [];
const leftResourceHistory = left.resourceHistory ?? [];
const rightResourceHistory = right.resourceHistory ?? [];
const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : [];
const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : [];
const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : [];
const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : [];
return (
left.memberName === right.memberName &&
left.alive === right.alive &&
@ -1001,6 +1030,13 @@ function areTeamAgentRuntimeEntriesEqual(
left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes &&
left.cpuPercent === right.cpuPercent &&
left.primaryCpuPercent === right.primaryCpuPercent &&
left.primaryRssBytes === right.primaryRssBytes &&
left.childCpuPercent === right.childCpuPercent &&
left.childRssBytes === right.childRssBytes &&
left.processCount === right.processCount &&
left.runtimeLoadScope === right.runtimeLoadScope &&
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
left.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource &&
left.processCommand === right.processCommand &&
@ -1016,17 +1052,9 @@ function areTeamAgentRuntimeEntriesEqual(
leftDiagnostics.length === rightDiagnostics.length &&
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
leftResourceHistory.length === rightResourceHistory.length &&
leftResourceHistory.every((value, index) => {
const other = rightResourceHistory[index];
return (
value.timestamp === other?.timestamp &&
value.cpuPercent === other?.cpuPercent &&
value.rssBytes === other?.rssBytes &&
value.pidSource === other?.pidSource &&
value.pid === other?.pid &&
value.runtimePid === other?.runtimePid
);
})
leftResourceHistory.every((value, index) =>
areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index])
)
);
}

View file

@ -304,10 +304,64 @@ function formatRetryCountdown(ms: number): string {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
function getRuntimeAdvisoryRetryRemainingMs(
advisory: MemberRuntimeAdvisory,
nowMs: number
): number | null {
const retryUntilMs = advisory.retryUntil ? Date.parse(advisory.retryUntil) : Number.NaN;
if (!Number.isFinite(retryUntilMs)) {
return null;
}
const remainingMs = retryUntilMs - nowMs;
return remainingMs > 0 ? remainingMs : null;
}
function isRetryTimedApiAdvisory(
advisory: MemberRuntimeAdvisory,
providerId: TeamProviderId | undefined
): boolean {
return (
advisory.kind === 'api_error' &&
providerId === 'opencode' &&
(advisory.reasonCode === 'quota_exhausted' || advisory.reasonCode === 'rate_limited')
);
}
function formatRetryUntilUtc(value: string | undefined): string | null {
const retryUntilMs = value ? Date.parse(value) : Number.NaN;
if (!Number.isFinite(retryUntilMs)) {
return null;
}
const date = new Date(retryUntilMs);
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${hours}:${minutes} UTC`;
}
function appendRuntimeAdvisoryRetryHint(
base: string,
advisory: MemberRuntimeAdvisory,
providerId: TeamProviderId | undefined
): string {
if (!isRetryTimedApiAdvisory(advisory, providerId)) {
return base;
}
const retryAt = formatRetryUntilUtc(advisory.retryUntil);
if (!retryAt) {
return base;
}
return `${base} Waiting for OpenCode retry or quota reset around ${retryAt}.`;
}
function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined): string | null {
switch (providerId) {
case 'anthropic':
@ -461,12 +515,20 @@ function formatRuntimeAdvisoryTitle(
switch (advisory.reasonCode) {
case 'quota_exhausted':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} quota exhausted.`,
appendRuntimeAdvisoryRetryHint(
`${providerLabel ?? 'Provider'} quota exhausted.`,
advisory,
providerId
),
advisory.message
);
case 'rate_limited':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} rate limited the request.`,
appendRuntimeAdvisoryRetryHint(
`${providerLabel ?? 'Provider'} rate limited the request.`,
advisory,
providerId
),
advisory.message
);
case 'auth_error':
@ -584,18 +646,17 @@ export function getMemberRuntimeAdvisoryLabel(
return null;
}
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
const remainingMs = getRuntimeAdvisoryRetryRemainingMs(advisory, nowMs);
if (advisory.kind === 'api_error') {
if (remainingMs && isRetryTimedApiAdvisory(advisory, providerId)) {
return `${baseLabel} · retry ${formatRetryCountdown(remainingMs)}`;
}
return baseLabel;
}
if (advisory.kind !== 'sdk_retrying') {
return null;
}
const retryUntilMs = advisory.retryUntil ? Date.parse(advisory.retryUntil) : Number.NaN;
if (!Number.isFinite(retryUntilMs)) {
return baseLabel;
}
const remainingMs = retryUntilMs - nowMs;
if (remainingMs <= 0) {
if (!remainingMs) {
return baseLabel;
}
return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`;

View file

@ -1,5 +1,6 @@
import type {
MemberLaunchState,
MemberRuntimeAdvisory,
MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberSpawnStatusEntry,
@ -49,6 +50,11 @@ export interface MemberLaunchDiagnosticsPayload {
rssBytes?: number;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
runtimeAdvisoryKind?: MemberRuntimeAdvisory['kind'];
runtimeAdvisoryReasonCode?: MemberRuntimeAdvisory['reasonCode'];
runtimeAdvisoryObservedAt?: string;
runtimeAdvisoryRetryUntil?: string;
runtimeAdvisoryRetryDelayMs?: number;
bootstrapStalled?: boolean;
pendingPermissionRequestIds?: string[];
firstSpawnAcceptedAt?: string;
@ -240,25 +246,39 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
livenessSource?: MemberSpawnLivenessSource;
spawnEntry?: MemberSpawnStatusEntry;
runtimeEntry?: TeamAgentRuntimeEntry;
runtimeAdvisory?: MemberRuntimeAdvisory;
runtimeAdvisoryLabel?: string | null;
runtimeAdvisoryTitle?: string;
}): MemberLaunchDiagnosticsPayload {
const spawnEntry = params.spawnEntry;
const runtimeEntry = params.runtimeEntry;
const runtimeAdvisory = params.runtimeAdvisory;
const runtimeAdvisoryTitle = boundedString(params.runtimeAdvisoryTitle);
const runtimeAdvisoryLabel = boundedString(params.runtimeAdvisoryLabel ?? undefined);
const runtimeAdvisoryMessage = boundedString(runtimeAdvisory?.message);
const runtimeAdvisoryCardError =
runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage;
const runtimeDiagnostic =
boundedString(spawnEntry?.runtimeDiagnostic) ??
boundedString(runtimeEntry?.runtimeDiagnostic) ??
boundedString(spawnEntry?.hardFailureReason) ??
boundedString(spawnEntry?.error);
const memberCardError = boundedString(
normalizeMemberLaunchFailureReason(
spawnEntry?.error ??
spawnEntry?.hardFailureReason ??
spawnEntry?.runtimeDiagnostic ??
runtimeEntry?.runtimeDiagnostic
) ?? undefined
);
boundedString(spawnEntry?.error) ??
runtimeAdvisoryMessage;
const memberCardError =
boundedString(
normalizeMemberLaunchFailureReason(
spawnEntry?.error ??
spawnEntry?.hardFailureReason ??
spawnEntry?.runtimeDiagnostic ??
runtimeEntry?.runtimeDiagnostic
) ?? undefined
) ?? runtimeAdvisoryCardError;
const diagnostics = uniqueDiagnostics(
memberCardError ? [memberCardError] : undefined,
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
runtimeAdvisoryTitle ? [runtimeAdvisoryTitle] : undefined,
runtimeAdvisoryLabel ? [runtimeAdvisoryLabel] : undefined,
runtimeAdvisoryMessage ? [runtimeAdvisoryMessage] : undefined,
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
spawnEntry?.error ? [spawnEntry.error] : undefined,
runtimeEntry?.diagnostics
@ -370,6 +390,19 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
}
: {}),
...(runtimeAdvisory?.kind ? { runtimeAdvisoryKind: runtimeAdvisory.kind } : {}),
...(runtimeAdvisory?.reasonCode
? { runtimeAdvisoryReasonCode: runtimeAdvisory.reasonCode }
: {}),
...(maybeString(runtimeAdvisory?.observedAt)
? { runtimeAdvisoryObservedAt: maybeString(runtimeAdvisory?.observedAt) }
: {}),
...(maybeString(runtimeAdvisory?.retryUntil)
? { runtimeAdvisoryRetryUntil: maybeString(runtimeAdvisory?.retryUntil) }
: {}),
...(boundedNumber(runtimeAdvisory?.retryDelayMs)
? { runtimeAdvisoryRetryDelayMs: boundedNumber(runtimeAdvisory?.retryDelayMs) }
: {}),
...(spawnEntry?.bootstrapStalled === true ? { bootstrapStalled: true } : {}),
...(boundedStringArray(spawnEntry?.pendingPermissionRequestIds)
? { pendingPermissionRequestIds: boundedStringArray(spawnEntry?.pendingPermissionRequestIds) }

View file

@ -84,6 +84,9 @@ function appendRuntimeSummarySuffixes(
export function getRuntimeMemorySourceLabel(
runtimeEntry: TeamAgentRuntimeEntry | undefined
): string | undefined {
if (runtimeEntry?.runtimeLoadScope === 'shared-host') {
return 'RSS source: shared OpenCode host';
}
if (!runtimeEntry?.pidSource) {
return undefined;
}

73
src/renderer/vendor/radixComposeRefs.ts vendored Normal file
View file

@ -0,0 +1,73 @@
import * as React from 'react';
type PossibleRef<T> = React.Ref<T> | undefined;
function setRef<T>(ref: PossibleRef<T>, value: T | null): void | (() => void) {
if (typeof ref === 'function') {
return ref(value);
}
if (ref !== null && ref !== undefined) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
export function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === 'function') {
hasCleanup = true;
}
return cleanup;
});
if (hasCleanup) {
return () => {
for (let index = 0; index < cleanups.length; index += 1) {
const cleanup = cleanups[index];
if (typeof cleanup === 'function') {
cleanup();
} else {
setRef(refs[index], null);
}
}
};
}
return undefined;
};
}
export function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
const refsRef = React.useRef(refs);
refsRef.current = refs;
return React.useCallback((node) => {
const currentRefs = refsRef.current;
let hasCleanup = false;
const cleanups = currentRefs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === 'function') {
hasCleanup = true;
}
return cleanup;
});
if (hasCleanup) {
return () => {
for (let index = 0; index < cleanups.length; index += 1) {
const cleanup = cleanups[index];
if (typeof cleanup === 'function') {
cleanup();
} else {
setRef(currentRefs[index], null);
}
}
};
}
return undefined;
}, []);
}

View file

@ -790,6 +790,19 @@ export interface ReviewAPI {
) => Promise<{ hash: string; timestamp: string; message: string }[]>;
}
// =============================================================================
// Telemetry API
// =============================================================================
export interface SentryTelemetryContext {
userId: string;
tags: Record<string, string>;
}
export interface TelemetryAPI {
getSentryContext: () => Promise<SentryTelemetryContext | null>;
}
// =============================================================================
// Main Electron API
// =============================================================================
@ -799,6 +812,7 @@ export interface ReviewAPI {
*/
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
startup?: AppStartupAPI;
telemetry: TelemetryAPI;
getAppVersion: () => Promise<string>;
getProjects: () => Promise<Project[]>;
getSessions: (projectId: string) => Promise<Session[]>;

View file

@ -214,6 +214,7 @@ export interface CliProviderStatus {
detailMessage?: string | null;
models: string[];
modelCatalog?: CliProviderModelCatalog | null;
modelCatalogRefreshState?: 'idle' | 'loading' | 'ready' | 'error';
modelAvailability?: CliProviderModelAvailability[];
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
subscriptionRateLimits?: CliProviderSubscriptionRateLimitSnapshot | null;

View file

@ -1203,11 +1203,20 @@ export interface TeamAgentRuntimeResourceSample {
timestamp: string;
cpuPercent?: number;
rssBytes?: number;
primaryCpuPercent?: number;
primaryRssBytes?: number;
childCpuPercent?: number;
childRssBytes?: number;
processCount?: number;
runtimeLoadScope?: TeamAgentRuntimeLoadScope;
runtimeLoadTruncated?: boolean;
pidSource?: TeamAgentRuntimePidSource;
pid?: number;
runtimePid?: number;
}
export type TeamAgentRuntimeLoadScope = 'single-process' | 'process-tree' | 'shared-host';
export interface TeamAgentRuntimeEntry {
memberName: string;
alive: boolean;
@ -1223,6 +1232,13 @@ export interface TeamAgentRuntimeEntry {
cwd?: string;
rssBytes?: number;
cpuPercent?: number;
primaryCpuPercent?: number;
primaryRssBytes?: number;
childCpuPercent?: number;
childRssBytes?: number;
processCount?: number;
runtimeLoadScope?: TeamAgentRuntimeLoadScope;
runtimeLoadTruncated?: boolean;
resourceHistory?: TeamAgentRuntimeResourceSample[];
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
@ -1307,6 +1323,12 @@ export interface MemberSpawnStatusEntry {
hardFailure?: boolean;
/** Pending runtime permission request ids currently blocking bootstrap. */
pendingPermissionRequestIds?: string[];
/** OpenCode bootstrap evidence source for launch/status recovery. */
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
/** OpenCode bootstrap proof mode. Missing means app-managed for current OpenCode sessions. */
bootstrapMode?: OpenCodeBootstrapMode;
/** Candidate used by app-managed OpenCode bootstrap before durable evidence promotion. */
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
/** ISO timestamp of the first accepted teammate spawn for this member. */
firstSpawnAcceptedAt?: string;
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { filterSafeSentryIntegrations, redactSentryEvent } from '../sentryConfig';
describe('sentryConfig privacy helpers', () => {
it('redacts high-risk event data recursively', () => {
const event = redactSentryEvent({
message: 'token sk-secretsecretsecret at /Users/alice/work/private-repo',
user: {
email: 'dev@example.com',
},
extra: {
accountUuid: 'd9b2d63a-582c-4d69-8a01-90e8199f532d',
nested: [{ projectPath: '/home/bob/repo' }],
},
});
const serialized = JSON.stringify(event);
expect(serialized).not.toContain('sk-secretsecretsecret');
expect(serialized).not.toContain('/Users/alice');
expect(serialized).not.toContain('private-repo');
expect(serialized).not.toContain('dev@example.com');
expect(serialized).not.toContain('d9b2d63a-582c-4d69-8a01-90e8199f532d');
expect(serialized).not.toContain('/home/bob');
});
it('filters default integrations that may collect PII-heavy context', () => {
expect(
filterSafeSentryIntegrations([
{ name: 'MainProcessSession' },
{ name: 'OnUncaughtException' },
{ name: 'Screenshots' },
{ name: 'SentryMinidump' },
{ name: 'ElectronContext' },
{ name: 'LocalVariables' },
{ name: 'ElectronBreadcrumbs' },
{ name: 'ScopeToMain' },
]).map((integration) => integration.name)
).toEqual(['MainProcessSession', 'OnUncaughtException', 'ScopeToMain']);
});
});

View file

@ -2,7 +2,7 @@
* Shared Sentry configuration constants.
*
* Used by both main and renderer process init modules.
* Does NOT resolve DSN each process does that with its own env access
* Does NOT resolve DSN - each process does that with its own env access
* (main: process.env, renderer: import.meta.env).
*/
@ -24,3 +24,94 @@ export const TRACES_SAMPLE_RATE = process.env.NODE_ENV === 'production' ? 0.1 :
export function isValidDsn(dsn: string | undefined): dsn is string {
return typeof dsn === 'string' && dsn.length > 0 && dsn.startsWith('https://');
}
const REDACTED = '[redacted]';
const MAX_REDACTION_DEPTH = 8;
const SENSITIVE_KEY_PATTERN =
/(token|secret|authorization|cookie|email|account|clientid|project|repo|path|cwd|teamname|sessionid|taskid|username|user_name)/i;
const SENSITIVE_STRING_PATTERNS: Array<[RegExp, string]> = [
[/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, REDACTED],
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, REDACTED],
[/\b(?:sk|pk|rk|ghp|gho|github_pat|xoxb|xoxp|ya29)[A-Za-z0-9_\-]{12,}\b/g, REDACTED],
[/\/Users\/[^/\s"'`]+(?:\/[^\s"'`]+)*/g, '/Users/[redacted]/[redacted-path]'],
[/\/home\/[^/\s"'`]+(?:\/[^\s"'`]+)*/g, '/home/[redacted]/[redacted-path]'],
[/([A-Za-z]:\\Users\\)[^\\\s"'`]+(?:\\[^\\\s"'`]+)*/g, '$1[redacted]\\[redacted-path]'],
];
const UNSAFE_SENTRY_INTEGRATION_NAMES = new Set([
'AdditionalContext',
'Breadcrumbs',
'BrowserSession',
'ChildProcess',
'Console',
'ContextLines',
'CultureContext',
'ElectronBreadcrumbs',
'ElectronContext',
'ElectronNet',
'EventLoopBlockRenderer',
'GpuContext',
'HttpContext',
'LocalVariables',
'NativeNodeFetch',
'NodeContext',
'NodeFetch',
'PreloadInjection',
'RendererEventLoopBlock',
'RendererProfiling',
'Screenshots',
'SentryMinidump',
'StartupTracing',
]);
interface SentryIntegrationLike {
name?: string;
}
export function filterSafeSentryIntegrations<TIntegration extends SentryIntegrationLike>(
integrations: TIntegration[]
): TIntegration[] {
return integrations.filter(
(integration) => !integration.name || !UNSAFE_SENTRY_INTEGRATION_NAMES.has(integration.name)
);
}
function redactSentryString(value: string): string {
return SENSITIVE_STRING_PATTERNS.reduce(
(current, [pattern, replacement]) => current.replace(pattern, replacement),
value
);
}
function redactSentryValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {
if (typeof value === 'string') {
return redactSentryString(value);
}
if (typeof value !== 'object' || value === null) {
return value;
}
if (depth >= MAX_REDACTION_DEPTH || seen.has(value)) {
return REDACTED;
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((entry) => redactSentryValue(entry, depth + 1, seen));
}
const redacted: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
redacted[key] = SENSITIVE_KEY_PATTERN.test(key)
? REDACTED
: redactSentryValue(entry, depth + 1, seen);
}
return redacted;
}
export function redactSentryEvent(event: unknown): unknown {
return redactSentryValue(event, 0, new WeakSet<object>());
}

View file

@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@shared/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
import {
initializeCliInstallerHandlers,
registerCliInstallerHandlers,
} from '@main/ipc/cliInstaller';
import {
CLI_INSTALLER_GET_PROVIDER_STATUS,
CLI_INSTALLER_GET_STATUS,
} from '@preload/constants/ipcChannels';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import type { CliInstallerService } from '@main/services';
import type { CliInstallationStatus, CliProviderId, CliProviderStatus, IpcResult } from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown;
function createMockIpcMain(): IpcMain & {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
} {
const handlers = new Map<string, IpcHandler>();
const ipcMain = {
handle: vi.fn((channel: string, handler: IpcHandler) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn((channel: string) => {
handlers.delete(channel);
}),
invoke: async (channel: string, ...args: unknown[]) => {
const handler = handlers.get(channel);
if (!handler) {
throw new Error(`No handler for ${channel}`);
}
return await Promise.resolve(handler({} as IpcMainInvokeEvent, ...args));
},
};
return ipcMain as unknown as IpcMain & {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
};
}
function provider(overrides: Partial<CliProviderStatus> & { providerId: CliProviderId }): CliProviderStatus {
const { providerId, ...rest } = overrides;
return {
providerId,
displayName: providerId,
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
modelCatalogRefreshState: 'idle',
statusMessage: null,
detailMessage: null,
models: [],
modelAvailability: [],
canLoginFromUi: providerId !== 'opencode',
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: null,
resolvedBackendId: null,
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
modelCatalog: null,
runtimeCapabilities: null,
subscriptionRateLimits: null,
...rest,
};
}
function status(providers: CliProviderStatus[]): CliInstallationStatus {
return {
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: true,
installedVersion: '0.0.3',
binaryPath: '/mock/agent_teams_orchestrator',
launchError: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authStatusChecking: false,
authMethod: null,
providers,
};
}
describe('cliInstaller IPC handlers', () => {
let ipcMain: ReturnType<typeof createMockIpcMain>;
let service: {
getLatestStatusSnapshot: ReturnType<typeof vi.fn>;
getStatus: ReturnType<typeof vi.fn>;
getProviderStatus: ReturnType<typeof vi.fn>;
verifyProviderModels: ReturnType<typeof vi.fn>;
install: ReturnType<typeof vi.fn>;
invalidateStatusCache: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
ipcMain = createMockIpcMain();
service = {
getLatestStatusSnapshot: vi.fn(() => null),
getStatus: vi.fn(),
getProviderStatus: vi.fn(),
verifyProviderModels: vi.fn(),
install: vi.fn(),
invalidateStatusCache: vi.fn(),
};
initializeCliInstallerHandlers(service as unknown as CliInstallerService);
registerCliInstallerHandlers(ipcMain);
});
it('does not let explicit hidden Gemini refresh poison cached frontend auth status', async () => {
service.getStatus.mockResolvedValue(
status([
provider({ providerId: 'anthropic' }),
provider({ providerId: 'codex' }),
provider({ providerId: 'opencode', canLoginFromUi: false }),
])
);
service.getProviderStatus.mockResolvedValue(
provider({
providerId: 'gemini',
authenticated: true,
authMethod: 'gemini_api_key',
models: ['gemini-2.5-pro'],
})
);
const initial = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
expect(initial.success).toBe(true);
expect(initial.data?.providers.map((entry) => entry.providerId)).toEqual([
'anthropic',
'codex',
'opencode',
]);
const gemini = (await ipcMain.invoke(
CLI_INSTALLER_GET_PROVIDER_STATUS,
'gemini'
)) as IpcResult<CliProviderStatus | null>;
expect(gemini.success).toBe(true);
expect(gemini.data?.authenticated).toBe(true);
const cached = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
expect(service.getStatus).toHaveBeenCalledTimes(1);
expect(cached.success).toBe(true);
expect(cached.data?.providers.map((entry) => entry.providerId)).toEqual([
'anthropic',
'codex',
'opencode',
]);
expect(cached.data?.authLoggedIn).toBe(false);
expect(cached.data?.authMethod).toBeNull();
});
});

View file

@ -1,3 +1,7 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { vi } from 'vitest';
describe('main Sentry telemetry gate', () => {
@ -18,20 +22,99 @@ describe('main Sentry telemetry gate', () => {
vi.resetModules();
});
it('does not initialize Sentry when persisted telemetry config is disabled', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-sentry-config-'));
fs.writeFileSync(
path.join(tempRoot, 'agent-teams-config.json'),
JSON.stringify({ general: { telemetryEnabled: false } }),
'utf8'
);
const { setClaudeBasePathOverride } = await import('@main/utils/pathDecoder');
setClaudeBasePathOverride(tempRoot);
const sentrySdk = await import('@sentry/electron/main');
const init = vi.mocked(sentrySdk.init);
init.mockClear();
const sentry = await import('@main/sentry');
expect(sentry.readPersistedTelemetryEnabled(tempRoot)).toBe(false);
expect(init).not.toHaveBeenCalled();
expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull();
setClaudeBasePathOverride(null);
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('clears user scope and drops events when telemetry is disabled', async () => {
const sentry = await import('@main/sentry');
const sentryApi = {
setUser: vi.fn(),
setTags: vi.fn(),
close: vi.fn(() => Promise.resolve(true)),
};
sentry.setMainSentryApiForTesting(sentryApi);
sentry.syncTelemetryFlag(false);
expect(sentryApi.setUser).toHaveBeenCalledWith(null);
expect(sentryApi.close).toHaveBeenCalled();
expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull();
});
it('returns only hashed anonymous Sentry context when telemetry is enabled', async () => {
const sentry = await import('@main/sentry');
sentry.syncTelemetryFlag(true);
const context = await sentry.getCurrentSentryTelemetryContext();
expect(context?.userId).toMatch(/^[a-f0-9]{64}$/);
expect(Object.keys(context?.tags ?? {}).sort((a, b) => a.localeCompare(b))).toEqual([
'app_version',
'arch',
'identity_source',
'platform',
]);
});
it('does not attach high-cardinality breadcrumb data', async () => {
const sentry = await import('@main/sentry');
const sentryApi = {
addBreadcrumb: vi.fn(),
};
sentry.setMainSentryApiForTesting(sentryApi);
sentry.addMainBreadcrumb('team', 'launch', { teamName: 'private-team-name' });
expect(sentryApi.addBreadcrumb).toHaveBeenCalledWith({
category: 'team',
message: 'launch',
level: 'info',
});
});
it('redacts sensitive fields before allowing telemetry events', async () => {
const sentry = await import('@main/sentry');
sentry.syncTelemetryFlag(true);
const filtered = sentry.filterSentryEventForTelemetry({
message: 'Failed for user dev@example.com in /Users/alice/private-repo',
extra: {
projectPath: '/Users/alice/private-repo',
token: 'sk-testsecretsecretsecret',
accountUuid: 'd9b2d63a-582c-4d69-8a01-90e8199f532d',
},
});
const serialized = JSON.stringify(filtered);
expect(serialized).not.toContain('dev@example.com');
expect(serialized).not.toContain('alice');
expect(serialized).not.toContain('private-repo');
expect(serialized).not.toContain('sk-testsecretsecretsecret');
expect(serialized).not.toContain('d9b2d63a-582c-4d69-8a01-90e8199f532d');
});
it('only exposes safe low-cardinality telemetry tags', async () => {
const { getSafeSentryTelemetryTags } = await import('@main/sentry');

View file

@ -128,7 +128,7 @@ describe('CliInstallerService', () => {
expect(status.updateAvailable).toBe(false);
});
it('includes OpenCode in unavailable multimodel bootstrap status', async () => {
it('includes frontend-visible providers in unavailable multimodel bootstrap status', async () => {
allowConsoleLogs();
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
@ -147,7 +147,6 @@ describe('CliInstallerService', () => {
expect(status.providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
'gemini',
'opencode',
]);
expect(openCodeStatus).toMatchObject({
@ -158,6 +157,104 @@ describe('CliInstallerService', () => {
});
});
it('does not expose hidden Gemini in frontend multimodel authentication snapshots', async () => {
allowConsoleLogs();
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
});
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
vi.mocked(execCli).mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' });
const providers = [
{
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
statusMessage: null,
models: [],
modelAvailability: [],
canLoginFromUi: true,
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
backend: null,
},
{
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
statusMessage: null,
models: [],
modelAvailability: [],
canLoginFromUi: true,
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
backend: null,
},
{
providerId: 'gemini',
displayName: 'Gemini',
supported: true,
authenticated: true,
authMethod: 'gemini_api_key',
verificationState: 'verified',
modelVerificationState: 'idle',
statusMessage: null,
models: ['gemini-2.5-pro'],
modelAvailability: [],
canLoginFromUi: true,
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
backend: { kind: 'api', label: 'Gemini API' },
},
{
providerId: 'opencode',
displayName: 'OpenCode',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
statusMessage: null,
models: [],
modelAvailability: [],
canLoginFromUi: false,
capabilities: { teamLaunch: true, oneShot: false, extensions: undefined as never },
backend: null,
},
];
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation(
async (_binaryPath, onUpdate) => {
onUpdate?.(providers as never);
return providers as never;
}
);
const status = await service.getStatus();
expect(status.providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
'opencode',
]);
expect(status.authLoggedIn).toBe(false);
expect(status.authMethod).toBeNull();
expect(
service
.getLatestStatusSnapshot()
?.providers.some((provider) => provider.providerId === 'gemini')
).toBe(false);
expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false);
});
it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

View file

@ -13,7 +13,9 @@ const execCliMock = vi.fn();
const buildProviderAwareCliEnvMock = vi.fn();
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>();
const enrichProviderStatusMock = vi.fn((provider) => Promise.resolve(provider));
const enrichProviderStatusMock = vi.fn(
(provider, _options?: { hydrateModelCatalog?: boolean }) => Promise.resolve(provider)
);
const enrichProviderStatusesMock = vi.fn((providers) => Promise.resolve(providers));
vi.mock('@main/utils/childProcess', () => ({
@ -22,6 +24,7 @@ vi.mock('@main/utils/childProcess', () => ({
vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
resolveInteractiveShellEnvBestEffort: () => resolveInteractiveShellEnvMock(),
}));
vi.mock('fs', () => ({
@ -84,11 +87,18 @@ describe('ClaudeMultimodelBridgeService', () => {
});
});
it('parses object-based model lists and exposes Gemini runtime status', async () => {
it('keeps Gemini out of frontend aggregate fallback while explicit Gemini status still works', async () => {
execCliMock.mockImplementation((_binaryPath, args, options) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const env = options?.env ?? {};
if (
normalizedArgs.startsWith('runtime status --json --provider ') &&
normalizedArgs.endsWith(' --summary')
) {
return Promise.reject(new Error('unknown option --summary'));
}
if (normalizedArgs === 'auth status --json --provider all') {
return Promise.resolve({
stdout: JSON.stringify({
@ -183,7 +193,12 @@ describe('ClaudeMultimodelBridgeService', () => {
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
expect(providers).toHaveLength(4);
expect(providers).toHaveLength(3);
expect(providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
'opencode',
]);
expect(providers[0]).toMatchObject({
providerId: 'anthropic',
authenticated: true,
@ -205,6 +220,20 @@ describe('ClaudeMultimodelBridgeService', () => {
},
});
expect(providers[2]).toMatchObject({
providerId: 'opencode',
displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
models: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: false,
oneShot: false,
},
});
const gemini = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'gemini');
expect(gemini).toMatchObject({
providerId: 'gemini',
displayName: 'Gemini',
supported: true,
@ -219,21 +248,102 @@ describe('ClaudeMultimodelBridgeService', () => {
projectId: 'demo-project',
},
});
expect(providers[3]).toMatchObject({
providerId: 'opencode',
displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
models: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: false,
oneShot: false,
},
});
});
it('loads all providers with parallel provider-scoped runtime status probes', async () => {
it('falls back to provider-scoped full runtime status without probing Gemini', async () => {
const providerPayloads = {
anthropic: {
supported: true,
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
canLoginFromUi: true,
models: ['claude-sonnet-4-5'],
capabilities: { teamLaunch: true, oneShot: true },
},
codex: {
supported: true,
authenticated: false,
verificationState: 'unknown',
canLoginFromUi: true,
models: ['gpt-5-codex'],
capabilities: { teamLaunch: true, oneShot: true },
},
opencode: {
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
canLoginFromUi: false,
models: ['openai/gpt-5.4-mini'],
capabilities: { teamLaunch: true, oneShot: false },
},
} as const;
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
const providerId =
providerArgIndex >= 0 && Array.isArray(args)
? (args[providerArgIndex + 1] as keyof typeof providerPayloads)
: null;
if (
normalizedArgs.startsWith('runtime status --json --provider ') &&
normalizedArgs.endsWith(' --summary')
) {
return Promise.reject(new Error('unknown option --summary'));
}
if (
normalizedArgs.startsWith('runtime status --json --provider ') &&
providerId &&
providerPayloads[providerId]
) {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
[providerId]: providerPayloads[providerId],
},
}),
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
expect(providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
'opencode',
]);
expect(calls).toEqual(
expect.arrayContaining([
'runtime status --json --provider anthropic --summary',
'runtime status --json --provider codex --summary',
'runtime status --json --provider opencode --summary',
'runtime status --json --provider anthropic',
'runtime status --json --provider codex',
'runtime status --json --provider opencode',
])
);
expect(calls).not.toContain('runtime status --json --provider gemini');
expect(calls).not.toContain('runtime status --json');
expect(calls).not.toContain('auth status --json --provider all');
expect(calls).not.toContain('model list --json --provider all');
});
it('loads frontend providers with parallel provider-scoped runtime status probes', async () => {
const providerPayloads = {
anthropic: {
supported: true,
@ -311,24 +421,31 @@ describe('ClaudeMultimodelBridgeService', () => {
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate);
expect(execCliMock).toHaveBeenCalledTimes(4);
expect(execCliMock).toHaveBeenCalledTimes(3);
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual(
expect.arrayContaining([
'runtime status --json --provider anthropic',
'runtime status --json --provider codex',
'runtime status --json --provider gemini',
'runtime status --json --provider opencode',
'runtime status --json --provider anthropic --summary',
'runtime status --json --provider codex --summary',
'runtime status --json --provider opencode --summary',
])
);
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).not.toContain(
'runtime status --json --provider gemini --summary'
);
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]);
).toEqual([8 * 1024 * 1024, 8 * 1024 * 1024, 8 * 1024 * 1024]);
expect(enrichProviderStatusMock).toHaveBeenCalledTimes(3);
expect(
enrichProviderStatusMock.mock.calls.every(
(call) => call[1]?.hydrateModelCatalog === false
)
).toBe(true);
expect(providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
'gemini',
'opencode',
]);
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
@ -340,6 +457,717 @@ describe('ClaudeMultimodelBridgeService', () => {
expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual(providers);
});
it('hydrates model catalogs without overwriting live summary auth state', async () => {
const summaryPayloads = {
anthropic: {
supported: true,
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
canLoginFromUi: true,
models: ['sonnet'],
capabilities: { teamLaunch: true, oneShot: true },
},
codex: {
supported: true,
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
canLoginFromUi: false,
statusMessage: null,
models: ['gpt-5.4'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
},
gemini: {
supported: true,
authenticated: false,
verificationState: 'unknown',
canLoginFromUi: true,
models: ['gemini-2.5-pro'],
capabilities: { teamLaunch: true, oneShot: true },
},
opencode: {
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
canLoginFromUi: false,
models: ['opencode/big-pickle'],
capabilities: { teamLaunch: true, oneShot: false },
},
} as const;
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
const providerId =
providerArgIndex >= 0 && Array.isArray(args)
? (args[providerArgIndex + 1] as keyof typeof summaryPayloads)
: null;
if (
normalizedArgs === 'runtime status --json --provider codex' &&
providerId === 'codex'
) {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
...summaryPayloads.codex,
authenticated: false,
authMethod: null,
statusMessage: 'stale full status should not win',
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:00:00.000Z',
staleAt: '2026-05-17T00:10:00.000Z',
defaultModelId: 'gpt-5.4',
defaultLaunchModel: 'gpt-5.4',
models: [
{
id: 'gpt-5.4',
launchModel: 'gpt-5.4',
displayName: 'GPT-5.4',
hidden: false,
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
defaultReasoningEffort: 'medium',
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'app-server',
},
],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
}
if (
normalizedArgs.startsWith('runtime status --json --provider ') &&
normalizedArgs.endsWith(' --summary') &&
providerId &&
summaryPayloads[providerId]
) {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
[providerId]: summaryPayloads[providerId],
},
}),
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
type ProviderStatuses = Awaited<ReturnType<typeof service.getProviderStatuses>>;
let resolveHydrated!: (providers: ProviderStatuses) => void;
const hydrated = new Promise<ProviderStatuses>((resolve) => {
resolveHydrated = resolve;
});
const onUpdate = vi.fn((providers: ProviderStatuses) => {
if (providers.find((provider) => provider.providerId === 'codex')?.modelCatalog) {
resolveHydrated(providers);
}
});
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate);
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
authenticated: true,
authMethod: 'api_key',
modelCatalogRefreshState: 'loading',
});
const hydratedProviders = await hydrated;
const hydratedCodex = hydratedProviders.find((provider) => provider.providerId === 'codex');
expect(hydratedCodex).toMatchObject({
authenticated: true,
authMethod: 'api_key',
statusMessage: null,
modelCatalogRefreshState: 'ready',
});
expect(hydratedCodex?.modelCatalog?.models.map((model) => model.id)).toEqual(['gpt-5.4']);
});
it('hydrates a single provider catalog after summary refresh', async () => {
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
canLoginFromUi: false,
statusMessage: null,
models: ['gpt-5.4'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
},
},
}),
stderr: '',
exitCode: 0,
});
}
if (normalizedArgs === 'runtime status --json --provider codex') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: false,
authMethod: 'oauth_token',
verificationState: 'unknown',
canLoginFromUi: false,
statusMessage: 'full status should not overwrite live summary',
models: ['gpt-5.4'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:00:00.000Z',
staleAt: '2026-05-17T00:10:00.000Z',
defaultModelId: 'gpt-5.4',
defaultLaunchModel: 'gpt-5.4',
models: [],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const onCatalogUpdate = vi.fn();
const provider = await service.getProviderStatus(
'/mock/agent_teams_orchestrator',
'codex',
onCatalogUpdate
);
expect(provider).toMatchObject({
authenticated: true,
authMethod: 'api_key',
modelCatalogRefreshState: 'loading',
});
await vi.waitFor(() => {
expect(onCatalogUpdate).toHaveBeenCalledTimes(1);
});
expect(onCatalogUpdate.mock.calls[0]?.[0]).toMatchObject({
authenticated: true,
authMethod: 'api_key',
statusMessage: null,
modelCatalogRefreshState: 'ready',
modelCatalog: {
defaultModelId: 'gpt-5.4',
},
});
});
it('hydrates Anthropic subscription rate limits after the live summary status', async () => {
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (normalizedArgs === 'runtime status --json --provider anthropic --summary') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
anthropic: {
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
canLoginFromUi: true,
statusMessage: null,
models: ['sonnet'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: {
modelCatalog: { dynamic: true, source: 'anthropic-models-api' },
},
subscriptionRateLimits: null,
},
},
}),
stderr: '',
exitCode: 0,
});
}
if (normalizedArgs === 'runtime status --json --provider anthropic') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
anthropic: {
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: false,
authMethod: 'oauth_token',
verificationState: 'unknown',
canLoginFromUi: true,
statusMessage: 'full status should not overwrite live summary',
models: ['sonnet'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: {
modelCatalog: { dynamic: true, source: 'anthropic-models-api' },
},
subscriptionRateLimits: {
primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_800 },
secondary: null,
},
modelCatalog: {
schemaVersion: 1,
providerId: 'anthropic',
source: 'anthropic-models-api',
status: 'ready',
fetchedAt: '2026-05-17T00:00:00.000Z',
staleAt: '2026-05-17T00:10:00.000Z',
defaultModelId: 'sonnet',
defaultLaunchModel: 'sonnet',
models: [],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const onCatalogUpdate = vi.fn();
const provider = await service.getProviderStatus(
'/mock/agent_teams_orchestrator',
'anthropic',
onCatalogUpdate
);
expect(provider).toMatchObject({
authenticated: true,
authMethod: 'oauth_token',
subscriptionRateLimits: null,
modelCatalogRefreshState: 'loading',
});
await vi.waitFor(() => {
expect(onCatalogUpdate).toHaveBeenCalledTimes(1);
});
expect(onCatalogUpdate.mock.calls[0]?.[0]).toMatchObject({
authenticated: true,
authMethod: 'oauth_token',
statusMessage: null,
subscriptionRateLimits: {
primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_800 },
secondary: null,
},
modelCatalogRefreshState: 'ready',
});
});
it('does not cancel one provider catalog hydration when another provider refresh starts', async () => {
let resolveCodexHydration!: (value: {
stdout: string;
stderr: string;
exitCode: number;
}) => void;
const codexHydration = new Promise<{
stdout: string;
stderr: string;
exitCode: number;
}>((resolve) => {
resolveCodexHydration = resolve;
});
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
canLoginFromUi: false,
statusMessage: null,
models: ['gpt-5.4'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
},
},
}),
stderr: '',
exitCode: 0,
});
}
if (normalizedArgs === 'runtime status --json --provider anthropic --summary') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
anthropic: {
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
canLoginFromUi: true,
statusMessage: 'Not connected',
models: ['sonnet'],
capabilities: { teamLaunch: true, oneShot: true },
},
},
}),
stderr: '',
exitCode: 0,
});
}
if (normalizedArgs === 'runtime status --json --provider codex') {
return codexHydration;
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const onCodexCatalogUpdate = vi.fn();
const codex = await service.getProviderStatus(
'/mock/agent_teams_orchestrator',
'codex',
onCodexCatalogUpdate
);
expect(codex.modelCatalogRefreshState).toBe('loading');
const anthropic = await service.getProviderStatus(
'/mock/agent_teams_orchestrator',
'anthropic'
);
expect(anthropic.statusMessage).toBe('Not connected');
resolveCodexHydration({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
...codex,
authenticated: false,
authMethod: null,
statusMessage: 'full status should not overwrite live summary',
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:00:00.000Z',
staleAt: '2026-05-17T00:10:00.000Z',
defaultModelId: 'gpt-5.4',
defaultLaunchModel: 'gpt-5.4',
models: [],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
await vi.waitFor(() => {
expect(onCodexCatalogUpdate).toHaveBeenCalledTimes(1);
});
expect(onCodexCatalogUpdate.mock.calls[0]?.[0]).toMatchObject({
authenticated: true,
authMethod: 'api_key',
statusMessage: null,
modelCatalogRefreshState: 'ready',
});
});
it('ignores stale catalog hydration from an older provider status refresh', async () => {
buildProviderAwareCliEnvMock.mockResolvedValue({
env: { HOME: '/Users/tester' },
connectionIssues: {},
});
const codexSummaryConnected = {
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
canLoginFromUi: false,
statusMessage: null,
models: ['gpt-5.4'],
capabilities: { teamLaunch: true, oneShot: true },
runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } },
};
const codexSummaryDisconnected = {
...codexSummaryConnected,
authenticated: false,
authMethod: null,
statusMessage: 'Not connected',
};
const staticSummaryPayloads = {
anthropic: {
supported: true,
authenticated: false,
verificationState: 'unknown',
canLoginFromUi: true,
models: ['sonnet'],
capabilities: { teamLaunch: true, oneShot: true },
},
gemini: {
supported: true,
authenticated: false,
verificationState: 'unknown',
canLoginFromUi: true,
models: ['gemini-2.5-pro'],
capabilities: { teamLaunch: true, oneShot: true },
},
opencode: {
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
verificationState: 'verified',
canLoginFromUi: false,
models: ['opencode/big-pickle'],
capabilities: { teamLaunch: true, oneShot: false },
},
} as const;
let codexSummaryCalls = 0;
let codexFullCalls = 0;
let firstHydrationStarted = false;
let resolveFirstHydration!: (value: {
stdout: string;
stderr: string;
exitCode: number;
}) => void;
const firstHydration = new Promise<{
stdout: string;
stderr: string;
exitCode: number;
}>((resolve) => {
resolveFirstHydration = resolve;
});
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
const providerId =
providerArgIndex >= 0 && Array.isArray(args)
? (args[providerArgIndex + 1] as keyof typeof staticSummaryPayloads | 'codex')
: null;
if (
normalizedArgs.startsWith('runtime status --json --provider ') &&
normalizedArgs.endsWith(' --summary') &&
providerId
) {
const payload =
providerId === 'codex'
? ++codexSummaryCalls === 1
? codexSummaryConnected
: codexSummaryDisconnected
: staticSummaryPayloads[providerId];
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
[providerId]: payload,
},
}),
stderr: '',
exitCode: 0,
});
}
if (normalizedArgs === 'runtime status --json --provider codex') {
codexFullCalls += 1;
if (codexFullCalls === 1) {
firstHydrationStarted = true;
return firstHydration;
}
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
...codexSummaryDisconnected,
authenticated: true,
authMethod: 'api_key',
statusMessage: 'fresh full status should not overwrite live summary',
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:01:00.000Z',
staleAt: '2026-05-17T00:11:00.000Z',
defaultModelId: 'fresh-model',
defaultLaunchModel: 'fresh-model',
models: [],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
type ProviderStatuses = Awaited<ReturnType<typeof service.getProviderStatuses>>;
const firstUpdates = vi.fn((_: ProviderStatuses) => undefined);
const secondUpdates = vi.fn((_: ProviderStatuses) => undefined);
const firstProviders = await service.getProviderStatuses(
'/mock/agent_teams_orchestrator',
firstUpdates
);
expect(firstProviders.find((provider) => provider.providerId === 'codex')).toMatchObject({
authenticated: true,
authMethod: 'api_key',
});
for (let attempt = 0; attempt < 10 && !firstHydrationStarted; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(firstHydrationStarted).toBe(true);
const secondProviders = await service.getProviderStatuses(
'/mock/agent_teams_orchestrator',
secondUpdates
);
expect(secondProviders.find((provider) => provider.providerId === 'codex')).toMatchObject({
authenticated: false,
authMethod: null,
statusMessage: 'Not connected',
});
resolveFirstHydration({
stdout: JSON.stringify({
schemaVersion: 2,
providers: {
codex: {
...codexSummaryConnected,
statusMessage: 'old catalog hydration',
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:00:00.000Z',
staleAt: '2026-05-17T00:10:00.000Z',
defaultModelId: 'old-model',
defaultLaunchModel: 'old-model',
models: [],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
},
},
}),
stderr: '',
exitCode: 0,
});
await new Promise((resolve) => setTimeout(resolve, 0));
const hasOldCatalogUpdate = [...firstUpdates.mock.calls, ...secondUpdates.mock.calls].some(
([providers]) =>
providers
.find((provider) => provider.providerId === 'codex')
?.modelCatalog?.defaultModelId === 'old-model'
);
expect(hasOldCatalogUpdate).toBe(false);
});
it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
buildProviderAwareCliEnvMock.mockResolvedValue({
env: { HOME: '/Users/tester' },
@ -836,7 +1664,10 @@ describe('ClaudeMultimodelBridgeService', () => {
execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (normalizedArgs === 'runtime status --json --provider opencode') {
if (
normalizedArgs === 'runtime status --json --provider opencode' ||
normalizedArgs === 'runtime status --json --provider opencode --summary'
) {
return Promise.resolve({
stdout: JSON.stringify({
providers: {

View file

@ -1577,6 +1577,72 @@ describe('ProviderConnectionService', () => {
});
});
it('skips Codex catalog hydration when summary enrichment disables catalog loading', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const directCatalog = vi.fn().mockResolvedValue({
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-04-28T00:00:00.000Z',
staleAt: '2026-04-28T00:10:00.000Z',
defaultModelId: 'gpt-5.4',
defaultLaunchModel: 'gpt-5.4',
models: [],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
});
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexModelCatalogFeature({ getCatalog: directCatalog } as never);
const enriched = await service.enrichProviderStatus(
{
providerId: 'codex',
displayName: 'Codex',
supported: true,
authenticated: true,
authMethod: 'chatgpt',
verificationState: 'verified',
models: ['gpt-5.4'],
modelCatalog: null,
runtimeCapabilities: {
modelCatalog: { dynamic: true, source: 'app-server' },
},
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: {
plugins: { status: 'unsupported', ownership: 'shared' },
mcp: { status: 'supported', ownership: 'shared' },
skills: { status: 'supported', ownership: 'shared' },
apiKeys: { status: 'supported', ownership: 'shared' },
},
},
},
{ hydrateModelCatalog: false }
);
expect(directCatalog).not.toHaveBeenCalled();
expect(enriched.models).toEqual(['gpt-5.4']);
expect(enriched.modelCatalog).toBeNull();
expect(enriched.runtimeCapabilities?.modelCatalog).toEqual({
dynamic: true,
source: 'app-server',
});
});
it('returns the stored Anthropic API key for team helper mode only in api_key auth mode', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY',

View file

@ -32,6 +32,40 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded');
});
it('selects OpenCode free usage exhaustion before empty assistant fallback text', () => {
const record = {
diagnostics: [
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.267Z',
'empty_assistant_turn',
],
lastReason: 'empty_assistant_turn',
responseState: 'empty_assistant_turn',
status: 'failed_terminal',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Free usage exceeded');
expect(
isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record))
).toBe(true);
});
it('ignores positive OpenCode delivery breadcrumbs before fallback text', () => {
const record = {
diagnostics: [
'OpenCode app MCP is connected for message delivery.',
'OpenCode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.',
'prompt_delivered_no_assistant_message',
],
lastReason: 'prompt_delivered_no_assistant_message',
responseState: 'prompt_delivered_no_assistant_message',
status: 'failed_terminal',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode accepted the prompt, but no assistant turn was recorded.'
);
});
it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => {
const record = {
diagnostics: [

View file

@ -35,6 +35,20 @@ describe('RuntimeDiagnosticClassifier', () => {
});
});
it('classifies OpenCode free usage retry status as quota exhausted', () => {
const selected = selectRuntimeDiagnosticClassification([
'empty_assistant_turn',
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.267Z',
]);
expect(selected).toMatchObject({
reasonCode: 'quota_exhausted',
normalizedMessage:
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.267Z',
actionRequired: true,
});
});
it('selects auth errors over bridge timeouts', () => {
const selected = selectRuntimeDiagnosticClassification([
'OpenCode bridge command timed out',

View file

@ -363,6 +363,107 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
expect(advisory?.message).not.toContain('Latest assistant message');
});
it('keeps pending OpenCode free usage exhaustion visible while delivery is unresolved', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-17T21:44:45.000Z'));
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'forge-labs';
const laneId = 'secondary:opencode:tom';
const oldIso = '2026-05-17T21:44:34.000Z';
const laneDir = path.join(
tmpDir,
'teams',
teamName,
'.opencode-runtime',
'lanes',
encodeURIComponent(laneId)
);
await fs.mkdir(laneDir, { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'),
JSON.stringify({
version: 1,
updatedAt: oldIso,
lanes: {
[laneId]: { laneId, state: 'active', updatedAt: oldIso },
},
}),
'utf8'
);
await fs.writeFile(
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
JSON.stringify({
schemaVersion: 1,
updatedAt: oldIso,
data: [
{
id: 'opencode-prompt:free-usage-pending',
teamName,
memberName: 'tom',
laneId,
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'msg-1',
inboxTimestamp: oldIso,
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'accepted',
responseState: 'pending',
attempts: 2,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: '2026-05-17T21:44:37.000Z',
lastAttemptAt: oldIso,
lastObservedAt: oldIso,
acceptedAt: '2026-05-17T21:40:21.000Z',
respondedAt: null,
failedAt: null,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'msg-opencode-user',
observedAssistantMessageId: 'msg-opencode-assistant',
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'assistant_response_pending',
diagnostics: [
'OpenCode app MCP is connected for message delivery.',
'OpenCode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.',
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.502Z)',
],
createdAt: oldIso,
updatedAt: oldIso,
},
],
}),
'utf8'
);
const service = new TeamMemberRuntimeAdvisoryService({
findMemberLogs: vi.fn(async () => []),
});
const advisory = await service.getMemberAdvisory(teamName, 'tom');
expect(advisory).toMatchObject({
kind: 'api_error',
reasonCode: 'quota_exhausted',
retryUntil: '2026-05-18T00:00:00.502Z',
});
expect(advisory?.retryDelayMs).toBeGreaterThan(0);
expect(advisory?.message).toContain('Free usage exceeded');
});
it('classifies terminal OpenCode protocol proof failures as warnings, not provider errors', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);

File diff suppressed because it is too large Load diff

View file

@ -994,7 +994,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
);
});
it('runs OpenCode model verification with bounded concurrency and preserves model order', async () => {
it('serializes OpenCode model verification and preserves model order', async () => {
const started: string[] = [];
let activeCount = 0;
let maxActiveCount = 0;
@ -1052,11 +1052,16 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
],
});
await vi.waitFor(() => expect(started).toEqual(['opencode/minimax-m2.5-free']));
expect(maxActiveCount).toBe(1);
expect(releases.has('opencode/nemotron-3-super-free')).toBe(false);
expect(releases.has('opencode/big-pickle')).toBe(false);
releases.get('opencode/minimax-m2.5-free')?.();
await vi.waitFor(() =>
expect(started).toEqual(['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'])
);
expect(maxActiveCount).toBe(2);
expect(releases.has('opencode/big-pickle')).toBe(false);
expect(maxActiveCount).toBe(1);
releases.get('opencode/nemotron-3-super-free')?.();
await vi.waitFor(() =>
@ -1066,10 +1071,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
'opencode/big-pickle',
])
);
expect(maxActiveCount).toBe(2);
expect(maxActiveCount).toBe(1);
releases.get('opencode/big-pickle')?.();
releases.get('opencode/minimax-m2.5-free')?.();
const result = await resultPromise;
@ -1079,7 +1083,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
'Selected model opencode/nemotron-3-super-free verified for launch.',
]);
expect(result.warnings).toEqual([
'Selected model opencode/big-pickle could not be verified. provider busy',
'Selected model opencode/big-pickle verification deferred. OpenCode session is busy; retry when idle.',
]);
});

View file

@ -19,6 +19,9 @@ interface StoreState {
worktrees: { path: string }[];
}[];
teams: { teamName: string; displayName: string }[];
provisioningRuns: Record<string, { state: string; runId: string; updatedAt: string }>;
currentProvisioningRunIdByTeam: Record<string, string | null>;
leadActivityByTeam: Record<string, 'active' | 'idle' | 'offline'>;
}
const storeState = {} as StoreState;
@ -83,12 +86,21 @@ vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
}));
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
SidebarTaskItem: ({ task, hideProjectName }: { task: GlobalTask; hideProjectName?: boolean }) =>
SidebarTaskItem: ({
task,
hideProjectName,
teamOffline,
}: {
task: GlobalTask;
hideProjectName?: boolean;
teamOffline?: boolean;
}) =>
React.createElement(
'div',
{
'data-testid': 'sidebar-task-item',
'data-hide-project-name': hideProjectName ? 'true' : 'false',
'data-team-offline': teamOffline ? 'true' : 'false',
},
task.subject
),
@ -189,6 +201,9 @@ describe('GlobalTaskList project grouping', () => {
storeState.viewMode = 'flat';
storeState.repositoryGroups = [];
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
storeState.provisioningRuns = {};
storeState.currentProvisioningRunIdByTeam = {};
storeState.leadActivityByTeam = {};
toggleCollapsedGroup.mockReset();
taskLocalState.isPinned.mockClear();
taskLocalState.isArchived.mockClear();
@ -277,6 +292,30 @@ describe('GlobalTaskList project grouping', () => {
});
});
it('marks task cards as offline when the owning team has gone offline', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = [makeTask(1)];
storeState.leadActivityByTeam = { 'alpha-team': 'offline' };
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
expect(
host.querySelector('[data-testid="sidebar-task-item"]')?.getAttribute('data-team-offline')
).toBe('true');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));

View file

@ -161,6 +161,26 @@ describe('SidebarTaskItem unread styling', () => {
});
});
it('pauses the in-progress status icon when the task team is offline', 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(SidebarTaskItem, { task: makeTask(), teamOffline: true }));
await Promise.resolve();
});
expect(host.querySelector('svg')?.getAttribute('class')).not.toContain('animate-spin');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('can hide the project label when the parent already groups by project', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);

View file

@ -349,6 +349,63 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('shows an OpenCode catalog loading skeleton instead of the transient big-pickle placeholder', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
flavor: 'agent_teams_orchestrator',
providers: [
{
providerId: 'opencode',
authMethod: 'opencode_managed',
backend: {
kind: 'opencode-cli',
label: 'OpenCode CLI',
endpointLabel: 'opencode',
},
authenticated: true,
supported: true,
capabilities: {
teamLaunch: true,
},
models: ['opencode/big-pickle'],
modelCatalog: null,
modelCatalogRefreshState: 'loading',
modelVerificationState: 'idle',
modelAvailability: [],
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'opencode',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
expect(
host.querySelector('[data-testid="team-model-selector-opencode-loading-skeleton"]')
).not.toBeNull();
expect(host.textContent).toContain('Default');
expect(host.textContent).toContain('Loading OpenCode models...');
expect(host.textContent).not.toContain('big-pickle');
expect(host.textContent).not.toContain('Recommended only');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('virtualizes large OpenCode model lists instead of rendering every model tile', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const models = Array.from(

View file

@ -202,6 +202,42 @@ describe('ProvisioningProviderStatusList', () => {
});
});
it('summarizes OpenCode busy model checks as deferred notes', 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(ProvisioningProviderStatusList, {
checks: [
{
providerId: 'opencode',
status: 'notes',
backendSummary: 'OpenCode CLI',
details: [
'qwen/qwen3-235b-a22b-thinking-2507 - verification deferred - OpenCode session is busy; retry when idle.',
],
},
],
})
);
await Promise.resolve();
});
expect(host.textContent).toContain(
'OpenCode (OpenCode CLI): Selected model checks - 1 verification deferred'
);
expect(host.textContent).not.toContain('model check failed');
expect(host.textContent).not.toContain('Needs attention');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not count generic one-shot diagnostic timeouts as model timeouts', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -487,6 +487,61 @@ describe('runProviderPrepareDiagnostics', () => {
});
});
it('treats OpenCode busy model verification as deferred notes', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
providerId?: TeamProviderId,
providerIds?: TeamProviderId[],
selectedModels?: string[],
limitContext?: boolean,
modelVerificationMode?: 'compatibility' | 'deep'
) => Promise<TeamProvisioningPrepareResult>
>((_cwd, _providerId, _providerIds, _selectedModels, _limitContext, modelVerificationMode) =>
Promise.resolve(
modelVerificationMode === 'compatibility'
? {
ready: true,
message: 'CLI is ready to launch',
details: [
'Selected model opencode/big-pickle is compatible. Deep verification pending.',
],
warnings: [],
}
: {
ready: true,
message: 'CLI is ready to launch',
warnings: [
'Selected model opencode/big-pickle verification deferred. OpenCode session is busy; retry when idle.',
],
}
)
);
const result = await runProviderPrepareDiagnostics({
cwd: '/tmp/project',
providerId: 'opencode',
selectedModelIds: ['opencode/big-pickle'],
prepareProvisioning,
});
expect(result.status).toBe('notes');
expect(result.details).toEqual([
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
]);
expect(result.warnings).toEqual([
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
]);
expect(result.modelResultsById).toEqual({
'opencode/big-pickle': {
status: 'notes',
line: 'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
warningLine:
'big-pickle - verification deferred - OpenCode session is busy; retry when idle.',
},
});
});
it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {
const runtimeFailure =
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?';

View file

@ -100,4 +100,29 @@ describe('CurrentTaskIndicator', () => {
await Promise.resolve();
});
});
it('pauses the spinner when the activity timer is not running', 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(CurrentTaskIndicator, {
task,
borderColor: '#3b82f6',
isTimerRunning: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('svg.animate-spin')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -27,12 +27,51 @@ vi.mock('@renderer/components/ui/badge', () => ({
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipProvider: ({
children,
delayDuration,
skipDelayDuration,
}: {
children: React.ReactNode;
delayDuration?: number;
skipDelayDuration?: number;
}) =>
React.createElement(
'div',
{
'data-testid': 'tooltip-provider',
'data-delay-duration': delayDuration,
'data-skip-delay-duration': skipDelayDuration,
},
children
),
Tooltip: ({
children,
delayDuration,
open,
}: {
children: React.ReactNode;
delayDuration?: number;
open?: boolean;
}) =>
React.createElement(
'div',
{
'data-testid': 'tooltip-root',
'data-delay-duration': delayDuration,
'data-open': open,
},
children
),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
TooltipContent: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => React.createElement('div', { className, 'data-testid': 'tooltip-content' }, children),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
@ -240,6 +279,156 @@ describe('MemberCard starting-state visuals', () => {
});
});
it('shows timed OpenCode quota advisory with a relaunch action', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRestartMember = vi.fn();
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
providerId: 'opencode',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-17T21:44:34.000Z',
retryUntil: '2099-05-18T00:00:00.000Z',
retryDelayMs: 8_000,
reasonCode: 'quota_exhausted',
message: 'Free usage exceeded, subscribe to Go https://opencode.ai/go',
},
},
memberColor: 'blue',
currentTask,
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
onRestartMember,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('OpenCode quota error · retry');
const relaunchButton = host.querySelector('button[aria-label="Relaunch OpenCode"]');
expect(relaunchButton).not.toBeNull();
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).not.toBeNull();
await act(async () => {
(relaunchButton as HTMLButtonElement).click();
await Promise.resolve();
});
expect(onRestartMember).toHaveBeenCalledWith('alice');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows the OpenCode advisory relaunch action in awaiting-reply rows', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRestartMember = vi.fn();
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
providerId: 'opencode',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-17T21:44:34.000Z',
retryUntil: '2099-05-18T00:00:00.000Z',
retryDelayMs: 8_000,
reasonCode: 'quota_exhausted',
message: 'Free usage exceeded, subscribe to Go https://opencode.ai/go',
},
},
memberColor: 'blue',
isAwaitingReply: true,
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
onRestartMember,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('OpenCode quota error · retry');
const relaunchButton = host.querySelector('button[aria-label="Relaunch OpenCode"]');
expect(relaunchButton).not.toBeNull();
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).not.toBeNull();
await act(async () => {
(relaunchButton as HTMLButtonElement).click();
await Promise.resolve();
});
expect(onRestartMember).toHaveBeenCalledWith('alice');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not show the OpenCode advisory relaunch action for protocol-proof warnings', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRestartMember = vi.fn();
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
providerId: 'opencode',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-17T21:44:34.000Z',
reasonCode: 'protocol_proof_missing',
message: 'non_visible_tool_without_task_progress',
},
},
memberColor: 'blue',
currentTask,
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
onRestartMember,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('OpenCode proof missing');
expect(host.querySelector('button[aria-label="Relaunch OpenCode"]')).toBeNull();
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).toBeNull();
expect(onRestartMember).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps runtime-pending launch status visible even when the teammate has an active task', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
@ -535,7 +724,7 @@ describe('MemberCard starting-state visuals', () => {
restartable: false,
providerId: 'opencode',
pid: 333,
pidSource: 'opencode_bridge',
runtimeLoadScope: 'shared-host',
rssBytes: 183.9 * 1024 * 1024,
updatedAt: '2026-04-24T12:00:00.000Z',
},
@ -575,11 +764,23 @@ describe('MemberCard starting-state visuals', () => {
pidSource: 'tmux_child',
rssBytes: 238.3 * 1024 * 1024,
cpuPercent: 14,
primaryCpuPercent: 4,
primaryRssBytes: 210 * 1024 * 1024,
childCpuPercent: 10,
childRssBytes: 28.3 * 1024 * 1024,
processCount: 3,
runtimeLoadScope: 'process-tree',
resourceHistory: [
{
timestamp: '2026-04-24T12:00:00.000Z',
rssBytes: 220 * 1024 * 1024,
cpuPercent: 4,
cpuPercent: 0,
primaryCpuPercent: 0,
primaryRssBytes: 210 * 1024 * 1024,
childCpuPercent: 0,
childRssBytes: 10 * 1024 * 1024,
processCount: 2,
runtimeLoadScope: 'process-tree',
pidSource: 'tmux_child',
pid: 222,
},
@ -587,12 +788,23 @@ describe('MemberCard starting-state visuals', () => {
timestamp: '2026-04-24T12:00:05.000Z',
rssBytes: 238.3 * 1024 * 1024,
cpuPercent: 14,
primaryCpuPercent: 4,
primaryRssBytes: 210 * 1024 * 1024,
childCpuPercent: 10,
childRssBytes: 28.3 * 1024 * 1024,
processCount: 3,
runtimeLoadScope: 'process-tree',
pidSource: 'tmux_child',
pid: 222,
},
],
updatedAt: '2026-04-24T12:00:05.000Z',
},
runtimeTelemetryVisible: true,
runtimeTelemetryScale: {
cpuCapPercent: 100,
memoryCapBytes: 512 * 1024 * 1024,
},
isTeamAlive: true,
isTeamProvisioning: false,
})
@ -603,6 +815,112 @@ describe('MemberCard starting-state visuals', () => {
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
expect(strip).not.toBeNull();
expect(strip?.querySelector('path[fill="#22c55e"]')).not.toBeNull();
const cpuPath = strip?.querySelector('path[stroke="#3b82f6"]');
expect(cpuPath).not.toBeNull();
expect(cpuPath?.getAttribute('d')).toContain('M0 16.10');
expect(strip?.getAttribute('title')).toBeNull();
expect(
host.querySelector('[data-testid="tooltip-root"][data-delay-duration="0"]')
).not.toBeNull();
expect(host.querySelector('[data-testid="tooltip-root"]')?.getAttribute('data-open')).toBe(
'false'
);
expect(host.textContent).toContain('Local runtime load');
expect(host.textContent).toContain('Parent and child processes only.');
expect(host.textContent).toContain('root PID 222');
expect(host.textContent).toContain('3 processes');
expect(host.textContent).toContain('CPU');
expect(host.textContent).toContain('14%');
expect(host.textContent).toContain('Memory');
expect(host.textContent).toContain('238 MB');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('ignores malformed runtime telemetry history without crashing', 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(MemberCard, {
member,
memberColor: 'blue',
runtimeSummary: 'gpt-5.4-mini · Codex · 238.3 MB',
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: true,
providerId: 'codex',
pid: 222,
resourceHistory: 'not-an-array',
updatedAt: '2026-04-24T12:00:05.000Z',
} as any,
runtimeTelemetryVisible: true,
isTeamAlive: true,
isTeamProvisioning: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="member-runtime-telemetry-strip"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('ignores malformed runtime telemetry samples while rendering valid samples', 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(MemberCard, {
member,
memberColor: 'blue',
runtimeSummary: 'gpt-5.4-mini · Codex · 238.3 MB',
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: true,
providerId: 'codex',
pid: 222,
resourceHistory: [
null,
{
timestamp: '2026-04-24T12:00:00.000Z',
rssBytes: 220 * 1024 * 1024,
cpuPercent: 0,
},
'bad-sample',
{
timestamp: '2026-04-24T12:00:05.000Z',
rssBytes: 238 * 1024 * 1024,
cpuPercent: 12,
},
],
updatedAt: '2026-04-24T12:00:05.000Z',
} as any,
runtimeTelemetryVisible: true,
isTeamAlive: true,
isTeamProvisioning: false,
})
);
await Promise.resolve();
});
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
expect(strip).not.toBeNull();
expect(strip?.querySelector('path[stroke="#3b82f6"]')).not.toBeNull();
await act(async () => {

View file

@ -478,8 +478,9 @@ describe('MessagesPanel idle summary invariants', () => {
});
});
it('clears pending replies from durable user_sent history even if the local pending timestamp drifted later', () => {
it('does not clear a fresh pending reply from older durable send history', () => {
const pendingSentAtMs = Date.parse('2026-04-08T12:02:00.000Z');
const pending = { forge: pendingSentAtMs };
const messages: InboxMessage[] = [
makeMessage({
messageId: 'user-send',
@ -499,7 +500,32 @@ describe('MessagesPanel idle summary invariants', () => {
}),
];
expect(reconcilePendingRepliesByMember({ forge: pendingSentAtMs }, messages)).toEqual({});
expect(reconcilePendingRepliesByMember(pending, messages)).toBe(pending);
});
it('keeps pending replies when a new local send has not materialized after an older lead answer', () => {
const pendingSentAtMs = Date.parse('2026-04-08T12:02:00.000Z');
const pending = { lead: pendingSentAtMs };
const messages: InboxMessage[] = [
makeMessage({
messageId: 'older-user-send',
from: 'user',
to: 'lead',
source: 'user_sent',
timestamp: '2026-04-08T12:00:00.000Z',
text: 'Предыдущий вопрос.',
}),
makeMessage({
messageId: 'older-lead-thought-reply',
from: 'lead',
to: undefined,
source: 'lead_session',
timestamp: '2026-04-08T12:01:00.000Z',
text: 'Предыдущий ответ.',
}),
];
expect(reconcilePendingRepliesByMember(pending, messages)).toBe(pending);
});
it('clears pending replies when the team lead answers through a visible lead thought', () => {

View file

@ -61,4 +61,22 @@ describe('ScopeWarningBanner', () => {
await cleanup();
});
it('uses work-interval wording for legacy interval-scoped task changes', async () => {
const { host, cleanup } = await renderBanner({
sourceKind: 'legacy',
confidence: {
tier: 2,
label: 'medium',
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
},
warnings: ['Task start boundary missing - scoped by persisted workIntervals timestamps.'],
});
expect(host.textContent).toContain('Scoped by persisted work interval');
expect(host.textContent).toContain('Interval scoped');
expect(host.textContent).not.toContain('End boundary estimated');
await cleanup();
});
});

View file

@ -8,13 +8,23 @@ import {
DialogDescription,
DialogTitle,
} from '@renderer/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
describe('DialogContent FocusScope integration', () => {
describe('Radix ref lifecycle integration', () => {
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = vi.fn();
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
@ -25,6 +35,7 @@ describe('DialogContent FocusScope integration', () => {
root.unmount();
});
document.body.innerHTML = '';
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
vi.unstubAllGlobals();
});
@ -52,4 +63,31 @@ describe('DialogContent FocusScope integration', () => {
expect(document.body.textContent).toContain('Create team updated');
});
it('keeps the Radix select and popper refs stable while an open select rerenders', () => {
const renderSelect = (label: string): void => {
root.render(
<Select open value="codex">
<SelectTrigger>
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude {label}</SelectItem>
<SelectItem value="codex">Codex {label}</SelectItem>
</SelectContent>
</Select>
);
};
expect(() => {
act(() => {
renderSelect('initial');
});
act(() => {
renderSelect('updated');
});
}).not.toThrow();
expect(document.body.textContent).toContain('Codex updated');
});
});

View file

@ -165,7 +165,7 @@ describe('cliInstallerSlice', () => {
});
describe('mergeCliStatusPreservingHydratedProviders', () => {
it('does not let model-only OpenCode fallback overwrite hydrated runtime status', () => {
it('keeps cached OpenCode models without preserving stale runtime auth status', () => {
const current = createMultimodelStatus([
createMultimodelProvider({
providerId: 'opencode',
@ -202,10 +202,11 @@ describe('cliInstallerSlice', () => {
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
{
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
supported: false,
authenticated: false,
authMethod: null,
backend: null,
models: ['opencode/minimax-m2.5-free'],
}
);
});
@ -300,7 +301,47 @@ describe('cliInstallerSlice', () => {
});
});
it('does not let stale OpenCode missing-CLI status overwrite a refreshed model list', () => {
it('drops stale hidden Gemini loading from multimodel auth checking', () => {
const status = createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: true,
authMethod: 'oauth_token',
models: ['claude-sonnet-4-5'],
}),
createMultimodelProvider({
providerId: 'codex',
displayName: 'Codex',
authenticated: true,
authMethod: 'chatgpt',
models: ['gpt-5.4'],
}),
createMultimodelProvider({
providerId: 'opencode',
displayName: 'OpenCode',
authenticated: true,
authMethod: 'opencode_managed',
models: ['opencode/big-pickle'],
canLoginFromUi: false,
}),
]);
expect(
reconcileMultimodelProviderLoading(status, {
anthropic: false,
codex: false,
gemini: true,
opencode: false,
})
).toEqual({
anthropic: false,
codex: false,
opencode: false,
});
});
it('keeps cached OpenCode models when a fresh runtime status reports missing CLI', () => {
const current = createMultimodelStatus([
createMultimodelProvider({
providerId: 'opencode',
@ -336,8 +377,10 @@ describe('cliInstallerSlice', () => {
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
{
authenticated: true,
authMethod: 'opencode_managed',
authenticated: false,
authMethod: null,
verificationState: 'error',
statusMessage: 'OpenCode CLI not found',
models: ['opencode/minimax-m2.5-free'],
}
);
@ -386,6 +429,59 @@ describe('cliInstallerSlice', () => {
}
);
});
it('drops hydrated hidden Gemini when a fresh frontend status omits it', () => {
const current = createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: false,
authMethod: null,
models: ['claude-sonnet-4-5'],
}),
createMultimodelProvider({
providerId: 'gemini',
displayName: 'Gemini',
authenticated: true,
authMethod: 'gemini_api_key',
models: ['gemini-2.5-pro'],
}),
]);
const incoming = createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: false,
authMethod: null,
models: ['claude-sonnet-4-5'],
}),
createMultimodelProvider({
providerId: 'codex',
displayName: 'Codex',
authenticated: false,
authMethod: null,
models: ['gpt-5.4'],
}),
createMultimodelProvider({
providerId: 'opencode',
displayName: 'OpenCode',
authenticated: false,
authMethod: null,
models: ['opencode/big-pickle'],
canLoginFromUi: false,
}),
]);
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
expect(merged.providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
'opencode',
]);
expect(merged.authLoggedIn).toBe(false);
expect(merged.authMethod).toBeNull();
});
});
describe('OpenCode runtime installer actions', () => {
@ -706,7 +802,6 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false,
codex: false,
gemini: false,
opencode: false,
});
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
@ -786,7 +881,6 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false,
codex: true,
gemini: false,
opencode: false,
});
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1);
@ -806,7 +900,6 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false,
codex: false,
gemini: false,
opencode: false,
});
expect(
@ -896,7 +989,6 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false,
codex: false,
gemini: false,
opencode: false,
});
expect(
@ -983,6 +1075,72 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
});
it('ignores hidden Gemini provider failures without keeping global auth checking active', async () => {
useStore.setState({
cliStatus: createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: true,
authMethod: 'oauth_token',
models: ['claude-sonnet-4-5'],
}),
]),
});
vi.mocked(api.cliInstaller.getProviderStatus).mockRejectedValue(
new Error('Gemini status unavailable')
);
await useStore.getState().fetchCliProviderStatus('gemini');
expect(useStore.getState().cliProviderStatusLoading).toEqual({
gemini: false,
});
expect(useStore.getState().cliStatus?.authLoggedIn).toBe(true);
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
expect(
useStore
.getState()
.cliStatus?.providers.find((provider) => provider.providerId === 'gemini')
).toBeUndefined();
});
it('ignores hidden Gemini provider success responses in multimodel frontend state', async () => {
useStore.setState({
cliStatus: createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
authenticated: false,
authMethod: null,
models: ['claude-sonnet-4-5'],
}),
]),
});
vi.mocked(api.cliInstaller.getProviderStatus).mockResolvedValue(
createMultimodelProvider({
providerId: 'gemini',
displayName: 'Gemini',
authenticated: true,
authMethod: 'gemini_api_key',
models: ['gemini-2.5-pro'],
})
);
await useStore.getState().fetchCliProviderStatus('gemini');
expect(useStore.getState().cliProviderStatusLoading).toEqual({
gemini: false,
});
expect(useStore.getState().cliStatus?.authLoggedIn).toBe(false);
expect(useStore.getState().cliStatus?.authMethod).toBeNull();
expect(
useStore
.getState()
.cliStatus?.providers.find((provider) => provider.providerId === 'gemini')
).toBeUndefined();
});
it('marks authStatusChecking true while a multimodel provider refresh is in flight and clears it on success', async () => {
let resolveProviderStatus!: (value: CliInstallationStatus['providers'][number]) => void;
const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>(
@ -1037,6 +1195,83 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
});
it('keeps cached catalog on summary-only provider refresh without stale auth', async () => {
const currentProvider = createMultimodelProvider({
providerId: 'codex',
displayName: 'Codex',
authenticated: true,
authMethod: 'chatgpt',
statusMessage: 'ChatGPT account ready',
models: ['gpt-5.4'],
modelCatalogRefreshState: 'ready',
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:00:00.000Z',
staleAt: '2026-05-17T00:10:00.000Z',
defaultModelId: 'gpt-5.4',
defaultLaunchModel: 'gpt-5.4',
models: [
{
id: 'gpt-5.4',
launchModel: 'gpt-5.4',
displayName: 'GPT-5.4',
hidden: false,
supportedReasoningEfforts: ['medium'],
defaultReasoningEffort: 'medium',
inputModalities: ['text'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'app-server',
},
],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
});
useStore.setState({
cliStatus: createMultimodelStatus([currentProvider]),
});
vi.mocked(api.cliInstaller.getProviderStatus).mockResolvedValue(
createMultimodelProvider({
providerId: 'codex',
displayName: 'Codex',
authenticated: false,
authMethod: null,
statusMessage: 'Not connected',
models: [],
modelCatalog: null,
modelCatalogRefreshState: 'loading',
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'app-server',
},
},
})
);
await useStore.getState().fetchCliProviderStatus('codex');
const provider = useStore
.getState()
.cliStatus?.providers.find((candidate) => candidate.providerId === 'codex');
expect(provider).toMatchObject({
authenticated: false,
authMethod: null,
statusMessage: 'Not connected',
models: ['gpt-5.4'],
modelCatalogRefreshState: 'ready',
});
expect(provider?.modelCatalog?.defaultModelId).toBe('gpt-5.4');
});
it('keeps OpenCode refresh status-only even when model verification is requested', async () => {
const nextProvider = createMultimodelProvider({
providerId: 'opencode',

View file

@ -4323,6 +4323,102 @@ describe('teamSlice actions', () => {
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('does not crash when runtime resource history contains malformed samples', async () => {
const store = createSliceStore();
const validSample = {
timestamp: '2026-03-12T10:00:00.000Z',
rssBytes: 256 * 1024 * 1024,
cpuPercent: 4,
pid: 4242,
};
const snapshot = createRuntimeSnapshot({
members: {
alice: {
...createRuntimeSnapshot().members.alice,
cpuPercent: 4,
resourceHistory: [null, validSample] as any,
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const semanticallySameSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
resourceHistory: [null, { ...validSample }] as any,
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(semanticallySameSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBe(firstSnapshot);
});
it('updates runtime snapshots when aggregate runtime load breakdown changes', async () => {
const store = createSliceStore();
const firstBreakdownHistorySample = {
timestamp: '2026-03-12T10:00:00.000Z',
rssBytes: 300 * 1024 * 1024,
cpuPercent: 12,
primaryCpuPercent: 12,
primaryRssBytes: 300 * 1024 * 1024,
processCount: 1,
runtimeLoadScope: 'single-process',
pid: 4242,
};
const snapshot = createRuntimeSnapshot({
members: {
alice: {
...createRuntimeSnapshot().members.alice,
cpuPercent: 12,
rssBytes: 300 * 1024 * 1024,
primaryCpuPercent: 12,
primaryRssBytes: 300 * 1024 * 1024,
processCount: 1,
runtimeLoadScope: 'single-process',
resourceHistory: [firstBreakdownHistorySample],
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team'];
const nextSnapshot = createRuntimeSnapshot({
members: {
alice: {
...snapshot.members.alice,
childCpuPercent: 8,
childRssBytes: 80 * 1024 * 1024,
processCount: 3,
runtimeLoadScope: 'process-tree',
resourceHistory: [
{
...firstBreakdownHistorySample,
childCpuPercent: 8,
childRssBytes: 80 * 1024 * 1024,
processCount: 3,
runtimeLoadScope: 'process-tree',
},
],
},
},
});
hoisted.getTeamAgentRuntime.mockResolvedValue(nextSnapshot);
await store.getState().fetchTeamAgentRuntime('my-team');
expect(store.getState().teamAgentRuntimeByTeam['my-team']).not.toBe(firstSnapshot);
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot);
});
it('updates runtime snapshots when copy-diagnostics details change', async () => {
const store = createSliceStore();
const snapshot = createRuntimeSnapshot({

View file

@ -806,6 +806,31 @@ describe('memberHelpers spawn-aware presence', () => {
).toContain('Anthropic authentication error');
});
it('renders timed OpenCode quota errors with retry/reset context', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-05-17T21:44:34.000Z',
retryUntil: '2026-05-18T00:00:00.502Z',
retryDelayMs: 8_126_502,
reasonCode: 'quota_exhausted' as const,
message:
'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.502Z',
};
expect(
getMemberRuntimeAdvisoryLabel(
advisory,
'opencode',
Date.parse('2026-05-17T21:45:00.000Z')
)
).toBe('OpenCode quota error · retry 2h 15m');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode quota exhausted.');
expect(title).toContain('Waiting for OpenCode retry or quota reset around 00:00 UTC.');
expect(title).toContain('Free usage exceeded');
});
it('formats raw OpenCode protocol advisory reasons before showing them in titles', () => {
const advisory = {
kind: 'api_error' as const,

View file

@ -90,4 +90,28 @@ describe('member launch diagnostics', () => {
);
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"');
});
it('includes runtime advisory evidence in copy diagnostics', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'alice',
runtimeAdvisoryLabel: 'OpenCode delivery error',
runtimeAdvisoryTitle: 'OpenCode accepted the prompt, but no assistant turn was recorded.',
runtimeAdvisory: {
kind: 'api_error',
observedAt: '2026-05-17T22:11:38.239Z',
reasonCode: 'backend_error',
message: 'OpenCode accepted the prompt, but no assistant turn was recorded.',
},
});
expect(payload.memberCardError).toBe(
'OpenCode accepted the prompt, but no assistant turn was recorded.'
);
expect(payload.runtimeAdvisoryKind).toBe('api_error');
expect(payload.runtimeAdvisoryReasonCode).toBe('backend_error');
expect(payload.diagnostics).toContain(
'OpenCode accepted the prompt, but no assistant turn was recorded.'
);
expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true);
});
});

View file

@ -9,7 +9,7 @@ import * as path from 'path';
import { afterEach, beforeEach, expect, vi } from 'vitest';
// Mock Sentry Electron SDK it requires the real `electron` package at import
// Mock Sentry Electron SDK - it requires the real `electron` package at import
// time which is unavailable in the vitest/happy-dom environment.
const sentryNoOp = {
init: vi.fn(),
@ -17,6 +17,7 @@ const sentryNoOp = {
captureException: vi.fn(),
setUser: vi.fn(),
setTags: vi.fn(),
close: vi.fn(() => Promise.resolve(true)),
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.fn() })),
browserTracingIntegration: vi.fn(() => ({

View file

@ -23,6 +23,10 @@ export default defineConfig({
'@renderer': resolve(__dirname, 'src/renderer'),
'@preload': resolve(__dirname, 'src/preload'),
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts'),
'@radix-ui/react-compose-refs': resolve(
__dirname,
'src/renderer/vendor/radixComposeRefs.ts'
),
react: resolve(__dirname, 'node_modules/react'),
'react-dom': resolve(__dirname, 'node_modules/react-dom'),
},