feat(team): harden member runtime liveness

This commit is contained in:
777genius 2026-04-24 16:18:12 +03:00
parent 267a192329
commit d517f2b320
34 changed files with 4089 additions and 294 deletions

File diff suppressed because it is too large Load diff

View file

@ -279,7 +279,13 @@ function drawLaunchStage(
for (let index = 0; index < 3; index += 1) {
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
ctx.beginPath();
ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2);
ctx.arc(
x + Math.cos(angle) * dotOrbit,
y + Math.sin(angle) * dotOrbit,
1.7,
0,
Math.PI * 2
);
ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72);
ctx.fill();
}
@ -736,6 +742,13 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri
return hexWithAlpha('#f59e0b', 0.92);
case 'runtime_pending':
return hexWithAlpha('#67e8f9', 0.9);
case 'shell_only':
case 'runtime_candidate':
return hexWithAlpha('#f97316', 0.9);
case 'registered_only':
return hexWithAlpha('#a1a1aa', 0.82);
case 'stale_runtime':
return hexWithAlpha('#ef4444', 0.82);
case 'settling':
return hexWithAlpha('#22c55e', 0.9);
case 'error':

View file

@ -22,6 +22,10 @@ export type GraphLaunchVisualState =
| 'spawning'
| 'permission_pending'
| 'runtime_pending'
| 'shell_only'
| 'runtime_candidate'
| 'registered_only'
| 'stale_runtime'
| 'settling'
| 'error';

View file

