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

1
.gitignore vendored
View file

@ -65,3 +65,4 @@ remotion/*
# Local reference captures # Local reference captures
/agent-teams-reference-fix-*.png /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 sentrySourceMapTargets = {
const sentryPlugins = process.env.SENTRY_AUTH_TOKEN main: {
? [ assets: ['./dist-electron/main/**/*.{js,cjs,mjs,map}'],
sentryVitePlugin({ filesToDeleteAfterUpload: ['./dist-electron/main/**/*.map'],
org: process.env.SENTRY_ORG ?? 'quant-jump-pro', },
project: process.env.SENTRY_PROJECT ?? 'electron', renderer: {
authToken: process.env.SENTRY_AUTH_TOKEN, assets: ['./out/renderer/**/*.{js,cjs,mjs,map}'],
release: { name: `agent-teams-ai@${pkg.version}` }, filesToDeleteAfterUpload: ['./out/renderer/**/*.map'],
sourcemaps: { },
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.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({ export default defineConfig({
main: { main: {
plugins: [ plugins: [
nativeModuleStub(), nativeModuleStub(),
...sentryPlugins, ...createSentryPlugins('main'),
], ],
define: { define: {
__APP_VERSION__: JSON.stringify(pkg.version), __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). // at runtime in packaged Electron apps (only during CI build).
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''), 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''),
}, },
@ -148,10 +160,14 @@ export default defineConfig({
'@renderer': resolve(__dirname, 'src/renderer'), '@renderer': resolve(__dirname, 'src/renderer'),
'@shared': resolve(__dirname, 'src/shared'), '@shared': resolve(__dirname, 'src/shared'),
'@main': resolve(__dirname, 'src/main'), '@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') '@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts')
} }
}, },
plugins: [react(), ...sentryPlugins], plugins: [react(), ...createSentryPlugins('renderer')],
build: { build: {
sourcemap: 'hidden', sourcemap: 'hidden',
rollupOptions: { rollupOptions: {

View file

@ -1259,17 +1259,17 @@
.cyber-feature-rail__reviewer-bubble { .cyber-feature-rail__reviewer-bubble {
--reviewer-bubble-center-shift: 3px; --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; left: auto;
top: auto; top: auto;
right: calc(var(--reviewer-robot-width) / 2); right: calc(var(--reviewer-robot-width) / 2);
bottom: calc(100% + 10px); 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: translateX(calc(50% + var(--reviewer-bubble-center-shift))) translate3d(0, 0, 0) rotate(-4deg);
transform-origin: center bottom; transform-origin: center bottom;
animation: cyberFeatureReviewerSpeechFloat 2.8s ease-in-out 0.45s infinite; 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" aria-hidden="true"
> >
<Transition name="cyber-feature-bubble"> <Transition name="cyber-feature-bubble">
<CyberHeroSpeechBubble <RobotSpeechBubble
v-if="reviewerBubbleText" v-if="reviewerBubbleText"
class="cyber-feature-rail__reviewer-bubble" class="cyber-feature-rail__reviewer-bubble"
role="reviewer" tail="down"
> >
{{ reviewerBubbleText }} {{ reviewerBubbleText }}
</CyberHeroSpeechBubble> </RobotSpeechBubble>
</Transition> </Transition>
<div class="cyber-feature-rail__reviewer-card cyber-panel"> <div class="cyber-feature-rail__reviewer-card cyber-panel">
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div> <div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>

View file

@ -14,20 +14,9 @@ const docsHref = computed(() => {
<template> <template>
<footer class="app-footer"> <footer class="app-footer">
<div class="app-footer__robot-stage"> <div class="app-footer__robot-stage">
<span class="app-footer__robot-bubble"> <RobotSpeechBubble class="app-footer__robot-bubble" tail="down">
<svg {{ t('footer.robotBubble') }}
class="app-footer__robot-bubble-shape" </RobotSpeechBubble>
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>
<img <img
class="app-footer__robot" class="app-footer__robot"
:src="robotLeadLounge" :src="robotLeadLounge"
@ -82,51 +71,17 @@ const docsHref = computed(() => {
} }
.app-footer__robot-bubble { .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; top: -28px;
left: -18px; 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: rotate(-2deg);
transform-origin: 72% 74%; 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 { .app-footer__inner {

View file

@ -317,12 +317,13 @@ function getStatusIcon(status: string): string {
aria-hidden="true" aria-hidden="true"
> >
<Transition name="comparison-robot-bubble"> <Transition name="comparison-robot-bubble">
<span <RobotSpeechBubble
v-if="showComparisonRobotBubble" v-if="showComparisonRobotBubble"
class="comparison-table__robot-bubble" class="comparison-table__robot-bubble"
tail="right"
> >
{{ t("comparison.robotBubble") }} {{ t("comparison.robotBubble") }}
</span> </RobotSpeechBubble>
</Transition> </Transition>
<img <img
class="comparison-table__robot-image" class="comparison-table__robot-image"
@ -487,59 +488,20 @@ function getStatusIcon(status: string): string {
} }
.comparison-table__robot-bubble { .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; top: 10px;
right: calc(100% + 12px); 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: rotate(-5deg);
transform-origin: right bottom; transform-origin: right bottom;
animation: comparisonRobotBubbleFloat 2.6s ease-in-out 0.42s infinite; 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-enter-active,
.comparison-robot-bubble-leave-active { .comparison-robot-bubble-leave-active {
transition: transition:

View file

@ -314,12 +314,13 @@ const releaseDate = computed(() => {
aria-hidden="true" aria-hidden="true"
> >
<Transition name="download-robot-bubble"> <Transition name="download-robot-bubble">
<span <RobotSpeechBubble
v-if="showLinuxRobotMessage" v-if="showLinuxRobotMessage"
class="download-section__card-robot-bubble" class="download-section__card-robot-bubble"
tail="right"
> >
Готов начать! Готов начать!
</span> </RobotSpeechBubble>
</Transition> </Transition>
<img <img
class="download-section__card-robot" class="download-section__card-robot"
@ -617,60 +618,20 @@ const releaseDate = computed(() => {
} }
.download-section__card-robot-bubble { .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; top: 12px;
right: calc(100% - 18px); 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: rotate(-5deg);
transform-origin: right bottom; transform-origin: right bottom;
animation: downloadRobotBubbleFloat 2.6s ease-in-out 0.42s infinite; 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-enter-active,
.download-robot-bubble-leave-active { .download-robot-bubble-leave-active {
transition: transition:
@ -970,9 +931,9 @@ const releaseDate = computed(() => {
.download-section__card-robot-bubble { .download-section__card-robot-bubble {
top: 8px; top: 8px;
right: calc(100% - 14px); right: calc(100% - 14px);
min-height: 28px; --robot-bubble-min-width: 88px;
padding: 6px 9px; --robot-bubble-font-size: 0.6rem;
font-size: 0.6rem; --robot-bubble-padding: 7px 23px 7px 11px;
} }
.download-section__card-robot { .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); return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
} }
export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise< export async function listRuntimeProcessTableForCurrentPlatform(): Promise<
RuntimeProcessTableRow[] RuntimeProcessTableRow[]
> { > {
return runtimeCommandExecutor.listRuntimeProcesses(); return runtimeCommandExecutor.listRuntimeProcesses();

View file

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

View file

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

View file

@ -84,6 +84,7 @@ import {
registerTerminalHandlers, registerTerminalHandlers,
removeTerminalHandlers, removeTerminalHandlers,
} from './terminal'; } from './terminal';
import { registerTelemetryHandlers, removeTelemetryHandlers } from './telemetry';
import { registerTmuxHandlers, removeTmuxHandlers } from './tmux'; import { registerTmuxHandlers, removeTmuxHandlers } from './tmux';
import { import {
initializeUpdaterHandlers, initializeUpdaterHandlers,
@ -268,6 +269,7 @@ export function initializeIpcHandlers(
registerWindowHandlers(ipcMain); registerWindowHandlers(ipcMain);
registerRendererLogHandlers(ipcMain); registerRendererLogHandlers(ipcMain);
registerScheduleHandlers(ipcMain); registerScheduleHandlers(ipcMain);
registerTelemetryHandlers(ipcMain);
if (cliInstaller) { if (cliInstaller) {
registerCliInstallerHandlers(ipcMain); registerCliInstallerHandlers(ipcMain);
} }
@ -315,6 +317,7 @@ export function removeIpcHandlers(): void {
removeWindowHandlers(ipcMain); removeWindowHandlers(ipcMain);
removeRendererLogHandlers(ipcMain); removeRendererLogHandlers(ipcMain);
removeScheduleHandlers(ipcMain); removeScheduleHandlers(ipcMain);
removeTelemetryHandlers(ipcMain);
removeCliInstallerHandlers(ipcMain); removeCliInstallerHandlers(ipcMain);
removeOpenCodeRuntimeHandlers(ipcMain); removeOpenCodeRuntimeHandlers(ipcMain);
removeCodexRuntimeHandlers(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, ensureAgentTeamsClientIdentity,
getSentryAnonymousUserId, getSentryAnonymousUserId,
} from '@main/services/identity/AgentTeamsIdentityStore'; } from '@main/services/identity/AgentTeamsIdentityStore';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { import {
filterSafeSentryIntegrations,
isValidDsn, isValidDsn,
redactSentryEvent,
SENTRY_ENVIRONMENT, SENTRY_ENVIRONMENT,
SENTRY_RELEASE, SENTRY_RELEASE,
TRACES_SAMPLE_RATE, TRACES_SAMPLE_RATE,
} from '@shared/utils/sentryConfig'; } from '@shared/utils/sentryConfig';
import * as fs from 'fs';
import * as path from 'path';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Telemetry gate // Telemetry gate
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Module-level flag that `beforeSend` checks. const CONFIG_FILENAME = 'agent-teams-config.json';
// Updated by `syncTelemetryFlag()` once ConfigManager is ready. const LEGACY_CONFIG_FILENAMES = [
// Defaults to `true` so early crash reports are NOT silently dropped; 'claude-devtools-config.json',
// if the user later turns telemetry off, the flag flips to `false`. 'claude-code-context-config.json',
let telemetryAllowed = true; ] 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; let telemetryIdentitySyncToken = 0;
export function getSafeSentryTelemetryTags( export function getSafeSentryTelemetryTags(
@ -50,21 +97,29 @@ export function getSafeSentryTelemetryTags(
*/ */
export function syncTelemetryFlag(enabled: boolean): void { export function syncTelemetryFlag(enabled: boolean): void {
telemetryAllowed = enabled; telemetryAllowed = enabled;
if (!enabled) {
telemetryIdentitySyncToken++;
shutdownSentry();
return;
}
initializeSentryIfAllowed();
void syncTelemetryIdentity(); void syncTelemetryIdentity();
} }
export function filterSentryEventForTelemetry(event: unknown): unknown { 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 { interface SentryMainApi {
init?: (options: SentryInitOptions) => void; init?: (options: SentryInitOptions) => void;
setUser?: (user: { id: string } | null) => void; setUser?: (user: { id: string } | null) => void;
setTags?: (tags: Record<string, string>) => void; setTags?: (tags: Record<string, string>) => void;
close?: (timeout?: number) => PromiseLike<boolean> | boolean;
addBreadcrumb?: (breadcrumb: { addBreadcrumb?: (breadcrumb: {
category: string; category: string;
message: string; message: string;
@ -82,6 +137,9 @@ interface SentryInitOptions {
sendDefaultPii: false; sendDefaultPii: false;
beforeSend: (event: unknown) => unknown; beforeSend: (event: unknown) => unknown;
beforeSendTransaction: (event: unknown) => unknown; beforeSendTransaction: (event: unknown) => unknown;
integrations: <TIntegration extends { name?: string }>(
integrations: TIntegration[]
) => TIntegration[];
} }
let Sentry: SentryMainApi | null = null; let Sentry: SentryMainApi | null = null;
@ -98,6 +156,41 @@ function clearSentryUser(): void {
Sentry.setUser?.(null); 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> { async function syncTelemetryIdentity(): Promise<void> {
const syncToken = ++telemetryIdentitySyncToken; const syncToken = ++telemetryIdentitySyncToken;
if (!initialized || !Sentry) { if (!initialized || !Sentry) {
@ -110,13 +203,18 @@ async function syncTelemetryIdentity(): Promise<void> {
} }
try { try {
const identity = await ensureAgentTeamsClientIdentity(); const context = await getCurrentSentryTelemetryContext();
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) { if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
return; return;
} }
Sentry.setUser?.({ id: getSentryAnonymousUserId(identity.clientId) }); if (!context) {
Sentry.setTags?.(getSafeSentryTelemetryTags(identity.source)); clearSentryUser();
return;
}
Sentry.setUser?.({ id: context.userId });
Sentry.setTags?.(context.tags);
} catch { } catch {
if (syncToken === telemetryIdentitySyncToken) { if (syncToken === telemetryIdentitySyncToken) {
clearSentryUser(); 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 { try {
// Dynamic import would be cleaner but top-level await is not available // Dynamic import would be cleaner but top-level await is not available
// in all contexts. require() is synchronous and works in both Electron // 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. // module is not resolvable.
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency. // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
Sentry = require('@sentry/electron/main') as SentryMainApi; Sentry = require('@sentry/electron/main') as SentryMainApi;
@ -143,15 +248,21 @@ if (isValidDsn(dsn)) {
beforeSend: filterSentryEventForTelemetry, beforeSend: filterSentryEventForTelemetry,
beforeSendTransaction: filterSentryEventForTelemetry, beforeSendTransaction: filterSentryEventForTelemetry,
integrations: filterSafeSentryIntegrations,
}); });
initialized = true; initialized = true;
void syncTelemetryIdentity();
} catch { } 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 // standalone (pure Node.js) mode. All exported helpers are no-ops when
// initialized is false, so this is safe to swallow. // initialized is false, so this is safe to swallow.
} }
} }
initializeSentryIfAllowed();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public helpers (no-op when Sentry is not configured) // Public helpers (no-op when Sentry is not configured)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -160,10 +271,10 @@ if (isValidDsn(dsn)) {
export function addMainBreadcrumb( export function addMainBreadcrumb(
category: string, category: string,
message: string, message: string,
data?: Record<string, unknown> _data?: Record<string, unknown>
): void { ): void {
if (!initialized) return; 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 { import {
getCachedShellEnv, getCachedShellEnv,
getShellPreferredHome, getShellPreferredHome,
resolveInteractiveShellEnv, resolveInteractiveShellEnvBestEffort,
} from '@main/utils/shellEnv'; } from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; 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'; 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases';
const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress'; 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) */ /** Timeout for `claude --version` (ms) */
const VERSION_TIMEOUT_MS = 10_000; const VERSION_TIMEOUT_MS = 10_000;
@ -152,6 +193,7 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
providers: status.providers.map((provider) => ({ providers: status.providers.map((provider) => ({
...provider, ...provider,
modelVerificationState: provider.modelVerificationState ?? 'idle', modelVerificationState: provider.modelVerificationState ?? 'idle',
modelCatalogRefreshState: provider.modelCatalogRefreshState ?? 'idle',
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null, modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
detailMessage: provider.detailMessage ?? null, detailMessage: provider.detailMessage ?? null,
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [], 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( function cloneProviderModelAvailability(
modelAvailability: CliProviderModelAvailability[] | undefined modelAvailability: CliProviderModelAvailability[] | undefined
): CliProviderModelAvailability[] { ): CliProviderModelAvailability[] {
@ -485,27 +547,9 @@ export class CliInstallerService {
const ui = getCliFlavorUiOptions(flavor); const ui = getCliFlavorUiOptions(flavor);
const providers = const providers =
flavor === 'agent_teams_orchestrator' flavor === 'agent_teams_orchestrator'
? ( ? FRONTEND_MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
[ providerId,
{ displayName: getProviderDisplayName(providerId),
providerId: 'anthropic',
displayName: 'Anthropic',
},
{
providerId: 'codex',
displayName: 'Codex',
},
{
providerId: 'gemini',
displayName: 'Gemini',
},
{
providerId: 'opencode',
displayName: 'OpenCode (200+ models)',
},
] as const
).map((provider) => ({
...provider,
supported: false, supported: false,
authenticated: false, authenticated: false,
authMethod: null, authMethod: null,
@ -514,7 +558,7 @@ export class CliInstallerService {
statusMessage: 'Checking...', statusMessage: 'Checking...',
models: [], models: [],
modelAvailability: [], modelAvailability: [],
canLoginFromUi: provider.providerId !== 'opencode', canLoginFromUi: providerId !== 'opencode',
capabilities: { capabilities: {
teamLaunch: false, teamLaunch: false,
oneShot: false, oneShot: false,
@ -652,6 +696,13 @@ export class CliInstallerService {
} }
private updateLatestProviderStatus(providerStatus: CliProviderStatus): void { private updateLatestProviderStatus(providerStatus: CliProviderStatus): void {
if (
this.latestStatusSnapshot?.flavor === 'agent_teams_orchestrator' &&
!isFrontendMultimodelProviderId(providerStatus.providerId)
) {
return;
}
if ( if (
providerStatus.modelVerificationState !== 'verifying' && providerStatus.modelVerificationState !== 'verifying' &&
(providerStatus.modelAvailability?.length ?? 0) <= 0 (providerStatus.modelAvailability?.length ?? 0) <= 0
@ -668,15 +719,17 @@ export class CliInstallerService {
); );
const nextProviders = hasProvider const nextProviders = hasProvider
? this.latestStatusSnapshot.providers.map((provider) => ? this.latestStatusSnapshot.providers.map((provider) =>
provider.providerId === providerStatus.providerId ? providerStatus : provider provider.providerId === providerStatus.providerId
? mergeProviderStatusCatalogCache(providerStatus, provider)
: provider
) )
: [...this.latestStatusSnapshot.providers, providerStatus]; : [...this.latestStatusSnapshot.providers, providerStatus];
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null; const authenticatedProvider = getFrontendAuthenticatedProvider(nextProviders);
this.latestStatusSnapshot = { this.latestStatusSnapshot = {
...this.latestStatusSnapshot, ...this.latestStatusSnapshot,
providers: nextProviders, providers: nextProviders,
authLoggedIn: nextProviders.some((provider) => provider.authenticated), authLoggedIn: hasFrontendAuthenticatedProvider(nextProviders),
authMethod: authenticatedProvider?.authMethod ?? null, authMethod: authenticatedProvider?.authMethod ?? null,
}; };
} }
@ -724,7 +777,7 @@ export class CliInstallerService {
} }
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> { async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
await resolveInteractiveShellEnv(); await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
const binaryPath = await ClaudeBinaryResolver.resolve(); const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) { if (!binaryPath) {
@ -744,14 +797,20 @@ export class CliInstallerService {
const providerStatus = await this.multimodelBridgeService.getProviderStatus( const providerStatus = await this.multimodelBridgeService.getProviderStatus(
binaryPath, binaryPath,
providerId providerId,
(hydratedProviderStatus) => {
this.updateLatestProviderStatus(hydratedProviderStatus);
if (this.latestStatusSnapshot) {
this.publishStatusSnapshot(this.latestStatusSnapshot);
}
}
); );
this.updateLatestProviderStatus(providerStatus); this.updateLatestProviderStatus(providerStatus);
return providerStatus; return providerStatus;
} }
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> { async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
await resolveInteractiveShellEnv(); await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
const binaryPath = await ClaudeBinaryResolver.resolve(); const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) { if (!binaryPath) {
@ -813,7 +872,7 @@ export class CliInstallerService {
diag: CliInstallerStatusRunDiag diag: CliInstallerStatusRunDiag
): Promise<void> { ): Promise<void> {
resetGatherDiag(diag); resetGatherDiag(diag);
await resolveInteractiveShellEnv(); await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
const r = ref.current; const r = ref.current;
const binaryPath = await ClaudeBinaryResolver.resolve(); const binaryPath = await ClaudeBinaryResolver.resolve();
@ -952,6 +1011,7 @@ export class CliInstallerService {
authMethod: null, authMethod: null,
verificationState: 'error', verificationState: 'error',
modelVerificationState: 'idle', modelVerificationState: 'idle',
modelCatalogRefreshState: 'error',
statusMessage: message, statusMessage: message,
models: [], models: [],
modelAvailability: [], modelAvailability: [],
@ -979,17 +1039,18 @@ export class CliInstallerService {
const providers = await this.multimodelBridgeService.getProviderStatuses( const providers = await this.multimodelBridgeService.getProviderStatuses(
binaryPath, binaryPath,
(providersSnapshot) => { (providersSnapshot) => {
result.providers = providersSnapshot; const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated); result.providers = frontendProviders;
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
result.authMethod = result.authMethod =
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null; getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
this.publishStatusSnapshot(result); this.publishStatusSnapshot(result);
} }
); );
result.providers = providers; const frontendProviders = filterFrontendMultimodelProviders(providers);
result.authLoggedIn = providers.some((provider) => provider.authenticated); result.providers = frontendProviders;
result.authMethod = result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
providers.find((provider) => provider.authenticated)?.authMethod ?? null; result.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
result.authStatusChecking = false; result.authStatusChecking = false;
this.publishStatusSnapshot(result); this.publishStatusSnapshot(result);
} catch (error) { } catch (error) {

View file

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

View file

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

View file

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

View file

@ -438,16 +438,7 @@ export class TeamMemberRuntimeAdvisoryService {
const memberKeysWithRecentErrors = new Set<string>(); const memberKeysWithRecentErrors = new Set<string>();
for (const [memberKey, records] of recordsByMember) { for (const [memberKey, records] of recordsByMember) {
if ( if (records.some((record) => this.isOpenCodeDeliveryAdvisoryCandidate(record, now))) {
records.some((record) => {
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
return (
isPotentialOpenCodeRuntimeDeliveryError(record) &&
Number.isFinite(observedAt) &&
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
);
})
) {
memberKeysWithRecentErrors.add(memberKey); memberKeysWithRecentErrors.add(memberKey);
} }
} }
@ -509,12 +500,7 @@ export class TeamMemberRuntimeAdvisoryService {
); );
const latestSuccess = ordered.find(isTerminalSuccessfulOpenCodeDeliveryRecord); const latestSuccess = ordered.find(isTerminalSuccessfulOpenCodeDeliveryRecord);
const latestError = ordered.find((record) => { const latestError = ordered.find((record) => {
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record); return this.isOpenCodeDeliveryAdvisoryCandidate(record, now);
return (
isPotentialOpenCodeRuntimeDeliveryError(record) &&
Number.isFinite(observedAt) &&
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
);
}); });
if (!latestError) { if (!latestError) {
return null; return null;
@ -540,14 +526,87 @@ export class TeamMemberRuntimeAdvisoryService {
if (!message || !decision.observedAt) { if (!message || !decision.observedAt) {
return null; return null;
} }
const retryWindow = this.extractOpenCodeDeliveryRetryWindow(latestError, now);
return { return {
kind: 'api_error', kind: 'api_error',
observedAt: decision.observedAt, observedAt: decision.observedAt,
reasonCode: decision.reasonCode, reasonCode: decision.reasonCode,
message, 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( private async findRecentMemberAdvisoriesFromBatchRefs(
teamName: string, teamName: string,
memberNames: readonly string[] memberNames: readonly string[]

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -18,7 +18,9 @@ export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boo
export function selectOpenCodeRuntimeDeliveryReason( export function selectOpenCodeRuntimeDeliveryReason(
record: OpenCodePromptDeliveryLedgerRecord record: OpenCodePromptDeliveryLedgerRecord
): string | null { ): 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); const selected = selectRuntimeDiagnosticClassification(candidates);
if (selected && !selected.generic && selected.normalizedMessage) { if (selected && !selected.generic && selected.normalizedMessage) {
@ -33,6 +35,19 @@ export function selectOpenCodeRuntimeDeliveryReason(
return selected ? 'OpenCode runtime delivery did not complete.' : null; 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( export function isActionRequiredOpenCodeRuntimeDeliveryReason(
message: string | null | undefined message: string | null | undefined
): boolean { ): boolean {

View file

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

View file

@ -23,6 +23,13 @@ export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
/** Main -> renderer startup progress update */ /** Main -> renderer startup progress update */
export const APP_STARTUP_PROGRESS = 'appStartup:progress'; 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 // Config API Channels
// ============================================================================= // =============================================================================

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups'; import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
@ -7,6 +8,7 @@ import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { markTaskUnread } from '@renderer/services/commentReadStorage'; import { markTaskUnread } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor';
import { import {
@ -16,6 +18,7 @@ import {
NO_PROJECT_KEY, NO_PROJECT_KEY,
sortTasksByFreshness, sortTasksByFreshness,
} from '@renderer/utils/taskGrouping'; } from '@renderer/utils/taskGrouping';
import { resolveTeamStatus } from '@renderer/utils/teamListStatus';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { import {
Archive, Archive,
@ -191,6 +194,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
viewMode, viewMode,
repositoryGroups, repositoryGroups,
teams, teams,
provisioningRuns,
currentProvisioningRunIdByTeam,
leadActivityByTeam,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
globalTasks: s.globalTasks, globalTasks: s.globalTasks,
@ -202,6 +208,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
viewMode: s.viewMode, viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
teams: s.teams, 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 [sortPopoverOpen, setSortPopoverOpen] = useState(false);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null); const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [aliveTeamsInitialized, setAliveTeamsInitialized] = useState(false);
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
Record<string, number> Record<string, number>
>({}); >({});
@ -224,6 +235,21 @@ export const GlobalTaskList = memo(function GlobalTaskList({
const hasFetchedRef = useRef(false); const hasFetchedRef = useRef(false);
const readState = useReadStateSnapshot(); const readState = useReadStateSnapshot();
const taskLocalState = useTaskLocalState(); 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) --- // --- New-task animation tracking (same pattern as ChatHistory) ---
const knownTaskIdsRef = useRef<Set<string>>(new Set()); const knownTaskIdsRef = useRef<Set<string>>(new Set());
@ -262,6 +288,70 @@ export const GlobalTaskList = memo(function GlobalTaskList({
[newTaskIds] [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 => { const setGroupingMode = (mode: TaskGroupingMode): void => {
setGroupingModeState(mode); setGroupingModeState(mode);
saveGroupingMode(mode); saveGroupingMode(mode);
@ -561,6 +651,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<SidebarTaskItem <SidebarTaskItem
task={task} task={task}
showTeamName showTeamName
teamOffline={offlineTeamNames.has(task.teamName)}
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}
@ -655,6 +746,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<SidebarTaskItem <SidebarTaskItem
task={task} task={task}
showTeamName showTeamName
teamOffline={offlineTeamNames.has(task.teamName)}
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}
@ -742,6 +834,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
task={task} task={task}
hideTeamName hideTeamName
hideProjectName hideProjectName
teamOffline={offlineTeamNames.has(task.teamName)}
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}
@ -848,6 +941,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<AnimatedHeightReveal animate={isNewTask(task)}> <AnimatedHeightReveal animate={isNewTask(task)}>
<SidebarTaskItem <SidebarTaskItem
task={task} task={task}
teamOffline={offlineTeamNames.has(task.teamName)}
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}

View file

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

View file

@ -32,6 +32,10 @@ interface RenderedTeamChangeSummary {
} }
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>(); 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[] { function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
if (!Array.isArray(changeSet?.files)) { if (!Array.isArray(changeSet?.files)) {
@ -111,6 +115,23 @@ function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefi
return undefined; 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[] { function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
const status = classifyTaskChangeReviewability(changeSet); const status = classifyTaskChangeReviewability(changeSet);
if (status.reviewability === 'unknown' || status.reviewability === 'none') { if (status.reviewability === 'unknown' || status.reviewability === 'none') {
@ -120,7 +141,13 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
status.diagnostics.length > 0 status.diagnostics.length > 0
? status.diagnostics.map((diagnostic) => diagnostic.message) ? status.diagnostics.map((diagnostic) => diagnostic.message)
: getChangeSetWarnings(changeSet); : 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({ export const TeamChangesSection = memo(function TeamChangesSection({

View file

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

View file

@ -147,6 +147,7 @@ type ProvisioningDetailSummary =
| 'Selected model unavailable' | 'Selected model unavailable'
| 'Selected model verification timed out' | 'Selected model verification timed out'
| 'Selected model check failed' | 'Selected model check failed'
| 'Selected model verification deferred'
| 'Selected model ping not confirmed' | 'Selected model ping not confirmed'
| 'Ready with notes' | 'Ready with notes'
| 'Needs attention'; | 'Needs attention';
@ -163,6 +164,7 @@ function isFormattedModelDetail(lower: string): boolean {
lower.includes(' - compatible, deep verification pending') || lower.includes(' - compatible, deep verification pending') ||
lower.includes(' - unavailable') || lower.includes(' - unavailable') ||
lower.includes(' - check failed') || lower.includes(' - check failed') ||
lower.includes(' - verification deferred') ||
lower.includes(' - ping not confirmed') lower.includes(' - ping not confirmed')
); );
} }
@ -244,6 +246,9 @@ function summarizeDetail(
if (isSelectedModelDetail(lower) && lower.includes('could not be verified')) { if (isSelectedModelDetail(lower) && lower.includes('could not be verified')) {
return 'Selected model check failed'; return 'Selected model check failed';
} }
if (isSelectedModelDetail(lower) && lower.includes('verification deferred')) {
return 'Selected model verification deferred';
}
if (lower.includes(' - verified')) { if (lower.includes(' - verified')) {
return 'Selected model verified'; return 'Selected model verified';
} }
@ -259,6 +264,9 @@ function summarizeDetail(
if (lower.includes(' - check failed -')) { if (lower.includes(' - check failed -')) {
return 'Selected model check failed'; return 'Selected model check failed';
} }
if (lower.includes(' - verification deferred')) {
return 'Selected model verification deferred';
}
if (lower.includes(' - ping not confirmed')) { if (lower.includes(' - ping not confirmed')) {
return 'Selected model ping not confirmed'; return 'Selected model ping not confirmed';
} }
@ -279,6 +287,7 @@ function getModelDetailSummary(details: string[]): string | null {
let unavailableCount = 0; let unavailableCount = 0;
let timedOutCount = 0; let timedOutCount = 0;
let checkFailedCount = 0; let checkFailedCount = 0;
let deferredCount = 0;
let pingNotConfirmedCount = 0; let pingNotConfirmedCount = 0;
let checkingCount = 0; let checkingCount = 0;
@ -327,6 +336,13 @@ function getModelDetailSummary(details: string[]): string | null {
checkFailedCount += 1; checkFailedCount += 1;
continue; continue;
} }
if (
lower.includes(' - verification deferred') ||
(isSelectedModelDetail(lower) && lower.includes('verification deferred'))
) {
deferredCount += 1;
continue;
}
if (lower.includes(' - ping not confirmed')) { if (lower.includes(' - ping not confirmed')) {
pingNotConfirmedCount += 1; pingNotConfirmedCount += 1;
continue; continue;
@ -346,6 +362,9 @@ function getModelDetailSummary(details: string[]): string | null {
if (timedOutCount > 0) { if (timedOutCount > 0) {
parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`); parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
} }
if (deferredCount > 0) {
parts.push(`${deferredCount} verification deferred`);
}
if (pingNotConfirmedCount > 0) { if (pingNotConfirmedCount > 0) {
parts.push(`${pingNotConfirmedCount} ping not confirmed`); 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 { export interface TeamModelSelectorProps {
providerId: TeamProviderId; providerId: TeamProviderId;
onProviderChange: (providerId: TeamProviderId) => void; onProviderChange: (providerId: TeamProviderId) => void;
@ -957,11 +999,18 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const visibleConcreteModelOptionCount = const visibleConcreteModelOptionCount =
visibleModelOptions.length - visibleDefaultModelOptions.length; visibleModelOptions.length - visibleDefaultModelOptions.length;
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).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 trimmedModelQuery = modelQuery.trim();
const shouldConstrainModelListHeight = visibleModelOptions.length > 8; const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
const shouldVirtualizeOpenCodeModels = const shouldVirtualizeOpenCodeModels =
effectiveProviderId === 'opencode' && effectiveProviderId === 'opencode' &&
!shouldShowOpenCodeCatalogLoading &&
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD; visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
const getModelAdvisoryBadgeLabel = (reason: string | null): string => const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note'; reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
@ -1270,8 +1319,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
/> />
</div> </div>
) : null} ) : null}
{(effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) || {!shouldShowOpenCodeCatalogLoading &&
hasRecommendedOpenCodeModels ? ( ((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
hasRecommendedOpenCodeModels) ? (
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2">
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? ( {effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
<Popover <Popover
@ -1370,7 +1420,22 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</div> </div>
) : null} ) : null}
{effectiveProviderId === 'opencode' ? ( {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 <OpenCodeVirtualizedModelGrid
defaultOptions={visibleDefaultModelOptions} defaultOptions={visibleDefaultModelOptions}
groups={visibleOpenCodeModelGroups} groups={visibleOpenCodeModelGroups}
@ -1437,7 +1502,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
{visibleModelOptions.map(renderModelOption)} {visibleModelOptions.map(renderModelOption)}
</div> </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)]"> <div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
{trimmedModelQuery {trimmedModelQuery
? 'No models match this search.' ? 'No models match this search.'

View file

@ -169,6 +169,7 @@ function stripSelectedModelPrefix(modelId: string, message: string): string {
const patterns = [ const patterns = [
new RegExp(`^Selected model ${escapeRegExp(modelId)} is unavailable\\.\\s*`, 'i'), 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)} 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)} verified for launch\\.\\s*`, 'i'),
new RegExp(`^Selected model ${escapeRegExp(modelId)} is available for launch\\.\\s*`, 'i'), new RegExp(`^Selected model ${escapeRegExp(modelId)} is available for launch\\.\\s*`, 'i'),
new RegExp( new RegExp(
@ -420,6 +421,17 @@ function buildModelFailureLine(
return reason ? `${label} - ${kind} - ${reason}` : `${label} - ${kind}`; 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[] { function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]); 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) => const hasUnavailableLine = modelScopedEntries.some((entry) =>
/selected model .* is unavailable\./i.test(entry) /selected model .* is unavailable\./i.test(entry)
); );

View file

@ -105,7 +105,11 @@ export const CurrentTaskIndicator = memo(
return ( return (
<div className="flex min-w-0 flex-1 items-center gap-1.5"> <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> <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button <button
type="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 { Badge } from '@renderer/components/ui/badge';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
@ -26,7 +26,20 @@ import {
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary'; import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; 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 { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
@ -46,6 +59,11 @@ import type {
TeamTaskWithKanban, TeamTaskWithKanban,
} from '@shared/types'; } from '@shared/types';
export interface RuntimeTelemetryScale {
memoryCapBytes?: number;
cpuCapPercent?: number;
}
interface MemberCardProps { interface MemberCardProps {
member: ResolvedTeamMember; member: ResolvedTeamMember;
memberColor: string; memberColor: string;
@ -72,6 +90,8 @@ interface MemberCardProps {
spawnLaunchState?: MemberLaunchState; spawnLaunchState?: MemberLaunchState;
spawnRuntimeAlive?: boolean; spawnRuntimeAlive?: boolean;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
runtimeTelemetryVisible?: boolean;
runtimeTelemetryScale?: RuntimeTelemetryScale;
onOpenTask?: () => void; onOpenTask?: () => void;
onOpenReviewTask?: () => void; onOpenReviewTask?: () => void;
onClick?: () => void; onClick?: () => void;
@ -170,6 +190,232 @@ function buildAreaPath(points: readonly TelemetryPoint[]): string | undefined {
].join(' '); ].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( function buildTelemetryPoints(
samples: readonly TeamAgentRuntimeResourceSample[], samples: readonly TeamAgentRuntimeResourceSample[],
getValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined, getValue: (sample: TeamAgentRuntimeResourceSample) => number | undefined,
@ -194,9 +440,10 @@ function buildTelemetryPoints(
} }
function buildRuntimeTelemetryPaths( function buildRuntimeTelemetryPaths(
history: readonly TeamAgentRuntimeResourceSample[] | undefined history: readonly TeamAgentRuntimeResourceSample[] | undefined,
scale?: RuntimeTelemetryScale
): RuntimeTelemetryPaths | undefined { ): RuntimeTelemetryPaths | undefined {
const samples = (history ?? []).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT); const samples = normalizeRuntimeTelemetrySamples(history).slice(-RUNTIME_TELEMETRY_SAMPLE_LIMIT);
if (samples.length < 2) { if (samples.length < 2) {
return undefined; return undefined;
} }
@ -205,19 +452,38 @@ function buildRuntimeTelemetryPaths(
samples, samples,
(sample) => sample.rssBytes, (sample) => sample.rssBytes,
(value, values) => { (value, values) => {
const min = Math.min(...values); const cappedY = getCappedTelemetryY(value, scale?.memoryCapBytes, {
const max = Math.max(...values); bottomY: 15.25,
const ratio = max > min ? (value - min) / (max - min) : 0.32; amplitude: 4.4,
return 15.25 - ratio * 4.4; });
return (
cappedY ??
getRelativeTelemetryY(value, values, {
bottomY: 15.25,
amplitude: 4.4,
fallbackRatio: 0.32,
})
);
} }
); );
const cpuPoints = buildTelemetryPoints( const cpuPoints = buildTelemetryPoints(
samples, samples,
(sample) => sample.cpuPercent, (sample) => sample.cpuPercent,
(value, values) => { (value, values) => {
const max = Math.max(10, ...values); const cappedY = getCappedTelemetryY(value, scale?.cpuCapPercent, {
const ratio = Math.min(1, value / max); bottomY: 16.1,
return 8.3 - ratio * 4.6; 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({ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
runtimeEntry, runtimeEntry,
visible,
scale,
}: { }: {
runtimeEntry?: TeamAgentRuntimeEntry; runtimeEntry?: TeamAgentRuntimeEntry;
visible: boolean;
scale?: RuntimeTelemetryScale;
}): React.JSX.Element | null { }): React.JSX.Element | null {
const paths = useMemo( const paths = useMemo(
() => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory), () => buildRuntimeTelemetryPaths(runtimeEntry?.resourceHistory, scale),
[runtimeEntry?.resourceHistory] [runtimeEntry?.resourceHistory, scale]
); );
if (!paths) { if (!paths) {
return null; return null;
@ -251,7 +521,16 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
<div <div
aria-hidden="true" aria-hidden="true"
data-testid="member-runtime-telemetry-strip" 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 <svg
className="size-full" className="size-full"
@ -259,7 +538,7 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
preserveAspectRatio="none" preserveAspectRatio="none"
> >
{paths.memoryAreaPath ? ( {paths.memoryAreaPath ? (
<path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.22" /> <path d={paths.memoryAreaPath} fill="#22c55e" opacity="0.14" />
) : null} ) : null}
{paths.memoryLinePath ? ( {paths.memoryLinePath ? (
<path <path
@ -269,7 +548,7 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="0.55" strokeWidth="0.55"
opacity="0.68" opacity="0.45"
/> />
) : null} ) : null}
{paths.cpuLinePath ? ( {paths.cpuLinePath ? (
@ -280,17 +559,19 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="0.62" strokeWidth="0.62"
opacity="0.78" opacity="0.62"
/> />
) : null} ) : null}
</svg> </svg>
<div <div
className="absolute inset-x-0 bottom-0 h-2" className="absolute inset-x-0 bottom-0 h-1.5"
style={{ style={{
background: background:
'linear-gradient(to top, color-mix(in srgb, var(--color-surface) 35%, transparent), transparent)', '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> </div>
); );
}); });
@ -321,6 +602,8 @@ export const MemberCard = memo(function MemberCard({
spawnLaunchState, spawnLaunchState,
spawnRuntimeAlive, spawnRuntimeAlive,
isLaunchSettling, isLaunchSettling,
runtimeTelemetryVisible = false,
runtimeTelemetryScale,
onOpenTask, onOpenTask,
onOpenReviewTask, onOpenReviewTask,
onClick, onClick,
@ -412,6 +695,47 @@ export const MemberCard = memo(function MemberCard({
: reviewTask : reviewTask
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
: undefined; : 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 = const showStartingSkeleton =
!isRemoved && !isRemoved &&
presenceLabel === 'starting' && presenceLabel === 'starting' &&
@ -439,15 +763,21 @@ export const MemberCard = memo(function MemberCard({
teamName: selectedTeamName, teamName: selectedTeamName,
runId: runtimeRunId, runId: runtimeRunId,
memberName: member.name, memberName: member.name,
member,
spawnStatus, spawnStatus,
launchState: spawnLaunchState, launchState: spawnLaunchState,
livenessSource: spawnLivenessSource, livenessSource: spawnLivenessSource,
spawnEntry, spawnEntry,
runtimeEntry, runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
runtimeAdvisoryLabel,
runtimeAdvisoryTitle,
}), }),
[ [
member.name, member,
runtimeEntry, runtimeEntry,
runtimeAdvisoryLabel,
runtimeAdvisoryTitle,
runtimeRunId, runtimeRunId,
selectedTeamName, selectedTeamName,
spawnEntry, spawnEntry,
@ -460,6 +790,11 @@ export const MemberCard = memo(function MemberCard({
!isRemoved && !isRemoved &&
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const showRuntimeAdvisoryDiagnostics =
!isRemoved &&
Boolean(runtimeAdvisoryLabel) &&
runtimeAdvisoryTone === 'error' &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
const isSkippedLaunch = const isSkippedLaunch =
spawnStatus === 'skipped' || spawnStatus === 'skipped' ||
@ -503,13 +838,26 @@ export const MemberCard = memo(function MemberCard({
!isFailedLaunch && !isFailedLaunch &&
!isSkippedLaunch && !isSkippedLaunch &&
(Boolean(activityTask) || !isAwaitingReply); (Boolean(activityTask) || !isAwaitingReply);
const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; const canRelaunchRuntimeAdvisoryOpenCode =
const restartActionBusyLabel = canRelaunchOpenCode Boolean(runtimeAdvisoryLabel) &&
? 'Relaunching OpenCode teammate' runtimeAdvisoryTone === 'error' &&
: 'Retrying teammate'; member.providerId === 'opencode' &&
const restartActionErrorFallback = canRelaunchOpenCode hasRestartMemberControl &&
? 'Failed to relaunch OpenCode teammate' !showLaunchBadge &&
: 'Failed to retry teammate'; !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> => { const handleRestartMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -545,7 +893,7 @@ export const MemberCard = memo(function MemberCard({
} }
}; };
return ( const cardContent = (
<div <div
className={cn( className={cn(
'rounded transition-opacity duration-300', 'rounded transition-opacity duration-300',
@ -560,7 +908,7 @@ export const MemberCard = memo(function MemberCard({
rowSurfaceBleedClass rowSurfaceBleedClass
)} )}
style={undefined} style={undefined}
title={activityTitle} title={rowTitle}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={onClick} 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="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 z-20 flex items-center gap-2.5">
<div className="relative shrink-0"> <div className="relative shrink-0">
@ -662,6 +1016,39 @@ export const MemberCard = memo(function MemberCard({
> >
{runtimeAdvisoryLabel ?? 'awaiting reply'} {runtimeAdvisoryLabel ?? 'awaiting reply'}
</span> </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} ) : null}
</div> </div>
@ -869,31 +1256,62 @@ export const MemberCard = memo(function MemberCard({
) : null} ) : null}
</span> </span>
) : showRuntimeAdvisoryBadge ? ( ) : showRuntimeAdvisoryBadge ? (
<Tooltip> <span className="flex shrink-0 items-center gap-1">
<TooltipTrigger asChild> <Tooltip>
<span className="flex shrink-0 items-center gap-1"> <TooltipTrigger asChild>
<AlertTriangle <span className="flex shrink-0 items-center gap-1">
className={`size-3.5 shrink-0 ${ <AlertTriangle
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400' className={`size-3.5 shrink-0 ${
}`} runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
/> }`}
<Badge />
variant="secondary" <Badge
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${ variant="secondary"
runtimeAdvisoryTone === 'error' className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
? 'bg-red-500/15 text-red-300' runtimeAdvisoryTone === 'error'
: 'bg-amber-500/15 text-amber-300' ? 'bg-red-500/15 text-red-300'
}`} : 'bg-amber-500/15 text-amber-300'
title={runtimeAdvisoryTitle} }`}
> title={runtimeAdvisoryTitle}
{runtimeAdvisoryLabel} >
</Badge> {runtimeAdvisoryLabel}
</span> </Badge>
</TooltipTrigger> </span>
<TooltipContent side="bottom"> </TooltipTrigger>
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel} <TooltipContent side="bottom">
</TooltipContent> {runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
</Tooltip> </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 ? ( ) : !activityTask ? (
<Badge <Badge
variant="secondary" variant="secondary"
@ -977,4 +1395,26 @@ export const MemberCard = memo(function MemberCard({
</div> </div>
</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 { isLeadMember } from '@shared/utils/leadDetection';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; 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 { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer'; import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer';
@ -25,6 +25,7 @@ import type {
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
ResolvedTeamMember, ResolvedTeamMember,
TeamAgentRuntimeEntry, TeamAgentRuntimeEntry,
TeamAgentRuntimeResourceSample,
TeamTaskWithKanban, TeamTaskWithKanban,
} from '@shared/types'; } from '@shared/types';
@ -44,6 +45,8 @@ interface MemberListProps {
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
launchParams?: TeamLaunchParams; launchParams?: TeamLaunchParams;
runtimeTelemetryVisible?: boolean;
onRuntimeTelemetryHoverChange?: (visible: boolean) => void;
onMemberClick?: (member: ResolvedTeamMember) => void; onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (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( function areMemberRuntimeEntriesEquivalent(
left: Map<string, TeamAgentRuntimeEntry> | undefined, left: Map<string, TeamAgentRuntimeEntry> | undefined,
right: Map<string, TeamAgentRuntimeEntry> | undefined right: Map<string, TeamAgentRuntimeEntry> | undefined
@ -273,10 +302,15 @@ function areMemberRuntimeEntriesEquivalent(
if (left.size !== right.size) return false; if (left.size !== right.size) return false;
for (const [key, leftEntry] of left) { for (const [key, leftEntry] of left) {
const rightEntry = right.get(key); const rightEntry = right.get(key);
const leftDiagnostics = leftEntry.diagnostics ?? []; const leftDiagnostics = Array.isArray(leftEntry.diagnostics) ? leftEntry.diagnostics : [];
const rightDiagnostics = rightEntry?.diagnostics ?? []; const rightDiagnostics = Array.isArray(rightEntry?.diagnostics) ? rightEntry.diagnostics : [];
const leftResourceHistory = leftEntry.resourceHistory ?? []; const rightResourceHistoryCandidate = rightEntry?.resourceHistory;
const rightResourceHistory = rightEntry?.resourceHistory ?? []; const leftResourceHistory = Array.isArray(leftEntry.resourceHistory)
? leftEntry.resourceHistory
: [];
const rightResourceHistory = Array.isArray(rightResourceHistoryCandidate)
? rightResourceHistoryCandidate
: [];
if ( if (
leftEntry.memberName !== rightEntry?.memberName || leftEntry.memberName !== rightEntry?.memberName ||
leftEntry.alive !== rightEntry?.alive || leftEntry.alive !== rightEntry?.alive ||
@ -290,6 +324,13 @@ function areMemberRuntimeEntriesEquivalent(
leftEntry.runtimeModel !== rightEntry?.runtimeModel || leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
leftEntry.rssBytes !== rightEntry?.rssBytes || leftEntry.rssBytes !== rightEntry?.rssBytes ||
leftEntry.cpuPercent !== rightEntry?.cpuPercent || 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.livenessKind !== rightEntry?.livenessKind ||
leftEntry.pidSource !== rightEntry?.pidSource || leftEntry.pidSource !== rightEntry?.pidSource ||
leftEntry.processCommand !== rightEntry?.processCommand || leftEntry.processCommand !== rightEntry?.processCommand ||
@ -305,17 +346,9 @@ function areMemberRuntimeEntriesEquivalent(
leftDiagnostics.length !== rightDiagnostics.length || leftDiagnostics.length !== rightDiagnostics.length ||
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) || !leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) ||
leftResourceHistory.length !== rightResourceHistory.length || leftResourceHistory.length !== rightResourceHistory.length ||
!leftResourceHistory.every((value, index) => { !leftResourceHistory.every((value, index) =>
const other = rightResourceHistory[index]; areRuntimeResourceSamplesEquivalent(value, 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
);
})
) { ) {
return false; return false;
} }
@ -323,6 +356,95 @@ function areMemberRuntimeEntriesEquivalent(
return true; 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( function areMemberListPropsEqual(
prev: Readonly<MemberListProps>, prev: Readonly<MemberListProps>,
next: Readonly<MemberListProps> next: Readonly<MemberListProps>
@ -342,6 +464,8 @@ function areMemberListPropsEqual(
prev.isTeamAlive === next.isTeamAlive && prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning && prev.isTeamProvisioning === next.isTeamProvisioning &&
prev.leadActivity === next.leadActivity && prev.leadActivity === next.leadActivity &&
prev.runtimeTelemetryVisible === next.runtimeTelemetryVisible &&
prev.onRuntimeTelemetryHoverChange === next.onRuntimeTelemetryHoverChange &&
prev.onRestartMember === next.onRestartMember && prev.onRestartMember === next.onRestartMember &&
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch && prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
areLaunchParamsEquivalent(prev.launchParams, next.launchParams) areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
@ -378,6 +502,8 @@ interface MemberCardRowProps {
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
runtimeTelemetryVisible?: boolean;
runtimeTelemetryScale?: RuntimeTelemetryScale;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onMemberClick?: (member: ResolvedTeamMember) => void; onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void;
@ -412,6 +538,8 @@ const MemberCardRow = memo(function MemberCardRow({
isTeamProvisioning, isTeamProvisioning,
leadActivity, leadActivity,
isLaunchSettling, isLaunchSettling,
runtimeTelemetryVisible,
runtimeTelemetryScale,
onOpenTask, onOpenTask,
onMemberClick, onMemberClick,
onSendMessage, onSendMessage,
@ -461,6 +589,8 @@ const MemberCardRow = memo(function MemberCardRow({
spawnLaunchState={spawnLaunchState} spawnLaunchState={spawnLaunchState}
spawnRuntimeAlive={spawnRuntimeAlive} spawnRuntimeAlive={spawnRuntimeAlive}
isLaunchSettling={isLaunchSettling} isLaunchSettling={isLaunchSettling}
runtimeTelemetryVisible={runtimeTelemetryVisible}
runtimeTelemetryScale={runtimeTelemetryScale}
onOpenTask={currentTask ? handleOpenTask : undefined} onOpenTask={currentTask ? handleOpenTask : undefined}
onOpenReviewTask={reviewTask ? handleOpenReviewTask : undefined} onOpenReviewTask={reviewTask ? handleOpenReviewTask : undefined}
onClick={handleClick} onClick={handleClick}
@ -584,6 +714,8 @@ export const MemberList = memo(function MemberList({
isTeamProvisioning, isTeamProvisioning,
leadActivity, leadActivity,
launchParams, launchParams,
runtimeTelemetryVisible = false,
onRuntimeTelemetryHoverChange,
onMemberClick, onMemberClick,
onSendMessage, onSendMessage,
onAssignTask, onAssignTask,
@ -610,6 +742,14 @@ export const MemberList = memo(function MemberList({
return () => observer.disconnect(); return () => observer.disconnect();
}, [handleResize]); }, [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 gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
const activeMembers = useMemo( const activeMembers = useMemo(
() => () =>
@ -628,6 +768,10 @@ export const MemberList = memo(function MemberList({
[activeMembers] [activeMembers]
); );
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); 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. // Pre-compute reviewer->task map to avoid O(n*n) scan per member.
const reviewTaskByMember = useMemo(() => { const reviewTaskByMember = useMemo(() => {
const result = new Map<string, TeamTaskWithKanban>(); const result = new Map<string, TeamTaskWithKanban>();
@ -797,7 +941,12 @@ export const MemberList = memo(function MemberList({
} }
return ( 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}> <div className={gridClass}>
{activeMembers.map((member) => { {activeMembers.map((member) => {
const spawnEntry = memberSpawnStatuses?.get(member.name); const spawnEntry = memberSpawnStatuses?.get(member.name);
@ -868,6 +1017,8 @@ export const MemberList = memo(function MemberList({
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity} leadActivity={leadActivity}
isLaunchSettling={isLaunchSettling} isLaunchSettling={isLaunchSettling}
runtimeTelemetryVisible={runtimeTelemetryVisible}
runtimeTelemetryScale={runtimeTelemetryScale}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onMemberClick={onMemberClick} onMemberClick={onMemberClick}
onSendMessage={onSendMessage} onSendMessage={onSendMessage}
@ -912,6 +1063,8 @@ export const MemberList = memo(function MemberList({
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity} leadActivity={leadActivity}
isLaunchSettling={false} isLaunchSettling={false}
runtimeTelemetryVisible={runtimeTelemetryVisible}
runtimeTelemetryScale={runtimeTelemetryScale}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onMemberClick={onMemberClick} onMemberClick={onMemberClick}
onSendMessage={onSendMessage} onSendMessage={onSendMessage}

View file

@ -178,7 +178,9 @@ export function reconcilePendingRepliesByMember(
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
const latestReplyAt = latestReplyToUserByMember.get(memberName); const latestReplyAt = latestReplyToUserByMember.get(memberName);
const latestDurableSendAt = latestUserSentByMember.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) { if (latestReplyAt != null && latestReplyAt > threshold) {
changed = true; changed = true;
continue; continue;

View file

@ -109,7 +109,21 @@ export const ScopeWarningBanner = ({
: 'Needs review', : 'Needs review',
} }
: null; : 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; const { Icon } = config;
return ( return (

View file

@ -8,20 +8,22 @@ const DEFAULT_SPIN_DURATION_MS = 1000;
export type SyncedLoader2Props = ComponentProps<typeof Loader2> & { export type SyncedLoader2Props = ComponentProps<typeof Loader2> & {
spinDurationMs?: number; spinDurationMs?: number;
spinning?: boolean;
}; };
export const SyncedLoader2 = ({ export const SyncedLoader2 = ({
className, className,
style, style,
spinDurationMs = DEFAULT_SPIN_DURATION_MS, spinDurationMs = DEFAULT_SPIN_DURATION_MS,
spinning = true,
...props ...props
}: SyncedLoader2Props): React.JSX.Element => { }: SyncedLoader2Props): React.JSX.Element => {
const syncedStyle = useSyncedAnimationStyle(true, spinDurationMs); const syncedStyle = useSyncedAnimationStyle(spinning, spinDurationMs);
return ( return (
<Loader2 <Loader2
{...props} {...props}
className={cn('animate-spin', className)} className={cn(spinning && 'animate-spin', className)}
style={{ ...syncedStyle, ...style }} 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(); initSentryRenderer();
let root: ReactDOM.Root | null = null; let root: ReactDOM.Root | null = null;

View file

@ -10,20 +10,78 @@
import * as SentryElectron from '@sentry/electron/renderer'; import * as SentryElectron from '@sentry/electron/renderer';
import { browserTracingIntegration as reactBrowserTracing, init as reactInit } from '@sentry/react'; import { browserTracingIntegration as reactBrowserTracing, init as reactInit } from '@sentry/react';
import { import {
filterSafeSentryIntegrations,
isValidDsn, isValidDsn,
redactSentryEvent,
SENTRY_ENVIRONMENT, SENTRY_ENVIRONMENT,
SENTRY_RELEASE, SENTRY_RELEASE,
TRACES_SAMPLE_RATE, TRACES_SAMPLE_RATE,
} from '@shared/utils/sentryConfig'; } from '@shared/utils/sentryConfig';
import type { ElectronAPI } from '@shared/types/api';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Telemetry gate (mirrors src/main/sentry.ts pattern) // Telemetry gate (mirrors src/main/sentry.ts pattern)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Defaults to `true` so early renderer crashes are captured. // Start closed until persisted config is loaded through the store.
// Synced to user's telemetryEnabled preference via syncRendererTelemetry(). let telemetryAllowed = false;
let telemetryAllowed = true;
let initialized = 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 * 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 { export function syncRendererTelemetry(enabled: boolean): void {
telemetryAllowed = enabled; telemetryAllowed = enabled;
if (!enabled && initialized && typeof SentryElectron.setUser === 'function') { if (!enabled) {
SentryElectron.setUser(null); telemetryIdentitySyncToken++;
clearRendererSentryUser();
return;
} }
initSentryRenderer();
void syncRendererTelemetryIdentity();
} }
export function initSentryRenderer(): void { export function initSentryRenderer(): void {
if (initialized) return; if (initialized || !telemetryAllowed) return;
const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined; const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined;
if (!isValidDsn(dsn)) return; 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 // 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 // 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) { if (getElectronApi()) {
// Electron renderer uses IPC transport to main process. // Electron renderer - uses IPC transport to main process.
// browserTracingIntegration from @sentry/electron/renderer to avoid // browserTracingIntegration from @sentry/electron/renderer to avoid
// @sentry/core version mismatch with @sentry/react. // @sentry/core version mismatch with @sentry/react.
SentryElectron.init({ SentryElectron.init({
...baseOptions, ...baseOptions,
beforeSend, beforeSend,
beforeSendTransaction, beforeSendTransaction,
integrations: [SentryElectron.browserTracingIntegration()], integrations: (integrations) => [
...filterSafeSentryIntegrations(integrations),
SentryElectron.browserTracingIntegration(),
],
}); });
} else { } else {
// Standalone browser mode — direct HTTP transport // Standalone browser mode - direct HTTP transport
reactInit({ reactInit({
...baseOptions, ...baseOptions,
beforeSend, beforeSend,
beforeSendTransaction, beforeSendTransaction,
integrations: [reactBrowserTracing()], integrations: (integrations) => [
...filterSafeSentryIntegrations(integrations),
reactBrowserTracing(),
],
}); });
} }
initialized = true; initialized = true;
void syncRendererTelemetryIdentity();
} }
/** Whether the renderer SDK was successfully initialised. */ /** Whether the renderer SDK was successfully initialised. */
@ -88,11 +159,11 @@ export function isSentryRendererActive(): boolean {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Record a navigation breadcrumb (tab switches). */ /** 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; if (!initialized) return;
SentryElectron.addBreadcrumb({ SentryElectron.addBreadcrumb({
category: 'navigation', category: 'navigation',
message: `Tab: ${from}${to}`, message: 'tab-change',
level: 'info', level: 'info',
}); });
} }
@ -101,17 +172,18 @@ export function addNavigationBreadcrumb(from: string, to: string): void {
export function addRendererBreadcrumb( export function addRendererBreadcrumb(
category: string, category: string,
message: string, message: string,
data?: Record<string, unknown> _data?: Record<string, unknown>
): void { ): void {
if (!initialized) return; if (!initialized) return;
SentryElectron.addBreadcrumb({ category, message, data, level: 'info' }); SentryElectron.addBreadcrumb({ category, message, level: 'info' });
} }
/** Capture an exception with optional extra context. */ /** Capture an exception with optional extra context. */
export function captureRendererException(error: Error, context?: Record<string, unknown>): void { export function captureRendererException(error: Error, context?: Record<string, unknown>): void {
if (!initialized) return; if (!initialized) return;
SentryElectron.withScope((scope) => { SentryElectron.withScope((scope) => {
if (context) scope.setContext('react', context); const safeContext = getSafeRendererErrorContext(context);
if (safeContext) scope.setContext('react', safeContext);
SentryElectron.captureException(error); SentryElectron.captureException(error);
}); });
} }

View file

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

View file

@ -62,6 +62,7 @@ import type {
TaskChangePresenceState, TaskChangePresenceState,
TaskComment, TaskComment,
TeamAgentRuntimeEntry, TeamAgentRuntimeEntry,
TeamAgentRuntimeResourceSample,
TeamAgentRuntimeSnapshot, TeamAgentRuntimeSnapshot,
TeamCreateRequest, TeamCreateRequest,
TeamGetDataOptions, 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( function areTeamAgentRuntimeEntriesEqual(
left: TeamAgentRuntimeEntry | undefined, left: TeamAgentRuntimeEntry | undefined,
right: TeamAgentRuntimeEntry | undefined right: TeamAgentRuntimeEntry | undefined
): boolean { ): boolean {
if (left === right) return true; if (left === right) return true;
if (!left || !right) return left === right; if (!left || !right) return left === right;
const leftDiagnostics = left.diagnostics ?? []; const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : [];
const rightDiagnostics = right.diagnostics ?? []; const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : [];
const leftResourceHistory = left.resourceHistory ?? []; const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : [];
const rightResourceHistory = right.resourceHistory ?? []; const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : [];
return ( return (
left.memberName === right.memberName && left.memberName === right.memberName &&
left.alive === right.alive && left.alive === right.alive &&
@ -1001,6 +1030,13 @@ function areTeamAgentRuntimeEntriesEqual(
left.runtimeModel === right.runtimeModel && left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes && left.rssBytes === right.rssBytes &&
left.cpuPercent === right.cpuPercent && 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.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource && left.pidSource === right.pidSource &&
left.processCommand === right.processCommand && left.processCommand === right.processCommand &&
@ -1016,17 +1052,9 @@ function areTeamAgentRuntimeEntriesEqual(
leftDiagnostics.length === rightDiagnostics.length && leftDiagnostics.length === rightDiagnostics.length &&
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) && leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
leftResourceHistory.length === rightResourceHistory.length && leftResourceHistory.length === rightResourceHistory.length &&
leftResourceHistory.every((value, index) => { leftResourceHistory.every((value, index) =>
const other = rightResourceHistory[index]; areTeamAgentRuntimeResourceSamplesEqual(value, 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
);
})
); );
} }

View file

@ -304,10 +304,64 @@ function formatRetryCountdown(ms: number): string {
return `${totalSeconds}s`; return `${totalSeconds}s`;
} }
const minutes = Math.floor(totalSeconds / 60); 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; const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; 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 { function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined): string | null {
switch (providerId) { switch (providerId) {
case 'anthropic': case 'anthropic':
@ -461,12 +515,20 @@ function formatRuntimeAdvisoryTitle(
switch (advisory.reasonCode) { switch (advisory.reasonCode) {
case 'quota_exhausted': case 'quota_exhausted':
return appendRuntimeAdvisoryRawMessage( return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} quota exhausted.`, appendRuntimeAdvisoryRetryHint(
`${providerLabel ?? 'Provider'} quota exhausted.`,
advisory,
providerId
),
advisory.message advisory.message
); );
case 'rate_limited': case 'rate_limited':
return appendRuntimeAdvisoryRawMessage( return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} rate limited the request.`, appendRuntimeAdvisoryRetryHint(
`${providerLabel ?? 'Provider'} rate limited the request.`,
advisory,
providerId
),
advisory.message advisory.message
); );
case 'auth_error': case 'auth_error':
@ -584,18 +646,17 @@ export function getMemberRuntimeAdvisoryLabel(
return null; return null;
} }
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId); const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
const remainingMs = getRuntimeAdvisoryRetryRemainingMs(advisory, nowMs);
if (advisory.kind === 'api_error') { if (advisory.kind === 'api_error') {
if (remainingMs && isRetryTimedApiAdvisory(advisory, providerId)) {
return `${baseLabel} · retry ${formatRetryCountdown(remainingMs)}`;
}
return baseLabel; return baseLabel;
} }
if (advisory.kind !== 'sdk_retrying') { if (advisory.kind !== 'sdk_retrying') {
return null; return null;
} }
const retryUntilMs = advisory.retryUntil ? Date.parse(advisory.retryUntil) : Number.NaN; if (!remainingMs) {
if (!Number.isFinite(retryUntilMs)) {
return baseLabel;
}
const remainingMs = retryUntilMs - nowMs;
if (remainingMs <= 0) {
return baseLabel; return baseLabel;
} }
return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`; return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`;

View file

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

View file

@ -84,6 +84,9 @@ function appendRuntimeSummarySuffixes(
export function getRuntimeMemorySourceLabel( export function getRuntimeMemorySourceLabel(
runtimeEntry: TeamAgentRuntimeEntry | undefined runtimeEntry: TeamAgentRuntimeEntry | undefined
): string | undefined { ): string | undefined {
if (runtimeEntry?.runtimeLoadScope === 'shared-host') {
return 'RSS source: shared OpenCode host';
}
if (!runtimeEntry?.pidSource) { if (!runtimeEntry?.pidSource) {
return undefined; 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 }[]>; ) => 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 // Main Electron API
// ============================================================================= // =============================================================================
@ -799,6 +812,7 @@ export interface ReviewAPI {
*/ */
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi { export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
startup?: AppStartupAPI; startup?: AppStartupAPI;
telemetry: TelemetryAPI;
getAppVersion: () => Promise<string>; getAppVersion: () => Promise<string>;
getProjects: () => Promise<Project[]>; getProjects: () => Promise<Project[]>;
getSessions: (projectId: string) => Promise<Session[]>; getSessions: (projectId: string) => Promise<Session[]>;

View file

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

View file

@ -1203,11 +1203,20 @@ export interface TeamAgentRuntimeResourceSample {
timestamp: string; timestamp: string;
cpuPercent?: number; cpuPercent?: number;
rssBytes?: number; rssBytes?: number;
primaryCpuPercent?: number;
primaryRssBytes?: number;
childCpuPercent?: number;
childRssBytes?: number;
processCount?: number;
runtimeLoadScope?: TeamAgentRuntimeLoadScope;
runtimeLoadTruncated?: boolean;
pidSource?: TeamAgentRuntimePidSource; pidSource?: TeamAgentRuntimePidSource;
pid?: number; pid?: number;
runtimePid?: number; runtimePid?: number;
} }
export type TeamAgentRuntimeLoadScope = 'single-process' | 'process-tree' | 'shared-host';
export interface TeamAgentRuntimeEntry { export interface TeamAgentRuntimeEntry {
memberName: string; memberName: string;
alive: boolean; alive: boolean;
@ -1223,6 +1232,13 @@ export interface TeamAgentRuntimeEntry {
cwd?: string; cwd?: string;
rssBytes?: number; rssBytes?: number;
cpuPercent?: number; cpuPercent?: number;
primaryCpuPercent?: number;
primaryRssBytes?: number;
childCpuPercent?: number;
childRssBytes?: number;
processCount?: number;
runtimeLoadScope?: TeamAgentRuntimeLoadScope;
runtimeLoadTruncated?: boolean;
resourceHistory?: TeamAgentRuntimeResourceSample[]; resourceHistory?: TeamAgentRuntimeResourceSample[];
livenessKind?: TeamAgentRuntimeLivenessKind; livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource; pidSource?: TeamAgentRuntimePidSource;
@ -1307,6 +1323,12 @@ export interface MemberSpawnStatusEntry {
hardFailure?: boolean; hardFailure?: boolean;
/** Pending runtime permission request ids currently blocking bootstrap. */ /** Pending runtime permission request ids currently blocking bootstrap. */
pendingPermissionRequestIds?: string[]; 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. */ /** ISO timestamp of the first accepted teammate spawn for this member. */
firstSpawnAcceptedAt?: string; firstSpawnAcceptedAt?: string;
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */ /** 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. * Shared Sentry configuration constants.
* *
* Used by both main and renderer process init modules. * 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). * (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 { export function isValidDsn(dsn: string | undefined): dsn is string {
return typeof dsn === 'string' && dsn.length > 0 && dsn.startsWith('https://'); 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'; import { vi } from 'vitest';
describe('main Sentry telemetry gate', () => { describe('main Sentry telemetry gate', () => {
@ -18,20 +22,99 @@ describe('main Sentry telemetry gate', () => {
vi.resetModules(); 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 () => { it('clears user scope and drops events when telemetry is disabled', async () => {
const sentry = await import('@main/sentry'); const sentry = await import('@main/sentry');
const sentryApi = { const sentryApi = {
setUser: vi.fn(), setUser: vi.fn(),
setTags: vi.fn(), setTags: vi.fn(),
close: vi.fn(() => Promise.resolve(true)),
}; };
sentry.setMainSentryApiForTesting(sentryApi); sentry.setMainSentryApiForTesting(sentryApi);
sentry.syncTelemetryFlag(false); sentry.syncTelemetryFlag(false);
expect(sentryApi.setUser).toHaveBeenCalledWith(null); expect(sentryApi.setUser).toHaveBeenCalledWith(null);
expect(sentryApi.close).toHaveBeenCalled();
expect(sentry.filterSentryEventForTelemetry({ ok: true })).toBeNull(); 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 () => { it('only exposes safe low-cardinality telemetry tags', async () => {
const { getSafeSentryTelemetryTags } = await import('@main/sentry'); const { getSafeSentryTelemetryTags } = await import('@main/sentry');

View file

@ -128,7 +128,7 @@ describe('CliInstallerService', () => {
expect(status.updateAvailable).toBe(false); 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(); allowConsoleLogs();
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator'); vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
vi.mocked(getCliFlavorUiOptions).mockReturnValue({ vi.mocked(getCliFlavorUiOptions).mockReturnValue({
@ -147,7 +147,6 @@ describe('CliInstallerService', () => {
expect(status.providers.map((provider) => provider.providerId)).toEqual([ expect(status.providers.map((provider) => provider.providerId)).toEqual([
'anthropic', 'anthropic',
'codex', 'codex',
'gemini',
'opencode', 'opencode',
]); ]);
expect(openCodeStatus).toMatchObject({ 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 () => { it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
allowConsoleLogs(); allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

View file

@ -13,7 +13,9 @@ const execCliMock = vi.fn();
const buildProviderAwareCliEnvMock = vi.fn(); const buildProviderAwareCliEnvMock = vi.fn();
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>(); const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>(); 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)); const enrichProviderStatusesMock = vi.fn((providers) => Promise.resolve(providers));
vi.mock('@main/utils/childProcess', () => ({ vi.mock('@main/utils/childProcess', () => ({
@ -22,6 +24,7 @@ vi.mock('@main/utils/childProcess', () => ({
vi.mock('@main/utils/shellEnv', () => ({ vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(), resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
resolveInteractiveShellEnvBestEffort: () => resolveInteractiveShellEnvMock(),
})); }));
vi.mock('fs', () => ({ 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) => { execCliMock.mockImplementation((_binaryPath, args, options) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const env = options?.env ?? {}; 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') { if (normalizedArgs === 'auth status --json --provider all') {
return Promise.resolve({ return Promise.resolve({
stdout: JSON.stringify({ stdout: JSON.stringify({
@ -183,7 +193,12 @@ describe('ClaudeMultimodelBridgeService', () => {
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator'); 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({ expect(providers[0]).toMatchObject({
providerId: 'anthropic', providerId: 'anthropic',
authenticated: true, authenticated: true,
@ -205,6 +220,20 @@ describe('ClaudeMultimodelBridgeService', () => {
}, },
}); });
expect(providers[2]).toMatchObject({ 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', providerId: 'gemini',
displayName: 'Gemini', displayName: 'Gemini',
supported: true, supported: true,
@ -219,21 +248,102 @@ describe('ClaudeMultimodelBridgeService', () => {
projectId: 'demo-project', 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 = { const providerPayloads = {
anthropic: { anthropic: {
supported: true, supported: true,
@ -311,24 +421,31 @@ describe('ClaudeMultimodelBridgeService', () => {
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate); 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(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'runtime status --json --provider anthropic', 'runtime status --json --provider anthropic --summary',
'runtime status --json --provider codex', 'runtime status --json --provider codex --summary',
'runtime status --json --provider gemini', 'runtime status --json --provider opencode --summary',
'runtime status --json --provider opencode',
]) ])
); );
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).not.toContain(
'runtime status --json --provider gemini --summary'
);
expect( expect(
execCliMock.mock.calls execCliMock.mock.calls
.filter((call) => call[1].join(' ').startsWith('runtime status --json --provider ')) .filter((call) => call[1].join(' ').startsWith('runtime status --json --provider '))
.map((call) => call[2]?.maxBuffer) .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([ expect(providers.map((provider) => provider.providerId)).toEqual([
'anthropic', 'anthropic',
'codex', 'codex',
'gemini',
'opencode', 'opencode',
]); ]);
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({ expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
@ -340,6 +457,717 @@ describe('ClaudeMultimodelBridgeService', () => {
expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual(providers); 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 () => { it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
buildProviderAwareCliEnvMock.mockResolvedValue({ buildProviderAwareCliEnvMock.mockResolvedValue({
env: { HOME: '/Users/tester' }, env: { HOME: '/Users/tester' },
@ -836,7 +1664,10 @@ describe('ClaudeMultimodelBridgeService', () => {
execCliMock.mockImplementation((_binaryPath, args) => { execCliMock.mockImplementation((_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; 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({ return Promise.resolve({
stdout: JSON.stringify({ stdout: JSON.stringify({
providers: { 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 () => { it('returns the stored Anthropic API key for team helper mode only in api_key auth mode', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({ const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'ANTHROPIC_API_KEY', envVarName: 'ANTHROPIC_API_KEY',

View file

@ -32,6 +32,40 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded'); 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', () => { it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => {
const record = { const record = {
diagnostics: [ 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', () => { it('selects auth errors over bridge timeouts', () => {
const selected = selectRuntimeDiagnosticClassification([ const selected = selectRuntimeDiagnosticClassification([
'OpenCode bridge command timed out', 'OpenCode bridge command timed out',

View file

@ -363,6 +363,107 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
expect(advisory?.message).not.toContain('Latest assistant message'); 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 () => { it('classifies terminal OpenCode protocol proof failures as warnings, not provider errors', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir); 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[] = []; const started: string[] = [];
let activeCount = 0; let activeCount = 0;
let maxActiveCount = 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(() => await vi.waitFor(() =>
expect(started).toEqual(['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free']) expect(started).toEqual(['opencode/minimax-m2.5-free', 'opencode/nemotron-3-super-free'])
); );
expect(maxActiveCount).toBe(2); expect(maxActiveCount).toBe(1);
expect(releases.has('opencode/big-pickle')).toBe(false);
releases.get('opencode/nemotron-3-super-free')?.(); releases.get('opencode/nemotron-3-super-free')?.();
await vi.waitFor(() => await vi.waitFor(() =>
@ -1066,10 +1071,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
'opencode/big-pickle', 'opencode/big-pickle',
]) ])
); );
expect(maxActiveCount).toBe(2); expect(maxActiveCount).toBe(1);
releases.get('opencode/big-pickle')?.(); releases.get('opencode/big-pickle')?.();
releases.get('opencode/minimax-m2.5-free')?.();
const result = await resultPromise; const result = await resultPromise;
@ -1079,7 +1083,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
'Selected model opencode/nemotron-3-super-free verified for launch.', 'Selected model opencode/nemotron-3-super-free verified for launch.',
]); ]);
expect(result.warnings).toEqual([ 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 }[]; worktrees: { path: string }[];
}[]; }[];
teams: { teamName: string; displayName: 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; const storeState = {} as StoreState;
@ -83,12 +86,21 @@ vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
})); }));
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({ 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( React.createElement(
'div', 'div',
{ {
'data-testid': 'sidebar-task-item', 'data-testid': 'sidebar-task-item',
'data-hide-project-name': hideProjectName ? 'true' : 'false', 'data-hide-project-name': hideProjectName ? 'true' : 'false',
'data-team-offline': teamOffline ? 'true' : 'false',
}, },
task.subject task.subject
), ),
@ -189,6 +201,9 @@ describe('GlobalTaskList project grouping', () => {
storeState.viewMode = 'flat'; storeState.viewMode = 'flat';
storeState.repositoryGroups = []; storeState.repositoryGroups = [];
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }]; storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
storeState.provisioningRuns = {};
storeState.currentProvisioningRunIdByTeam = {};
storeState.leadActivityByTeam = {};
toggleCollapsedGroup.mockReset(); toggleCollapsedGroup.mockReset();
taskLocalState.isPinned.mockClear(); taskLocalState.isPinned.mockClear();
taskLocalState.isArchived.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 () => { it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1)); 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 () => { it('can hide the project label when the parent already groups by project', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); 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 () => { it('virtualizes large OpenCode model lists instead of rendering every model tile', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const models = Array.from( 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 () => { it('does not count generic one-shot diagnostic timeouts as model timeouts', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); 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 () => { it('keeps stale OpenCode model-scoped runtime failures provider-scoped', async () => {
const runtimeFailure = const runtimeFailure =
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?'; '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(); 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', () => ({ vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => TooltipProvider: ({
React.createElement(React.Fragment, null, children), 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 }) => TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children), React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) => TooltipContent: ({
React.createElement('div', null, children), children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => React.createElement('div', { className, 'data-testid': 'tooltip-content' }, children),
})); }));
vi.mock('@renderer/hooks/useTheme', () => ({ 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 () => { it('keeps runtime-pending launch status visible even when the teammate has an active task', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');
@ -535,7 +724,7 @@ describe('MemberCard starting-state visuals', () => {
restartable: false, restartable: false,
providerId: 'opencode', providerId: 'opencode',
pid: 333, pid: 333,
pidSource: 'opencode_bridge', runtimeLoadScope: 'shared-host',
rssBytes: 183.9 * 1024 * 1024, rssBytes: 183.9 * 1024 * 1024,
updatedAt: '2026-04-24T12:00:00.000Z', updatedAt: '2026-04-24T12:00:00.000Z',
}, },
@ -575,11 +764,23 @@ describe('MemberCard starting-state visuals', () => {
pidSource: 'tmux_child', pidSource: 'tmux_child',
rssBytes: 238.3 * 1024 * 1024, rssBytes: 238.3 * 1024 * 1024,
cpuPercent: 14, cpuPercent: 14,
primaryCpuPercent: 4,
primaryRssBytes: 210 * 1024 * 1024,
childCpuPercent: 10,
childRssBytes: 28.3 * 1024 * 1024,
processCount: 3,
runtimeLoadScope: 'process-tree',
resourceHistory: [ resourceHistory: [
{ {
timestamp: '2026-04-24T12:00:00.000Z', timestamp: '2026-04-24T12:00:00.000Z',
rssBytes: 220 * 1024 * 1024, 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', pidSource: 'tmux_child',
pid: 222, pid: 222,
}, },
@ -587,12 +788,23 @@ describe('MemberCard starting-state visuals', () => {
timestamp: '2026-04-24T12:00:05.000Z', timestamp: '2026-04-24T12:00:05.000Z',
rssBytes: 238.3 * 1024 * 1024, rssBytes: 238.3 * 1024 * 1024,
cpuPercent: 14, cpuPercent: 14,
primaryCpuPercent: 4,
primaryRssBytes: 210 * 1024 * 1024,
childCpuPercent: 10,
childRssBytes: 28.3 * 1024 * 1024,
processCount: 3,
runtimeLoadScope: 'process-tree',
pidSource: 'tmux_child', pidSource: 'tmux_child',
pid: 222, pid: 222,
}, },
], ],
updatedAt: '2026-04-24T12:00:05.000Z', updatedAt: '2026-04-24T12:00:05.000Z',
}, },
runtimeTelemetryVisible: true,
runtimeTelemetryScale: {
cpuCapPercent: 100,
memoryCapBytes: 512 * 1024 * 1024,
},
isTeamAlive: true, isTeamAlive: true,
isTeamProvisioning: false, isTeamProvisioning: false,
}) })
@ -603,6 +815,112 @@ describe('MemberCard starting-state visuals', () => {
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]'); const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
expect(strip).not.toBeNull(); expect(strip).not.toBeNull();
expect(strip?.querySelector('path[fill="#22c55e"]')).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(); expect(strip?.querySelector('path[stroke="#3b82f6"]')).not.toBeNull();
await act(async () => { 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 pendingSentAtMs = Date.parse('2026-04-08T12:02:00.000Z');
const pending = { forge: pendingSentAtMs };
const messages: InboxMessage[] = [ const messages: InboxMessage[] = [
makeMessage({ makeMessage({
messageId: 'user-send', 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', () => { it('clears pending replies when the team lead answers through a visible lead thought', () => {

View file

@ -61,4 +61,22 @@ describe('ScopeWarningBanner', () => {
await cleanup(); 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, DialogDescription,
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } 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 host: HTMLDivElement;
let root: ReturnType<typeof createRoot>; let root: ReturnType<typeof createRoot>;
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView;
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = vi.fn();
host = document.createElement('div'); host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
root = createRoot(host); root = createRoot(host);
@ -25,6 +35,7 @@ describe('DialogContent FocusScope integration', () => {
root.unmount(); root.unmount();
}); });
document.body.innerHTML = ''; document.body.innerHTML = '';
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@ -52,4 +63,31 @@ describe('DialogContent FocusScope integration', () => {
expect(document.body.textContent).toContain('Create team updated'); 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', () => { 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([ const current = createMultimodelStatus([
createMultimodelProvider({ createMultimodelProvider({
providerId: 'opencode', providerId: 'opencode',
@ -202,10 +202,11 @@ describe('cliInstallerSlice', () => {
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject( expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
{ {
supported: true, supported: false,
authenticated: true, authenticated: false,
authMethod: 'opencode_managed', authMethod: null,
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, 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([ const current = createMultimodelStatus([
createMultimodelProvider({ createMultimodelProvider({
providerId: 'opencode', providerId: 'opencode',
@ -336,8 +377,10 @@ describe('cliInstallerSlice', () => {
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject( expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
{ {
authenticated: true, authenticated: false,
authMethod: 'opencode_managed', authMethod: null,
verificationState: 'error',
statusMessage: 'OpenCode CLI not found',
models: ['opencode/minimax-m2.5-free'], 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', () => { describe('OpenCode runtime installer actions', () => {
@ -706,7 +802,6 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliProviderStatusLoading).toEqual({ expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false, anthropic: false,
codex: false, codex: false,
gemini: false,
opencode: false, opencode: false,
}); });
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
@ -786,7 +881,6 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliProviderStatusLoading).toEqual({ expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false, anthropic: false,
codex: true, codex: true,
gemini: false,
opencode: false, opencode: false,
}); });
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1); expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1);
@ -806,7 +900,6 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliProviderStatusLoading).toEqual({ expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false, anthropic: false,
codex: false, codex: false,
gemini: false,
opencode: false, opencode: false,
}); });
expect( expect(
@ -896,7 +989,6 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliProviderStatusLoading).toEqual({ expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false, anthropic: false,
codex: false, codex: false,
gemini: false,
opencode: false, opencode: false,
}); });
expect( expect(
@ -983,6 +1075,72 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false); 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 () => { 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; let resolveProviderStatus!: (value: CliInstallationStatus['providers'][number]) => void;
const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>( const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>(
@ -1037,6 +1195,83 @@ describe('cliInstallerSlice', () => {
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false); 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 () => { it('keeps OpenCode refresh status-only even when model verification is requested', async () => {
const nextProvider = createMultimodelProvider({ const nextProvider = createMultimodelProvider({
providerId: 'opencode', providerId: 'opencode',

View file

@ -4323,6 +4323,102 @@ describe('teamSlice actions', () => {
expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(nextSnapshot); 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 () => { it('updates runtime snapshots when copy-diagnostics details change', async () => {
const store = createSliceStore(); const store = createSliceStore();
const snapshot = createRuntimeSnapshot({ const snapshot = createRuntimeSnapshot({

View file

@ -806,6 +806,31 @@ describe('memberHelpers spawn-aware presence', () => {
).toContain('Anthropic authentication error'); ).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', () => { it('formats raw OpenCode protocol advisory reasons before showing them in titles', () => {
const advisory = { const advisory = {
kind: 'api_error' as const, kind: 'api_error' as const,

View file

@ -90,4 +90,28 @@ describe('member launch diagnostics', () => {
); );
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"'); 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'; 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. // time which is unavailable in the vitest/happy-dom environment.
const sentryNoOp = { const sentryNoOp = {
init: vi.fn(), init: vi.fn(),
@ -17,6 +17,7 @@ const sentryNoOp = {
captureException: vi.fn(), captureException: vi.fn(),
setUser: vi.fn(), setUser: vi.fn(),
setTags: vi.fn(), setTags: vi.fn(),
close: vi.fn(() => Promise.resolve(true)),
startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()), startSpan: vi.fn((_opts: unknown, fn: () => unknown) => fn()),
withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.fn() })), withScope: vi.fn((fn: (scope: unknown) => void) => fn({ setContext: vi.fn() })),
browserTracingIntegration: vi.fn(() => ({ browserTracingIntegration: vi.fn(() => ({

View file

@ -23,6 +23,10 @@ export default defineConfig({
'@renderer': resolve(__dirname, 'src/renderer'), '@renderer': resolve(__dirname, 'src/renderer'),
'@preload': resolve(__dirname, 'src/preload'), '@preload': resolve(__dirname, 'src/preload'),
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts'), '@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: resolve(__dirname, 'node_modules/react'),
'react-dom': resolve(__dirname, 'node_modules/react-dom'), 'react-dom': resolve(__dirname, 'node_modules/react-dom'),
}, },