@ -19,6 +19,7 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
: defaultRuntimeCacheRoot;
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
const runtimeDisplayName = 'teams orchestrator';
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
function shouldUseWindowsShell(cmd) {
@ -108,9 +109,10 @@ function getPlatformAssetKey() {
}
function getReleaseAssetUrl(runtimeLock, asset) {
const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
? runtimeLock.releaseTag.trim()
: runtimeLock.sourceRef;
const releaseTag =
typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
? runtimeLock.releaseTag.trim()
: runtimeLock.sourceRef;
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
}
@ -152,9 +154,7 @@ function truncateMiddle(value, maxLength) {
function buildProgressBar(progressRatio, width) {
const safeWidth = Math.max(10, width);
const clampedRatio = Number.isFinite(progressRatio)
? Math.min(1, Math.max(0, progressRatio))
: 0;
const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0;
const filledWidth = Math.round(safeWidth * clampedRatio);
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
}
@ -164,7 +164,8 @@ function supportsProgressRedraw() {
}
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
const columns =
process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
const ratio = hasTotal ? writtenBytes / totalBytes : 0;
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
const bytesText = hasTotal
@ -196,6 +197,16 @@ function readBinaryVersion(binaryPath) {
return runAndCapture(binaryPath, ['--version']);
}
function formatRuntimeVersionForDisplay(versionText) {
const trimmed = versionText.trim();
if (!trimmed) {
return runtimeDisplayName;
}
const versionOnly = trimmed.replace(/\s*\([^)]*\)\s*$/, '');
return `${versionOnly} (${runtimeDisplayName})`;
}
function isExecutable(filePath) {
if (!fs.existsSync(filePath)) {
return false;
@ -305,7 +316,10 @@ async function downloadWithProgress(url, destinationPath) {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
} else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) {
} else if (
(hasTotal && lastLoggedPercent < 100) ||
(!hasTotal && writtenBytes !== lastLoggedBytes)
) {
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
}
}
@ -511,7 +525,9 @@ async function main() {
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
}
process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`);
process.stdout.write(
`Runtime version: ${formatRuntimeVersionForDisplay(resolvedRuntime.versionText)}\n`
);
const uiEnv = {
...process.env,

View file

@ -1,5 +1,9 @@
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
import { TmuxPlatformCommandExecutor } from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
import {
TmuxPlatformCommandExecutor,
type RuntimeProcessTableRow,
type TmuxPaneRuntimeInfo,
} from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
const runtimeStatusSource = new TmuxStatusSourceAdapter();
const runtimeCommandExecutor = new TmuxPlatformCommandExecutor();
@ -24,6 +28,18 @@ export async function listTmuxPanePidsForCurrentPlatform(
return runtimeCommandExecutor.listPanePids(paneIds);
}
export async function listTmuxPaneRuntimeInfoForCurrentPlatform(
paneIds: readonly string[]
): Promise<Map<string, TmuxPaneRuntimeInfo>> {
return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
}
export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
RuntimeProcessTableRow[]
> {
return runtimeCommandExecutor.listRuntimeProcesses();
}
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
runtimeCommandExecutor.killPaneSync(paneId);
invalidateTmuxRuntimeStatusCache();

View file

@ -9,5 +9,11 @@ export {
isTmuxRuntimeReadyForCurrentPlatform,
killTmuxPaneForCurrentPlatform,
killTmuxPaneForCurrentPlatformSync,
listRuntimeProcessesForCurrentTmuxPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
} from './composition/runtimeSupport';
export type {
RuntimeProcessTableRow,
TmuxPaneRuntimeInfo,
} from './infrastructure/runtime/TmuxPlatformCommandExecutor';

View file

@ -12,6 +12,43 @@ interface ExecResult {
stderr: string;
}
export interface TmuxPaneRuntimeInfo {
paneId: string;
panePid: number;
currentCommand?: string;
currentPath?: string;
sessionName?: string;
windowName?: string;
}
export interface RuntimeProcessTableRow {
pid: number;
ppid: number;
command: string;
}
export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
const rows: RuntimeProcessTableRow[] = [];
for (const line of output.split('\n')) {
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
if (!match) continue;
const pid = Number.parseInt(match[1], 10);
const ppid = Number.parseInt(match[2], 10);
const command = match[3]?.trim() ?? '';
if (
Number.isFinite(pid) &&
pid > 0 &&
Number.isFinite(ppid) &&
ppid >= 0 &&
command.length > 0
) {
rows.push({ pid, ppid, command });
}
}
return rows;
}
export class TmuxPlatformCommandExecutor {
readonly #wslService: TmuxWslService;
readonly #packageManagerResolver: TmuxPackageManagerResolver;
@ -54,34 +91,70 @@ export class TmuxPlatformCommandExecutor {
}
}
async listPanePids(paneIds: readonly string[]): Promise<Map<string, number>> {
async listPaneRuntimeInfo(paneIds: readonly string[]): Promise<Map<string, TmuxPaneRuntimeInfo>> {
const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
if (normalizedPaneIds.length === 0) {
return new Map();
}
const result = await this.execTmux(
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
3_000
);
const format = [
'#{pane_id}',
'#{pane_pid}',
'#{pane_current_command}',
'#{pane_current_path}',
'#{session_name}',
'#{window_name}',
].join('\t');
const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000);
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Failed to list tmux panes');
}
const wanted = new Set(normalizedPaneIds);
const panePidById = new Map<string, number>();
const paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const [paneId = '', rawPid = ''] = trimmed.split('\t');
const [
paneId = '',
rawPid = '',
currentCommand = '',
currentPath = '',
sessionName = '',
windowName = '',
] = trimmed.split('\t');
const normalizedPaneId = paneId.trim();
if (!wanted.has(normalizedPaneId)) continue;
const pid = Number.parseInt(rawPid.trim(), 10);
if (Number.isFinite(pid) && pid > 0) {
panePidById.set(normalizedPaneId, pid);
paneInfoById.set(normalizedPaneId, {
paneId: normalizedPaneId,
panePid: pid,
currentCommand: currentCommand.trim() || undefined,
currentPath: currentPath.trim() || undefined,
sessionName: sessionName.trim() || undefined,
windowName: windowName.trim() || undefined,
});
}
}
return panePidById;
return paneInfoById;
}
async listPanePids(paneIds: readonly string[]): Promise<Map<string, number>> {
const info = await this.listPaneRuntimeInfo(paneIds);
return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid]));
}
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
const result =
process.platform === 'win32'
? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command='])
: await this.#execNativePs();
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Failed to list runtime processes');
}
return parseRuntimeProcessTable(result.stdout);
}
killPaneSync(paneId: string): void {
@ -125,6 +198,29 @@ export class TmuxPlatformCommandExecutor {
return [...candidates];
}
async #execNativePs(): Promise<ExecResult> {
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
return new Promise((resolve) => {
execFile(
'ps',
['-ax', '-o', 'pid=,ppid=,command='],
{ env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
(error, stdout, stderr) => {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as NodeJS.ErrnoException).code
: undefined;
resolve({
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
stdout: String(stdout),
stderr: String(stderr) || (error instanceof Error ? error.message : ''),
});
}
);
});
}
async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise<string> {
const platform =
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'

View file

@ -78,7 +78,8 @@ describe('TmuxPlatformCommandExecutor', () => {
);
vi.spyOn(executor, 'execTmux').mockResolvedValue({
exitCode: 0,
stdout: '%1\t111\n%2\t222\n%3\tnot-a-pid\n',
stdout:
'%1\t111\tzsh\t/tmp\tteam\tmain\n%2\t222\tnode\t/project\tteam\tworker\n%3\tnot-a-pid\tzsh\t/tmp\tteam\tmain\n',
stderr: '',
});
@ -86,7 +87,12 @@ describe('TmuxPlatformCommandExecutor', () => {
new Map([['%2', 222]])
);
expect(executor.execTmux).toHaveBeenCalledWith(
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
[
'list-panes',
'-a',
'-F',
'#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}\t#{session_name}\t#{window_name}',
],
3_000
);
});

View file

@ -268,6 +268,23 @@ export class TmuxWslService {
return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout);
}
async execInPreferredDistro(
args: string[],
preferredDistroName?: string | null,
timeout = 5_000
): Promise<ExecWslResult> {
const distroName = preferredDistroName ?? (await this.probe()).preference?.preferredDistroName;
if (!distroName) {
return {
exitCode: 1,
stdout: '',
stderr: 'No WSL distribution is available.',
};
}
return this.#run(['-d', distroName, '-e', ...args], timeout);
}
getPersistedPreferredDistroSync(): string | null {
return this.#preferenceStore.getPreferredDistroSync();
}

View file

@ -12,6 +12,9 @@ import type {
PersistedTeamLaunchSnapshot,
PersistedTeamLaunchSummary,
ProviderModelLaunchIdentity,
TeamAgentRuntimeDiagnosticSeverity,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamLaunchAggregateState,
} from '@shared/types';
@ -37,8 +40,13 @@ type RuntimeMemberSpawnState = Pick<
| 'bootstrapConfirmed'
| 'hardFailure'
| 'pendingPermissionRequestIds'
| 'livenessKind'
| 'runtimeDiagnostic'
| 'runtimeDiagnosticSeverity'
| 'livenessLastCheckedAt'
| 'firstSpawnAcceptedAt'
| 'lastHeartbeatAt'
| 'runtimeModel'
| 'updatedAt'
>;
@ -59,6 +67,41 @@ function normalizeRuntimePid(value: unknown): number | undefined {
: undefined;
}
function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | undefined {
return value === 'confirmed_bootstrap' ||
value === 'runtime_process' ||
value === 'runtime_process_candidate' ||
value === 'permission_blocked' ||
value === 'shell_only' ||
value === 'registered_only' ||
value === 'stale_metadata' ||
value === 'not_found'
? value
: undefined;
}
function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined {
return value === 'lead_process' ||
value === 'tmux_pane' ||
value === 'tmux_child' ||
value === 'agent_process_table' ||
value === 'opencode_bridge' ||
value === 'runtime_bootstrap' ||
value === 'persisted_metadata'
? value
: undefined;
}
function normalizeDiagnosticSeverity(
value: unknown
): TeamAgentRuntimeDiagnosticSeverity | undefined {
return value === 'info' || value === 'warning' || value === 'error' ? value : undefined;
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeMemberName(name: string): string {
return name.trim();
}
@ -110,6 +153,11 @@ export function summarizePersistedLaunchMembers(
let pendingCount = 0;
let failedCount = 0;
let runtimeAlivePendingCount = 0;
let shellOnlyPendingCount = 0;
let runtimeProcessPendingCount = 0;
let runtimeCandidatePendingCount = 0;
let noRuntimePendingCount = 0;
let permissionPendingCount = 0;
const normalizedExpected = expectedMembers.map(normalizeMemberName).filter(Boolean);
const memberNames = Array.from(
new Set([
@ -136,9 +184,31 @@ export function summarizePersistedLaunchMembers(
if (entry.runtimeAlive) {
runtimeAlivePendingCount += 1;
}
if (entry.launchState === 'runtime_pending_permission') {
permissionPendingCount += 1;
}
if (entry.livenessKind === 'shell_only') {
shellOnlyPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process') {
runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1;
} else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') {
noRuntimePendingCount += 1;
}
}
return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount };
return {
confirmedCount,
pendingCount,
failedCount,
runtimeAlivePendingCount,
shellOnlyPendingCount,
runtimeProcessPendingCount,
runtimeCandidatePendingCount,
noRuntimePendingCount,
permissionPendingCount,
};
}
export function hasMixedPersistedLaunchMetadata(
@ -340,6 +410,12 @@ function normalizePersistedMemberState(
parsed.pendingPermissionRequestIds
),
runtimePid: normalizeRuntimePid(parsed.runtimePid),
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
livenessKind: normalizeLivenessKind(parsed.livenessKind),
pidSource: normalizePidSource(parsed.pidSource),
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
runtimeLastSeenAt: normalizeOptionalString(parsed.runtimeLastSeenAt),
firstSpawnAcceptedAt:
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
lastHeartbeatAt:
@ -492,6 +568,10 @@ export function snapshotFromRuntimeMemberStatuses(params: {
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
? [...new Set(runtime.pendingPermissionRequestIds)]
: undefined,
livenessKind: runtime?.livenessKind,
runtimeDiagnostic: runtime?.runtimeDiagnostic,
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
lastHeartbeatAt: runtime?.lastHeartbeatAt,
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
@ -555,6 +635,10 @@ export function snapshotToMemberSpawnStatuses(
bootstrapConfirmed: entry.bootstrapConfirmed,
hardFailure: entry.hardFailure,
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
livenessKind: entry.livenessKind,
runtimeDiagnostic: entry.runtimeDiagnostic,
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
livenessLastCheckedAt: entry.runtimeLastSeenAt ?? entry.lastEvaluatedAt,
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
lastHeartbeatAt: entry.lastHeartbeatAt,
updatedAt: entry.lastEvaluatedAt,

View file

@ -19,6 +19,11 @@ export interface LaunchStateSummary {
pendingCount?: number;
failedCount?: number;
runtimeAlivePendingCount?: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
runtimeCandidatePendingCount?: number;
noRuntimePendingCount?: number;
permissionPendingCount?: number;
}
export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary {
@ -73,6 +78,11 @@ export function createLaunchStateSummary(
pendingCount: snapshot.summary.pendingCount,
failedCount: snapshot.summary.failedCount,
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount,
runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount,
runtimeCandidatePendingCount: snapshot.summary.runtimeCandidatePendingCount,
noRuntimePendingCount: snapshot.summary.noRuntimePendingCount,
permissionPendingCount: snapshot.summary.permissionPendingCount,
};
}
@ -147,6 +157,27 @@ export function normalizePersistedLaunchSummaryProjection(
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount;
}
if (typeof record.shellOnlyPendingCount === 'number' && record.shellOnlyPendingCount >= 0) {
normalized.shellOnlyPendingCount = record.shellOnlyPendingCount;
}
if (
typeof record.runtimeProcessPendingCount === 'number' &&
record.runtimeProcessPendingCount >= 0
) {
normalized.runtimeProcessPendingCount = record.runtimeProcessPendingCount;
}
if (
typeof record.runtimeCandidatePendingCount === 'number' &&
record.runtimeCandidatePendingCount >= 0
) {
normalized.runtimeCandidatePendingCount = record.runtimeCandidatePendingCount;
}
if (typeof record.noRuntimePendingCount === 'number' && record.noRuntimePendingCount >= 0) {
normalized.noRuntimePendingCount = record.noRuntimePendingCount;
}
if (typeof record.permissionPendingCount === 'number' && record.permissionPendingCount >= 0) {
normalized.permissionPendingCount = record.permissionPendingCount;
}
normalized.launchUpdatedAt = updatedAt;
return normalized;
}

View file

@ -227,17 +227,7 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
}
// 2. Dev mode — prefer built dist for reliable direct execution
const builtEntry = getBuiltServerEntry();
checked.push(builtEntry);
if (await pathExists(builtEntry)) {
return {
command: await resolveNodePath(),
args: [builtEntry],
};
}
// 3. Dev mode fallback — run source directly through a local tsx binary
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
const sourceEntry = getSourceServerEntry();
checked.push(sourceEntry);
if (await pathExists(sourceEntry)) {
@ -252,6 +242,16 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
}
}
// 3. Dev mode fallback — use built dist when source execution is unavailable
const builtEntry = getBuiltServerEntry();
checked.push(builtEntry);
if (await pathExists(builtEntry)) {
return {
command: await resolveNodePath(),
args: [builtEntry],
};
}
throw new Error(
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
);

View file

@ -0,0 +1,14 @@
export type TeamMemberLivenessMode = 'diagnostics' | 'strict';
export const CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV = 'CLAUDE_TEAM_MEMBER_LIVENESS_MODE';
export function resolveTeamMemberLivenessModeFromEnv(
env: NodeJS.ProcessEnv = process.env
): TeamMemberLivenessMode {
const raw = env[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]?.trim().toLowerCase();
return raw === 'strict' ? 'strict' : 'diagnostics';
}
export function isStrictTeamMemberLivenessMode(env: NodeJS.ProcessEnv = process.env): boolean {
return resolveTeamMemberLivenessModeFromEnv(env) === 'strict';
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,350 @@
import type { RuntimeProcessTableRow, TmuxPaneRuntimeInfo } from '@features/tmux-installer/main';
import type {
MemberSpawnStatusEntry,
TeamAgentRuntimeBackendType,
TeamAgentRuntimeDiagnosticSeverity,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamProviderId,
} from '@shared/types';
export interface ResolveTeamMemberRuntimeLivenessInput {
teamName: string;
memberName: string;
agentId?: string;
backendType?: TeamAgentRuntimeBackendType;
providerId?: TeamProviderId;
tmuxPaneId?: string;
persistedRuntimePid?: number;
persistedRuntimeSessionId?: string;
trackedSpawnStatus?: MemberSpawnStatusEntry;
runtimePid?: number;
runtimeSessionId?: string;
pane?: TmuxPaneRuntimeInfo;
processRows: readonly RuntimeProcessTableRow[];
processTableAvailable: boolean;
nowIso: string;
}
export interface ResolvedTeamMemberRuntimeLiveness {
alive: boolean;
livenessKind: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
pid?: number;
metricsPid?: number;
panePid?: number;
paneCurrentCommand?: string;
processCommand?: string;
runtimeSessionId?: string;
runtimeLastSeenAt?: string;
runtimeDiagnostic: string;
runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity;
diagnostics: string[];
}
const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'login', 'tmux']);
const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
function basenameCommand(command: string | undefined): string {
const firstToken = command?.trim().split(/\s+/, 1)[0] ?? '';
const base = firstToken.split(/[\\/]/).pop() ?? firstToken;
return base.replace(/^-/, '').toLowerCase();
}
export function isShellLikeCommand(command: string | undefined): boolean {
return SHELL_COMMAND_NAMES.has(basenameCommand(command));
}
export function sanitizeProcessCommandForDiagnostics(
command: string | undefined
): string | undefined {
const trimmed = command?.trim();
if (!trimmed) return undefined;
return trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]').slice(0, 500);
}
function escapeRegexLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function extractCliArgValues(command: string, argName: string): string[] {
const escapedArg = escapeRegexLiteral(argName);
const pattern = new RegExp(
`(?:^|\\s)${escapedArg}(?:=|\\s+)("([^"]*)"|'([^']*)'|([^\\s]+))`,
'g'
);
const values: string[] = [];
for (const match of command.matchAll(pattern)) {
const value = (match[2] ?? match[3] ?? match[4] ?? '').trim();
if (value) values.push(value);
}
return values;
}
export function commandArgEquals(
command: string,
argName: string,
expected: string | undefined
): boolean {
const normalizedExpected = expected?.trim();
if (!normalizedExpected) return false;
return extractCliArgValues(command, argName).some((value) => value === normalizedExpected);
}
function collectDescendants(
rows: readonly RuntimeProcessTableRow[],
rootPid: number
): RuntimeProcessTableRow[] {
const childrenByParent = new Map<number, RuntimeProcessTableRow[]>();
for (const row of rows) {
const current = childrenByParent.get(row.ppid) ?? [];
current.push(row);
childrenByParent.set(row.ppid, current);
}
const descendants: RuntimeProcessTableRow[] = [];
const queue = [...(childrenByParent.get(rootPid) ?? [])];
const seen = new Set<number>();
while (queue.length > 0) {
const row = queue.shift();
if (!row || seen.has(row.pid)) continue;
seen.add(row.pid);
descendants.push(row);
queue.push(...(childrenByParent.get(row.pid) ?? []));
}
return descendants;
}
function isVerifiedRuntimeProcess(params: {
row: RuntimeProcessTableRow;
teamName: string;
agentId?: string;
}): boolean {
return (
commandArgEquals(params.row.command, '--team-name', params.teamName) &&
commandArgEquals(params.row.command, '--agent-id', params.agentId)
);
}
function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean {
return Boolean(
input.agentId?.trim() ||
input.tmuxPaneId?.trim() ||
input.persistedRuntimePid ||
input.runtimePid ||
input.persistedRuntimeSessionId?.trim() ||
input.runtimeSessionId?.trim() ||
input.backendType
);
}
function result(params: {
alive: boolean;
livenessKind: TeamAgentRuntimeLivenessKind;
runtimeDiagnostic: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
diagnostics?: string[];
pidSource?: TeamAgentRuntimePidSource;
pid?: number;
metricsPid?: number;
panePid?: number;
paneCurrentCommand?: string;
processCommand?: string;
runtimeSessionId?: string;
runtimeLastSeenAt?: string;
}): ResolvedTeamMemberRuntimeLiveness {
return {
alive: params.alive,
livenessKind: params.livenessKind,
runtimeDiagnostic: params.runtimeDiagnostic,
runtimeDiagnosticSeverity: params.runtimeDiagnosticSeverity ?? 'info',
diagnostics: params.diagnostics ?? [params.runtimeDiagnostic],
...(params.pidSource ? { pidSource: params.pidSource } : {}),
...(typeof params.pid === 'number' && params.pid > 0 ? { pid: params.pid } : {}),
...(typeof params.metricsPid === 'number' && params.metricsPid > 0
? { metricsPid: params.metricsPid }
: {}),
...(typeof params.panePid === 'number' && params.panePid > 0
? { panePid: params.panePid }
: {}),
...(params.paneCurrentCommand ? { paneCurrentCommand: params.paneCurrentCommand } : {}),
...(params.processCommand ? { processCommand: params.processCommand } : {}),
...(params.runtimeSessionId ? { runtimeSessionId: params.runtimeSessionId } : {}),
...(params.runtimeLastSeenAt ? { runtimeLastSeenAt: params.runtimeLastSeenAt } : {}),
};
}
export function resolveTeamMemberRuntimeLiveness(
input: ResolveTeamMemberRuntimeLivenessInput
): ResolvedTeamMemberRuntimeLiveness {
const tracked = input.trackedSpawnStatus;
const runtimeSessionId = input.runtimeSessionId ?? input.persistedRuntimeSessionId;
const diagnostics: string[] = [];
if (!input.processTableAvailable) {
diagnostics.push('process table unavailable');
}
if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') {
return result({
alive: true,
livenessKind: 'confirmed_bootstrap',
pidSource: 'runtime_bootstrap',
runtimeSessionId,
runtimeLastSeenAt: tracked.lastHeartbeatAt ?? tracked.updatedAt,
runtimeDiagnostic: 'bootstrap confirmed',
diagnostics: [...diagnostics, 'bootstrap confirmed'],
});
}
if (
tracked?.launchState === 'runtime_pending_permission' ||
(tracked?.pendingPermissionRequestIds?.length ?? 0) > 0
) {
return result({
alive: false,
livenessKind: 'permission_blocked',
runtimeSessionId,
runtimeDiagnostic: 'waiting for permission approval',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'permission approval pending'],
});
}
const verifiedProcess = input.processRows
.filter((row) =>
isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId })
)
.sort((left, right) => right.pid - left.pid)[0];
if (verifiedProcess) {
return result({
alive: true,
livenessKind: 'runtime_process',
pidSource: 'agent_process_table',
pid: verifiedProcess.pid,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(verifiedProcess.command),
runtimeDiagnostic: 'verified runtime process detected',
diagnostics: [...diagnostics, 'matched process table by team-name and agent-id'],
});
}
const runtimePid = input.runtimePid ?? input.persistedRuntimePid;
const runtimePidRow =
typeof runtimePid === 'number' && runtimePid > 0
? input.processRows.find((row) => row.pid === runtimePid)
: undefined;
if (runtimePidRow && input.providerId === 'opencode') {
return result({
alive: true,
livenessKind: 'runtime_process',
pidSource: 'opencode_bridge',
pid: runtimePidRow.pid,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(runtimePidRow.command),
runtimeDiagnostic: 'OpenCode runtime process detected',
diagnostics: [...diagnostics, 'matched OpenCode runtime pid in process table'],
});
}
const pane = input.pane;
if (pane) {
const descendants = collectDescendants(input.processRows, pane.panePid);
const verifiedDescendant = descendants
.filter((row) =>
isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId })
)
.sort((left, right) => right.pid - left.pid)[0];
if (verifiedDescendant) {
return result({
alive: true,
livenessKind: 'runtime_process',
pidSource: 'tmux_child',
pid: verifiedDescendant.pid,
panePid: pane.panePid,
paneCurrentCommand: pane.currentCommand,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(verifiedDescendant.command),
runtimeDiagnostic: 'verified tmux runtime child detected',
diagnostics: [...diagnostics, 'matched tmux descendant by team-name and agent-id'],
});
}
const candidate = descendants.find((row) => !isShellLikeCommand(row.command));
if (candidate) {
return result({
alive: false,
livenessKind: 'runtime_process_candidate',
pidSource: 'tmux_child',
pid: candidate.pid,
panePid: pane.panePid,
paneCurrentCommand: pane.currentCommand,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(candidate.command),
runtimeDiagnostic: 'runtime process candidate detected',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'],
});
}
const shellOnly = isShellLikeCommand(pane.currentCommand);
return result({
alive: false,
livenessKind: shellOnly ? 'shell_only' : 'runtime_process_candidate',
pidSource: 'tmux_pane',
pid: pane.panePid,
panePid: pane.panePid,
paneCurrentCommand: pane.currentCommand,
runtimeSessionId,
runtimeDiagnostic: shellOnly
? `tmux pane foreground command is ${pane.currentCommand ?? 'a shell'}`
: 'tmux pane is alive, but runtime identity is not verified',
runtimeDiagnosticSeverity: shellOnly ? 'warning' : 'info',
diagnostics: [
...diagnostics,
shellOnly
? `tmux pane is alive, but foreground command is ${pane.currentCommand ?? 'a shell'}`
: 'tmux pane exists, but no verified runtime process was found',
],
});
}
if (runtimePid && !runtimePidRow) {
return result({
alive: false,
livenessKind: 'stale_metadata',
pidSource: 'persisted_metadata',
pid: runtimePid,
runtimeSessionId,
runtimeDiagnostic: 'persisted runtime pid is not alive',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'persisted runtime pid was not found in process table'],
});
}
if (hasPersistedEvidence(input)) {
return result({
alive: false,
livenessKind: 'registered_only',
runtimeSessionId,
runtimeDiagnostic: 'registered runtime metadata without live process',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'member has persisted runtime metadata only'],
});
}
return result({
alive: false,
livenessKind: 'not_found',
runtimeDiagnostic: 'runtime process not found',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'runtime process not found'],
});
}
export function isStrongRuntimeEvidence(
value: { livenessKind?: TeamAgentRuntimeLivenessKind } | undefined
): boolean {
return value?.livenessKind === 'confirmed_bootstrap' || value?.livenessKind === 'runtime_process';
}

View file

@ -12,8 +12,12 @@
* diagnostics and completion-time reports.
*/
import type { TeamLaunchDiagnosticItem } from '@shared/types';
export const PROGRESS_LOG_TAIL_LINES = 200;
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500;
/**
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
@ -50,3 +54,29 @@ export function buildProgressAssistantOutput(
const joined = tail.join('\n\n');
return joined.trim().length === 0 ? undefined : joined;
}
function boundDiagnosticText(value: string | undefined): string | undefined {
const trimmed = value?.replace(/\s+/g, ' ').trim();
if (!trimmed) {
return undefined;
}
return trimmed.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT
? `${trimmed.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...`
: trimmed;
}
export function boundLaunchDiagnostics(
items: readonly TeamLaunchDiagnosticItem[] | undefined,
maxItems: number = PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT
): TeamLaunchDiagnosticItem[] | undefined {
if (!items || items.length === 0) {
return undefined;
}
const bounded = items.slice(0, Math.max(1, maxItems)).map((item) => ({
...item,
label: boundDiagnosticText(item.label) ?? item.code,
detail: boundDiagnosticText(item.detail),
}));
return bounded.length > 0 ? bounded : undefined;
}

View file

@ -546,7 +546,25 @@ function mapBridgeMemberToRuntimeEvidence(
const confirmed = launchState === 'confirmed_alive';
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
const failed = launchState === 'failed';
const pendingRuntimeObserved = createdOrBlocked && runtimeMaterialized;
const hasRuntimePid =
typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
const pendingRuntimeObserved = launchState === 'created' && hasRuntimePid;
const livenessKind = confirmed
? 'confirmed_bootstrap'
: pendingRuntimeObserved
? 'runtime_process'
: launchState === 'permission_blocked'
? 'permission_blocked'
: runtimeMaterialized || sessionId
? 'runtime_process_candidate'
: 'registered_only';
const runtimeDiagnostic = pendingRuntimeObserved
? 'OpenCode runtime process reported by bridge'
: launchState === 'permission_blocked'
? 'OpenCode runtime is waiting for permission approval'
: runtimeMaterialized || sessionId
? 'OpenCode session exists without verified runtime pid'
: undefined;
return {
memberName,
providerId: 'opencode',
@ -557,7 +575,7 @@ function mapBridgeMemberToRuntimeEvidence(
: launchState === 'permission_blocked'
? 'runtime_pending_permission'
: 'runtime_pending_bootstrap',
agentToolAccepted: confirmed || pendingRuntimeObserved,
agentToolAccepted: confirmed || createdOrBlocked || runtimeMaterialized,
runtimeAlive: confirmed || pendingRuntimeObserved,
bootstrapConfirmed: confirmed,
hardFailure: failed,
@ -567,9 +585,10 @@ function mapBridgeMemberToRuntimeEvidence(
? [...new Set(pendingPermissionRequestIds)]
: undefined,
sessionId,
...(typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0
? { runtimePid }
: {}),
...(hasRuntimePid ? { runtimePid } : {}),
livenessKind,
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
diagnostics,
};
}

View file

@ -4,6 +4,8 @@ import type {
PersistedTeamLaunchPhase,
PersistedTeamLaunchSnapshot,
TeamAgentRuntimeBackendType,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamLaunchAggregateState,
} from '@shared/types';
@ -73,6 +75,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
sessionId?: string;
backendType?: TeamAgentRuntimeBackendType;
runtimePid?: number;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string;
diagnostics: string[];
}

View file

@ -19,6 +19,7 @@ import { DISPLAY_STEPS } from './provisioningSteps';
import { StepProgressBar } from './StepProgressBar';
import type { StepProgressBarStep } from './StepProgressBar';
import type { TeamLaunchDiagnosticItem } from '@shared/types';
/** Pre-built step definitions for the provisioning stepper. */
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
@ -61,6 +62,8 @@ export interface ProvisioningProgressBlockProps {
cliLogsTail?: string;
/** Accumulated assistant text output for live preview */
assistantOutput?: string;
/** Bounded structured launch diagnostics */
launchDiagnostics?: TeamLaunchDiagnosticItem[];
/** Visual surface chrome for the outer block */
surface?: 'raised' | 'flat';
className?: string;
@ -153,11 +156,13 @@ export const ProvisioningProgressBlock = ({
pid,
cliLogsTail,
assistantOutput,
launchDiagnostics,
surface = 'raised',
className,
}: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error';
@ -293,6 +298,42 @@ export const ProvisioningProgressBlock = ({
errorIndex={errorStepIndex}
/>
</div>
{launchDiagnostics && launchDiagnostics.length > 0 ? (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setDiagnosticsOpen((v) => !v)}
>
{diagnosticsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Diagnostics
</button>
{diagnosticsOpen ? (
<div className="mt-1 space-y-1 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
{launchDiagnostics.map((item) => (
<div key={item.id} className="text-[11px]">
<div
className={cn(
item.severity === 'error'
? 'text-red-400'
: item.severity === 'warning'
? 'text-amber-400'
: 'text-[var(--color-text-secondary)]'
)}
>
{item.label}
</div>
{item.detail ? (
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
{item.detail}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
) : null}
<div className="mt-2">
<button
type="button"

View file

@ -62,6 +62,7 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
pid={presentation.progress.pid}
cliLogsTail={presentation.progress.cliLogsTail}
assistantOutput={presentation.progress.assistantOutput}
launchDiagnostics={presentation.progress.launchDiagnostics}
defaultLiveOutputOpen={presentation.defaultLiveOutputOpen}
defaultLogsOpen={defaultLogsOpen}
onCancel={

View file

@ -25,6 +25,7 @@ import type {
MemberSpawnLivenessSource,
MemberSpawnStatus,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamTaskWithKanban,
} from '@shared/types';
@ -32,6 +33,7 @@ interface MemberCardProps {
member: ResolvedTeamMember;
memberColor: string;
runtimeSummary?: string;
runtimeEntry?: TeamAgentRuntimeEntry;
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
@ -77,6 +79,7 @@ export const MemberCard = ({
member,
memberColor,
runtimeSummary,
runtimeEntry,
taskCounts,
isTeamAlive,
isTeamProvisioning,
@ -113,6 +116,7 @@ export const MemberCard = ({
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
@ -128,7 +132,12 @@ export const MemberCard = ({
const launchVisualState = launchPresentation.launchVisualState;
const launchStatusLabel = launchPresentation.launchStatusLabel;
const displayPresenceLabel =
launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
const colors = getTeamColorSet(memberColor);
@ -159,7 +168,11 @@ export const MemberCard = ({
!runtimeAdvisoryLabel &&
(presenceLabel === 'starting' ||
presenceLabel === 'connecting' ||
launchVisualState === 'runtime_pending');
launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime');
const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel;
const showRuntimeAdvisoryBadge =
!isRemoved &&
@ -283,12 +296,28 @@ export const MemberCard = ({
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
<span className="shrink-0 opacity-60"></span>
) : null}
{memoryLabel ? <span className="shrink-0">{memoryLabel}</span> : null}
{memoryLabel ? (
<span
className="shrink-0"
title={
runtimeEntry?.pidSource === 'tmux_pane'
? 'RSS source: tmux pane shell'
: runtimeEntry?.pidSource
? `PID source: ${runtimeEntry.pidSource}`
: undefined
}
>
{memoryLabel}
</span>
) : null}
</div>
) : null}
</div>
{showLaunchBadge ? (
<span className="flex shrink-0 items-center gap-1">
<span
className="flex shrink-0 items-center gap-1"
title={runtimeEntry?.runtimeDiagnostic}
>
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
aria-label={launchBadgeLabel}

View file

@ -167,6 +167,7 @@ export const MemberDetailDialog = ({
spawnLaunchState={spawnEntry?.launchState}
spawnLivenessSource={spawnEntry?.livenessSource}
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
runtimeEntry={runtimeEntry}
isLaunchSettling={isLaunchSettling}
onUpdateRole={
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
@ -253,6 +254,7 @@ export const MemberDetailDialog = ({
) : runtimeEntry?.pid ? (
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
PID {runtimeEntry.pid}
{runtimeEntry.pidSource ? ` · ${runtimeEntry.pidSource}` : ''}
</div>
) : (
<div className="mr-auto" />

View file

@ -23,11 +23,13 @@ import type {
MemberSpawnLivenessSource,
MemberSpawnStatus,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
} from '@shared/types';
interface MemberDetailHeaderProps {
member: ResolvedTeamMember;
runtimeSummary?: string;
runtimeEntry?: TeamAgentRuntimeEntry;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
@ -43,6 +45,7 @@ interface MemberDetailHeaderProps {
export const MemberDetailHeader = ({
member,
runtimeSummary,
runtimeEntry,
isTeamAlive,
isTeamProvisioning,
leadActivity,
@ -75,6 +78,7 @@ export const MemberDetailHeader = ({
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
@ -91,7 +95,12 @@ export const MemberDetailHeader = ({
const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel
: launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
: launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;

View file

@ -68,6 +68,7 @@ export const MemberHoverCard = ({
memberSpawnSnapshot,
memberSpawnStatuses,
spawnEntry,
runtimeEntry,
leadActivity,
} = useStore(
useShallow((s) => ({
@ -89,6 +90,9 @@ export const MemberHoverCard = ({
spawnEntry: effectiveTeamName
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
: undefined,
runtimeEntry: effectiveTeamName
? s.teamAgentRuntimeByTeam[effectiveTeamName]?.members[name]
: undefined,
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
}))
);
@ -114,6 +118,7 @@ export const MemberHoverCard = ({
spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
@ -130,7 +135,12 @@ export const MemberHoverCard = ({
const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel
: launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
: launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
const currentTask: TeamTaskWithKanban | null = member.currentTaskId

View file

@ -151,6 +151,9 @@ function areMemberSpawnStatusesEquivalent(
leftEntry.hardFailure !== rightEntry.hardFailure ||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
leftEntry.livenessSource !== rightEntry.livenessSource ||
leftEntry.livenessKind !== rightEntry.livenessKind ||
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
leftEntry.runtimeDiagnosticSeverity !== rightEntry.runtimeDiagnosticSeverity ||
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive ||
leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed ||
@ -196,7 +199,13 @@ function areMemberRuntimeEntriesEquivalent(
leftEntry.backendType !== rightEntry?.backendType ||
leftEntry.pid !== rightEntry?.pid ||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
leftEntry.rssBytes !== rightEntry?.rssBytes
leftEntry.rssBytes !== rightEntry?.rssBytes ||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
leftEntry.pidSource !== rightEntry?.pidSource ||
leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand ||
leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic ||
leftEntry.runtimeDiagnosticSeverity !== rightEntry?.runtimeDiagnosticSeverity ||
leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt
) {
return false;
}
@ -332,6 +341,7 @@ export const MemberList = memo(function MemberList({
isRemoved ? undefined : spawnEntry,
isRemoved ? undefined : runtimeEntry
)}
runtimeEntry={isRemoved ? undefined : runtimeEntry}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}

View file

@ -131,7 +131,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
entry.launchState === 'runtime_pending_bootstrap' ||
entry.launchState === 'runtime_pending_permission'
) {
if (entry.runtimeAlive === true) {
if (entry.runtimeAlive === true && entry.livenessKind !== 'shell_only') {
processOnlyAliveCount += 1;
} else {
pendingSpawnCount += 1;
@ -199,7 +199,8 @@ export function getLaunchJoinMilestonesFromMembers({
const snapshotMilestones = {
expectedTeammateCount,
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
processOnlyAliveCount: snapshotSummary.runtimeAlivePendingCount,
processOnlyAliveCount:
snapshotSummary.runtimeProcessPendingCount ?? snapshotSummary.runtimeAlivePendingCount,
pendingSpawnCount: Math.max(
0,
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount

View file

@ -242,6 +242,7 @@ export function initializeNotificationListeners(): () => void {
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let inProgressChangePresencePollInFlight = false;
let teamMessageFallbackPollInFlight = false;
@ -286,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
memberSpawnRefreshTimers.set(teamName, timer);
};
const scheduleTeamAgentRuntimeRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
if (teamAgentRuntimeRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
teamAgentRuntimeRefreshTimers.delete(teamName);
void useStore.getState().fetchTeamAgentRuntime(teamName);
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
teamAgentRuntimeRefreshTimers.set(teamName, timer);
};
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
return;
@ -1194,6 +1208,7 @@ export function initializeNotificationListeners(): () => void {
}
seedCurrentRunIdIfMissing();
scheduleMemberSpawnStatusesRefresh(event.teamName);
scheduleTeamAgentRuntimeRefresh(event.teamName);
return;
}
@ -1276,6 +1291,8 @@ export function initializeNotificationListeners(): () => void {
teamPresenceRefreshTimers = new Map();
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
memberSpawnRefreshTimers = new Map();
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
teamAgentRuntimeRefreshTimers = new Map();
for (const t of toolActivityTimers.values()) clearTimeout(t);
toolActivityTimers = new Map();
teamLastRelevantActivityAt.clear();

View file

@ -702,7 +702,12 @@ function areLaunchSummaryCountsEqual(
left.confirmedCount === right.confirmedCount &&
left.pendingCount === right.pendingCount &&
left.failedCount === right.failedCount &&
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
left.noRuntimePendingCount === right.noRuntimePendingCount &&
left.permissionPendingCount === right.permissionPendingCount
);
}
@ -739,6 +744,9 @@ function areMemberSpawnStatusEntriesEqual(
left.livenessSource === right.livenessSource &&
left.runtimeAlive === right.runtimeAlive &&
left.runtimeModel === right.runtimeModel &&
left.livenessKind === right.livenessKind &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.bootstrapConfirmed === right.bootstrapConfirmed &&
left.hardFailure === right.hardFailure &&
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
@ -809,7 +817,13 @@ function areTeamAgentRuntimeEntriesEqual(
left.backendType === right.backendType &&
left.pid === right.pid &&
left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes
left.rssBytes === right.rssBytes &&
left.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource &&
left.paneCurrentCommand === right.paneCurrentCommand &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.runtimeLastSeenAt === right.runtimeLastSeenAt
);
}

View file

@ -15,6 +15,7 @@ import type {
MemberSpawnStatus,
MemberStatus,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamProviderId,
TeamReviewState,
TeamTaskStatus,
@ -531,6 +532,10 @@ export type MemberLaunchVisualState =
| 'spawning'
| 'permission_pending'
| 'runtime_pending'
| 'shell_only'
| 'runtime_candidate'
| 'registered_only'
| 'stale_runtime'
| 'settling'
| 'error'
| null;
@ -556,7 +561,15 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
case 'permission_pending':
return 'awaiting permission';
case 'runtime_pending':
return 'connecting';
return 'waiting for bootstrap';
case 'shell_only':
return 'shell only';
case 'runtime_candidate':
return 'process candidate';
case 'registered_only':
return 'registered';
case 'stale_runtime':
return 'stale runtime';
case 'settling':
return 'joining team';
case 'error':
@ -573,6 +586,7 @@ export function buildMemberLaunchPresentation({
spawnLivenessSource,
spawnRuntimeAlive,
runtimeAdvisory,
runtimeEntry,
isLaunchSettling = false,
isTeamAlive,
isTeamProvisioning,
@ -584,6 +598,7 @@ export function buildMemberLaunchPresentation({
spawnLivenessSource: MemberSpawnLivenessSource | undefined;
spawnRuntimeAlive: boolean | undefined;
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
runtimeEntry?: TeamAgentRuntimeEntry;
isLaunchSettling?: boolean;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
@ -630,10 +645,21 @@ export function buildMemberLaunchPresentation({
launchVisualState = 'error';
} else if (spawnLaunchState === 'runtime_pending_permission') {
launchVisualState = 'permission_pending';
} else if (runtimeEntry?.livenessKind === 'shell_only') {
launchVisualState = 'shell_only';
} else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') {
launchVisualState = 'runtime_candidate';
} else if (runtimeEntry?.livenessKind === 'registered_only') {
launchVisualState = 'registered_only';
} else if (
runtimeEntry?.livenessKind === 'stale_metadata' ||
runtimeEntry?.livenessKind === 'not_found'
) {
launchVisualState = 'stale_runtime';
} else if (
spawnLaunchState === 'runtime_pending_bootstrap' &&
spawnStatus === 'online' &&
spawnRuntimeAlive === true
(runtimeEntry?.livenessKind === 'runtime_process' ||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
) {
launchVisualState = 'runtime_pending';
} else if (
@ -655,6 +681,15 @@ export function buildMemberLaunchPresentation({
}
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
const displayPresenceLabel =
launchVisualState === 'permission_pending' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
const spawnBadgeLabel =
spawnStatus && spawnStatus !== 'online'
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
@ -663,7 +698,7 @@ export function buildMemberLaunchPresentation({
: null;
return {
presenceLabel,
presenceLabel: displayPresenceLabel,
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
cardClass,
runtimeAdvisoryLabel,

View file

@ -126,6 +126,27 @@ function buildAwaitingPermissionPhrase(count: number): string {
: `${count} teammates awaiting permission approval`;
}
function buildPendingDiagnosticPhrase(
summary: MemberSpawnStatusesSnapshot['summary'] | undefined,
fallbackJoiningPhrase: string
): string {
if (!summary) {
return fallbackJoiningPhrase;
}
const parts = [
summary.shellOnlyPendingCount ? `${summary.shellOnlyPendingCount} shell-only` : '',
summary.runtimeProcessPendingCount
? `${summary.runtimeProcessPendingCount} waiting for bootstrap`
: '',
summary.runtimeCandidatePendingCount
? `${summary.runtimeCandidatePendingCount} process candidates`
: '',
summary.permissionPendingCount ? `${summary.permissionPendingCount} awaiting permission` : '',
summary.noRuntimePendingCount ? `${summary.noRuntimePendingCount} no runtime found` : '',
].filter(Boolean);
return parts.length > 0 ? parts.join(', ') : fallbackJoiningPhrase;
}
const ACTIVE_PROVISIONING_STATES = new Set([
'validating',
'spawning',
@ -394,7 +415,7 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount === remainingJoinCount;
const pendingDetailPhrase = pendingMembersAwaitApproval
? buildAwaitingPermissionPhrase(permissionBlockedCount)
: joiningPhrase;
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, joiningPhrase);
const readyCompactDetail =
failedSpawnCount > 0
? (failedSpawnCompactDetail ??
@ -471,7 +492,7 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount
? buildAwaitingPermissionPhrase(permissionBlockedCount)
: activeJoiningPhrase;
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, activeJoiningPhrase);
return {
progress,
isActive: true,

View file

@ -82,6 +82,11 @@ export interface TeamSummary {
pendingCount?: number;
failedCount?: number;
runtimeAlivePendingCount?: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
runtimeCandidatePendingCount?: number;
noRuntimePendingCount?: number;
permissionPendingCount?: number;
}
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
@ -941,6 +946,12 @@ export interface PersistedTeamLaunchMemberState {
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
runtimePid?: number;
runtimeSessionId?: string;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
runtimeLastSeenAt?: string;
firstSpawnAcceptedAt?: string;
lastHeartbeatAt?: string;
lastRuntimeAliveAt?: string;
@ -954,6 +965,11 @@ export interface PersistedTeamLaunchSummary {
pendingCount: number;
failedCount: number;
runtimeAlivePendingCount: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
runtimeCandidatePendingCount?: number;
noRuntimePendingCount?: number;
permissionPendingCount?: number;
}
export interface PersistedTeamLaunchSnapshot {
@ -984,6 +1000,27 @@ export type MemberSpawnLivenessSource = 'heartbeat' | 'process';
export type TeamAgentRuntimeBackendType = 'lead' | 'tmux' | 'iterm2' | 'in-process' | 'process';
export type TeamAgentRuntimeLivenessKind =
| 'confirmed_bootstrap'
| 'runtime_process'
| 'runtime_process_candidate'
| 'permission_blocked'
| 'shell_only'
| 'registered_only'
| 'stale_metadata'
| 'not_found';
export type TeamAgentRuntimePidSource =
| 'lead_process'
| 'tmux_pane'
| 'tmux_child'
| 'agent_process_table'
| 'opencode_bridge'
| 'runtime_bootstrap'
| 'persisted_metadata';
export type TeamAgentRuntimeDiagnosticSeverity = 'info' | 'warning' | 'error';
export interface TeamAgentRuntimeEntry {
memberName: string;
alive: boolean;
@ -996,6 +1033,19 @@ export interface TeamAgentRuntimeEntry {
pid?: number;
runtimeModel?: string;
rssBytes?: number;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
processCommand?: string;
paneId?: string;
panePid?: number;
paneCurrentCommand?: string;
runtimePid?: number;
runtimeSessionId?: string;
runtimeLeaseExpiresAt?: string;
runtimeLastSeenAt?: string;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
diagnostics?: string[];
updatedAt: string;
}
@ -1062,6 +1112,14 @@ export interface MemberSpawnStatusEntry {
lastHeartbeatAt?: string;
/** Live runtime model observed from the teammate process, when available. */
runtimeModel?: string;
/** Compact runtime liveness classification for launch UI. */
livenessKind?: TeamAgentRuntimeLivenessKind;
/** Short user-facing liveness diagnostic. */
runtimeDiagnostic?: string;
/** Visual severity for runtimeDiagnostic. */
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
/** ISO timestamp of the last liveness evaluation. */
livenessLastCheckedAt?: string;
/** ISO timestamp of the last status change. */
updatedAt: string;
}
@ -1176,6 +1234,28 @@ export interface TeamProvisioningProgress {
assistantOutput?: string;
/** True once provisioning has written a readable config.json for this team. */
configReady?: boolean;
/** Bounded structured launch diagnostics for the progress UI. */
launchDiagnostics?: TeamLaunchDiagnosticItem[];
}
export interface TeamLaunchDiagnosticItem {
id: string;
memberName?: string;
severity: TeamAgentRuntimeDiagnosticSeverity;
code:
| 'spawn_accepted'
| 'runtime_process_detected'
| 'runtime_process_candidate'
| 'tmux_shell_only'
| 'runtime_not_found'
| 'permission_pending'
| 'bootstrap_confirmed'
| 'bootstrap_stalled'
| 'stale_runtime_event_rejected'
| 'process_table_unavailable';
label: string;
detail?: string;
observedAt: string;
}
export interface TeamRuntimeState {

View file

@ -60,26 +60,27 @@ describe('TeamLaunchStateEvaluator', () => {
});
it('counts persisted members in launch summary even when expectedMembers is stale', () => {
const summary = summarizePersistedLaunchMembers(
['alice'],
{
alice: {
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
},
bob: {
launchState: 'runtime_pending_permission',
runtimeAlive: true,
},
} as any
);
const summary = summarizePersistedLaunchMembers(['alice'], {
alice: {
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
},
bob: {
launchState: 'runtime_pending_permission',
runtimeAlive: true,
},
} as any);
expect(summary).toEqual({
confirmedCount: 0,
pendingCount: 2,
failedCount: 0,
runtimeAlivePendingCount: 1,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
noRuntimePendingCount: 0,
permissionPendingCount: 1,
});
});
});

View file

@ -75,7 +75,9 @@ describe('TeamMcpConfigBuilder', () => {
return dir;
}
function readGeneratedServer(configPath: string): { command?: string; args?: string[] } | undefined {
function readGeneratedServer(
configPath: string
): { command?: string; args?: string[] } | undefined {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>;
@ -83,26 +85,72 @@ describe('TeamMcpConfigBuilder', () => {
return parsed.mcpServers?.['agent-teams'];
}
function expectNodeEntry(server: { command?: string; args?: string[] } | undefined, entry: string): void {
function expectNodeEntry(
server: { command?: string; args?: string[] } | undefined,
entry: string
): void {
expect(server?.args).toEqual([entry]);
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
}
function mockPathExists(existingPaths: string[]): void {
function expectTsxEntry(
server: { command?: string; args?: string[] } | undefined,
entry: string
): void {
expect(server?.args).toEqual([entry]);
expect(server?.command).toMatch(/[\\/]tsx(?:\.cmd)?$/);
}
function getBuiltWorkspaceEntry(): string {
return path.join(process.cwd(), 'mcp-server', 'dist', 'index.js');
}
function getSourceWorkspaceEntry(): string {
return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts');
}
function getWorkspaceTsxBin(): string {
return path.join(process.cwd(), 'mcp-server', 'node_modules', '.bin', 'tsx');
}
function mockPathExists(existingPaths: string[], options: { strict?: boolean } = {}): void {
const originalAccess = fs.promises.access.bind(fs.promises);
vi.spyOn(fs.promises, 'access').mockImplementation(async (targetPath, mode) => {
const normalizedPath =
typeof targetPath === 'string' ? targetPath : Buffer.isBuffer(targetPath) ? targetPath.toString() : `${targetPath}`;
typeof targetPath === 'string'
? targetPath
: Buffer.isBuffer(targetPath)
? targetPath.toString()
: `${targetPath}`;
if (existingPaths.includes(normalizedPath)) {
return;
}
if (options.strict) {
const error = new Error(
`ENOENT: no such file or directory, access '${normalizedPath}'`
) as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
await originalAccess(targetPath, mode);
});
}
function mockSourceWorkspaceEntryAvailable(): {
sourceEntry: string;
tsxBin: string;
builtEntry: string;
} {
const sourceEntry = getSourceWorkspaceEntry();
const tsxBin = getWorkspaceTsxBin();
const builtEntry = getBuiltWorkspaceEntry();
mockPathExists([sourceEntry, tsxBin, builtEntry], { strict: true });
return { sourceEntry, tsxBin, builtEntry };
}
function mockBuiltWorkspaceEntryAvailable(): string {
const builtEntry = path.join(process.cwd(), 'mcp-server', 'dist', 'index.js');
mockPathExists([builtEntry]);
const builtEntry = getBuiltWorkspaceEntry();
mockPathExists([builtEntry], { strict: true });
return builtEntry;
}
@ -172,12 +220,26 @@ describe('TeamMcpConfigBuilder', () => {
createdPaths.push(configPath);
const filename = path.basename(configPath);
expect(filename).toMatch(
new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`)
);
expect(filename).toMatch(new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`));
});
it('prefers the built workspace MCP entry when available', async () => {
it('prefers the source workspace MCP entry in dev mode when available', async () => {
const { sourceEntry } = mockSourceWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>;
};
const server = parsed.mcpServers?.['agent-teams'];
expectTsxEntry(server, sourceEntry);
});
it('falls back to the built workspace MCP entry when source execution is unavailable', async () => {
const builtEntry = mockBuiltWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder();
@ -232,7 +294,10 @@ describe('TeamMcpConfigBuilder', () => {
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[]; type?: string; url?: string }>;
mcpServers: Record<
string,
{ command?: string; args?: string[]; type?: string; url?: string }
>;
};
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
@ -246,7 +311,10 @@ describe('TeamMcpConfigBuilder', () => {
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(path.join(homeDir, '.claude.json'), JSON.stringify({ mcpServers: {} }, null, 2));
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify({ mcpServers: {} }, null, 2)
);
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
@ -273,7 +341,7 @@ describe('TeamMcpConfigBuilder', () => {
});
it('generated agent-teams server ignores same-named user MCP entry', async () => {
const builtEntry = mockBuiltWorkspaceEntryAvailable();
const { sourceEntry } = mockSourceWorkspaceEntryAvailable();
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
createdDirs.push(homeDir);
mockHomeDir = homeDir;
@ -299,7 +367,7 @@ describe('TeamMcpConfigBuilder', () => {
mcpServers: Record<string, { command?: string; args?: string[] }>;
};
expectNodeEntry(parsed.mcpServers['agent-teams'], builtEntry);
expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry);
});
it('ignores malformed user MCP file', async () => {
@ -494,7 +562,10 @@ describe('TeamMcpConfigBuilder', () => {
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expectNodeEntry(readGeneratedServer(configPath), path.join(resourcesDir, 'mcp-server', 'index.js'));
expectNodeEntry(
readGeneratedServer(configPath),
path.join(resourcesDir, 'mcp-server', 'index.js')
);
});
it('packaged mode uses the winner stable copy when atomic rename loses the race', async () => {
@ -526,8 +597,8 @@ describe('TeamMcpConfigBuilder', () => {
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
});
it('packaged mode falls back to the built workspace MCP entry when resourcesPath bundle is missing', async () => {
const builtEntry = mockBuiltWorkspaceEntryAvailable();
it('packaged mode falls back to the source workspace MCP entry when resourcesPath bundle is missing', async () => {
const { sourceEntry } = mockSourceWorkspaceEntryAvailable();
setPackagedMode(true, '6.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir);
@ -537,6 +608,6 @@ describe('TeamMcpConfigBuilder', () => {
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expectNodeEntry(readGeneratedServer(configPath), builtEntry);
expectTsxEntry(readGeneratedServer(configPath), sourceEntry);
});
});

View file

@ -27,7 +27,9 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
vi.mock('@features/tmux-installer/main', () => ({
killTmuxPaneForCurrentPlatformSync: vi.fn(),
listRuntimeProcessesForCurrentTmuxPlatform: vi.fn(async () => []),
listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()),
listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()),
isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true),
}));
@ -144,7 +146,9 @@ import {
} from 'agent-teams-controller';
import {
killTmuxPaneForCurrentPlatformSync,
listRuntimeProcessesForCurrentTmuxPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
} from '@features/tmux-installer/main';
import pidusage from 'pidusage';
@ -409,6 +413,13 @@ function createClaudeLogsRun(overrides: Record<string, unknown> = {}) {
describe('TeamProvisioningService', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(killTmuxPaneForCurrentPlatformSync).mockReset();
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockReset();
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([]);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockReset();
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValue(new Map());
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReset();
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue(new Map());
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-provisioning-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
@ -557,7 +568,18 @@ describe('TeamProvisioningService', () => {
cancelRequested: false,
spawnContext: null,
});
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map([['%1', 222]]));
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValueOnce(
new Map([
[
'%1',
{
paneId: '%1',
panePid: 222,
currentCommand: 'codex',
},
],
])
);
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000),
@ -650,7 +672,18 @@ describe('TeamProvisioningService', () => {
cancelRequested: false,
spawnContext: null,
});
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map([['%1', 222]]));
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValueOnce(
new Map([
[
'%1',
{
paneId: '%1',
panePid: 222,
currentCommand: 'codex',
},
],
])
);
vi.mocked(pidusage)
.mockRejectedValueOnce(new Error('ps: process exited'))
@ -693,14 +726,14 @@ describe('TeamProvisioningService', () => {
cancelRequested: false,
spawnContext: null,
});
(svc as any).readUnixProcessTableRows = vi.fn(() => [
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{
pid: 333,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
},
]);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000),
'333': createPidusageStat(333, 456_000_000),
@ -746,19 +779,20 @@ describe('TeamProvisioningService', () => {
cancelRequested: false,
spawnContext: null,
});
(svc as any).readUnixProcessTableRows = vi.fn(() => [
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{
pid: 222,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
},
{
pid: 333,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --team-name nice-team --agent-id alice@nice-team --agent-name alice --model gpt-5.2',
},
]);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000),
'333': createPidusageStat(333, 456_000_000),
@ -911,14 +945,20 @@ describe('TeamProvisioningService', () => {
]),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).findLiveProcessPidByAgentId = vi.fn(
() =>
new Map([
['alice@signal-ops-6', 17527],
['atlas@signal-ops-6', 17528],
])
);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{
pid: 17527,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@signal-ops-6 --agent-name alice --team-name signal-ops-6 --model gpt-5.4-mini',
},
{
pid: 17528,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --agent-id atlas@signal-ops-6 --agent-name atlas --team-name signal-ops-6 --model gpt-5.3-codex',
},
]);
const metadata = await (svc as any).getLiveTeamAgentRuntimeMetadata('signal-ops-6');
@ -4009,6 +4049,9 @@ describe('TeamProvisioningService', () => {
const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow(
`Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).`
);
await vi.waitFor(() => {
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
});
await vi.advanceTimersByTimeAsync(1_500);
await restartPromise;
@ -4056,9 +4099,14 @@ describe('TeamProvisioningService', () => {
backendType: 'process',
},
]);
(svc as any).findLiveProcessPidByAgentId = vi.fn(
() => new Map([['forge@process-team', process.pid]])
);
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{
pid: process.pid,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --team-name process-team --agent-id forge@process-team --agent-name forge --model gpt-5.4',
},
]);
(svc as any).liveTeamAgentRuntimeMetadataCache.set('process-team', {
expiresAtMs: Date.now() + 60_000,
metadata: new Map([
@ -4078,6 +4126,9 @@ describe('TeamProvisioningService', () => {
const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow(
`Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).`
);
await vi.waitFor(() => {
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
});
await vi.advanceTimersByTimeAsync(1_500);
await restartPromise;
@ -4120,15 +4171,23 @@ describe('TeamProvisioningService', () => {
]),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).findLiveProcessPidByAgentId = vi.fn(
() => new Map([['forge@process-team', process.pid]])
);
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{
pid: process.pid,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --team-name process-team --agent-id forge@process-team --agent-name forge --model gpt-5.4',
},
]);
(svc as any).aliveRunByTeam.set('process-team', run.runId);
(svc as any).runs.set(run.runId, run);
const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow(
`Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).`
);
await vi.waitFor(() => {
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
});
await vi.advanceTimersByTimeAsync(1_500);
await restartPromise;