feat(team): make tmux optional for desktop teammates
This commit is contained in:
parent
afe50439b1
commit
31ae128778
7 changed files with 213 additions and 49 deletions
|
|
@ -121,7 +121,6 @@ import {
|
||||||
} from '../runtime/providerModelProbe';
|
} from '../runtime/providerModelProbe';
|
||||||
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||||
|
|
||||||
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
|
|
||||||
import {
|
import {
|
||||||
createOpenCodePromptDeliveryLedgerStore,
|
createOpenCodePromptDeliveryLedgerStore,
|
||||||
hashOpenCodePromptDeliveryPayload,
|
hashOpenCodePromptDeliveryPayload,
|
||||||
|
|
@ -133,16 +132,17 @@ import {
|
||||||
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
|
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||||
import {
|
import {
|
||||||
isOpenCodePromptDeliveryObserveLaterResponseState,
|
isOpenCodePromptDeliveryObserveLaterResponseState,
|
||||||
isOpenCodePromptDeliveryRetryAttemptDue,
|
|
||||||
isOpenCodePromptDeliveryRetryableResponseState,
|
isOpenCodePromptDeliveryRetryableResponseState,
|
||||||
isOpenCodeVisibleReplySemanticallySufficient,
|
isOpenCodePromptDeliveryRetryAttemptDue,
|
||||||
isOpenCodeVisibleReplyReadCommitAllowed,
|
isOpenCodeVisibleReplyReadCommitAllowed,
|
||||||
|
isOpenCodeVisibleReplySemanticallySufficient,
|
||||||
OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS,
|
OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS,
|
||||||
OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS,
|
OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS,
|
||||||
OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY,
|
OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY,
|
||||||
OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY,
|
OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY,
|
||||||
type OpenCodeVisibleReplyProof,
|
type OpenCodeVisibleReplyProof,
|
||||||
} from './opencode/delivery/OpenCodePromptDeliveryWatchdog';
|
} from './opencode/delivery/OpenCodePromptDeliveryWatchdog';
|
||||||
|
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
|
||||||
import {
|
import {
|
||||||
type RuntimeDeliveryDestinationPort,
|
type RuntimeDeliveryDestinationPort,
|
||||||
RuntimeDeliveryDestinationRegistry,
|
RuntimeDeliveryDestinationRegistry,
|
||||||
|
|
@ -184,7 +184,11 @@ import {
|
||||||
buildProgressAssistantOutput,
|
buildProgressAssistantOutput,
|
||||||
buildProgressLogsTail,
|
buildProgressLogsTail,
|
||||||
} from './progressPayload';
|
} from './progressPayload';
|
||||||
import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode';
|
import {
|
||||||
|
applyDesktopTeammateModeDecisionToEnv,
|
||||||
|
buildDesktopTeammateModeCliArgs,
|
||||||
|
resolveDesktopTeammateModeDecision,
|
||||||
|
} from './runtimeTeammateMode';
|
||||||
import {
|
import {
|
||||||
choosePreferredLaunchSnapshot,
|
choosePreferredLaunchSnapshot,
|
||||||
clearBootstrapState,
|
clearBootstrapState,
|
||||||
|
|
@ -205,8 +209,8 @@ import {
|
||||||
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager';
|
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import {
|
import {
|
||||||
commandArgEquals,
|
commandArgEquals,
|
||||||
|
|
@ -1099,6 +1103,9 @@ function buildRuntimeLaunchWarning(
|
||||||
if (env.CLAUDE_CODE_CODEX_BACKEND) {
|
if (env.CLAUDE_CODE_CODEX_BACKEND) {
|
||||||
flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`);
|
flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`);
|
||||||
}
|
}
|
||||||
|
if (env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES === '1') {
|
||||||
|
flags.push('FORCE_PROCESS_TEAMMATES');
|
||||||
|
}
|
||||||
const backendPart = backend ? `, backend ${backend}` : '';
|
const backendPart = backend ? `, backend ${backend}` : '';
|
||||||
const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : '';
|
const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : '';
|
||||||
const geminiAuth = options?.geminiRuntimeAuth;
|
const geminiAuth = options?.geminiRuntimeAuth;
|
||||||
|
|
@ -1162,6 +1169,7 @@ function logRuntimeLaunchSnapshot(
|
||||||
CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null,
|
CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null,
|
||||||
CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null,
|
CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null,
|
||||||
CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null,
|
CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null,
|
||||||
|
CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES ?? null,
|
||||||
CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null,
|
CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null,
|
||||||
CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null,
|
CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null,
|
||||||
},
|
},
|
||||||
|
|
@ -11744,9 +11752,7 @@ export class TeamProvisioningService {
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
||||||
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
||||||
if (teammateModeDecision.forceProcessTeammates) {
|
applyDesktopTeammateModeDecisionToEnv(shellEnv, teammateModeDecision);
|
||||||
shellEnv.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1';
|
|
||||||
}
|
|
||||||
let mcpConfigPath: string;
|
let mcpConfigPath: string;
|
||||||
let bootstrapSpecPath: string;
|
let bootstrapSpecPath: string;
|
||||||
let bootstrapUserPromptPath: string | null = null;
|
let bootstrapUserPromptPath: string | null = null;
|
||||||
|
|
@ -11810,6 +11816,7 @@ export class TeamProvisioningService {
|
||||||
...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []),
|
...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []),
|
||||||
...providerFastModeArgs,
|
...providerFastModeArgs,
|
||||||
...(request.worktree ? ['--worktree', request.worktree] : []),
|
...(request.worktree ? ['--worktree', request.worktree] : []),
|
||||||
|
...buildDesktopTeammateModeCliArgs(teammateModeDecision),
|
||||||
...parseCliArgs(request.extraCliArgs),
|
...parseCliArgs(request.extraCliArgs),
|
||||||
...providerArgs,
|
...providerArgs,
|
||||||
];
|
];
|
||||||
|
|
@ -12812,9 +12819,7 @@ export class TeamProvisioningService {
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
||||||
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
||||||
if (teammateModeDecision.forceProcessTeammates) {
|
applyDesktopTeammateModeDecisionToEnv(shellEnv, teammateModeDecision);
|
||||||
shellEnv.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1';
|
|
||||||
}
|
|
||||||
let mcpConfigPath: string;
|
let mcpConfigPath: string;
|
||||||
let bootstrapSpecPath: string;
|
let bootstrapSpecPath: string;
|
||||||
let bootstrapUserPromptPath: string | null = null;
|
let bootstrapUserPromptPath: string | null = null;
|
||||||
|
|
@ -12894,6 +12899,7 @@ export class TeamProvisioningService {
|
||||||
if (request.worktree) {
|
if (request.worktree) {
|
||||||
launchArgs.push('--worktree', request.worktree);
|
launchArgs.push('--worktree', request.worktree);
|
||||||
}
|
}
|
||||||
|
launchArgs.push(...buildDesktopTeammateModeCliArgs(teammateModeDecision));
|
||||||
launchArgs.push(...parseCliArgs(request.extraCliArgs));
|
launchArgs.push(...parseCliArgs(request.extraCliArgs));
|
||||||
launchArgs.push(...providerArgs);
|
launchArgs.push(...providerArgs);
|
||||||
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
||||||
|
|
@ -15438,8 +15444,9 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const paneIds = [...metadataByMember.values()]
|
const paneIds = [...metadataByMember.values()]
|
||||||
|
.filter((metadata) => metadata.backendType === 'tmux' || metadata.backendType === undefined)
|
||||||
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
|
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
|
||||||
.filter((paneId) => paneId.length > 0);
|
.filter((paneId) => paneId.length > 0 && !paneId.startsWith('process:'));
|
||||||
let paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
|
let paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
|
||||||
if (paneIds.length > 0) {
|
if (paneIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -58,22 +58,42 @@ export async function resolveDesktopTeammateModeDecision(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (explicitMode === 'auto' || explicitMode === 'in-process') {
|
if (explicitMode === 'auto') {
|
||||||
|
return {
|
||||||
|
injectedTeammateMode: null,
|
||||||
|
forceProcessTeammates: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicitMode === 'in-process') {
|
||||||
return {
|
return {
|
||||||
injectedTeammateMode: null,
|
injectedTeammateMode: null,
|
||||||
forceProcessTeammates: false,
|
forceProcessTeammates: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isTmuxAvailable())) {
|
const tmuxAvailable = await isTmuxAvailable();
|
||||||
return {
|
|
||||||
injectedTeammateMode: null,
|
|
||||||
forceProcessTeammates: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
injectedTeammateMode: 'tmux',
|
injectedTeammateMode: tmuxAvailable ? 'tmux' : null,
|
||||||
forceProcessTeammates: true,
|
forceProcessTeammates: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyDesktopTeammateModeDecisionToEnv(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
decision: Pick<DesktopTeammateModeDecision, 'forceProcessTeammates'>
|
||||||
|
): void {
|
||||||
|
if (decision.forceProcessTeammates) {
|
||||||
|
env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDesktopTeammateModeCliArgs(
|
||||||
|
decision: Pick<DesktopTeammateModeDecision, 'injectedTeammateMode'>
|
||||||
|
): string[] {
|
||||||
|
return decision.injectedTeammateMode ? ['--teammate-mode', decision.injectedTeammateMode] : [];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ type TeammateRuntimeIssueReason =
|
||||||
| 'mixed-provider'
|
| 'mixed-provider'
|
||||||
| 'codex-native-runtime'
|
| 'codex-native-runtime'
|
||||||
| 'explicit-tmux-mode'
|
| 'explicit-tmux-mode'
|
||||||
|
| 'explicit-in-process-mode'
|
||||||
| 'opencode-led-mixed-unsupported';
|
| 'opencode-led-mixed-unsupported';
|
||||||
|
|
||||||
interface RuntimeMemberInput {
|
interface RuntimeMemberInput {
|
||||||
|
|
@ -176,6 +177,13 @@ export function analyzeTeammateRuntimeCompatibility({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requiresSeparateProcess = issues.some(
|
||||||
|
(issue) => issue.reason === 'mixed-provider' || issue.reason === 'codex-native-runtime'
|
||||||
|
);
|
||||||
|
if (explicitTeammateMode === 'in-process' && requiresSeparateProcess) {
|
||||||
|
issues.push({ reason: 'explicit-in-process-mode' });
|
||||||
|
}
|
||||||
|
|
||||||
if (issues.length === 0) {
|
if (issues.length === 0) {
|
||||||
return {
|
return {
|
||||||
visible: false,
|
visible: false,
|
||||||
|
|
@ -193,7 +201,9 @@ export function analyzeTeammateRuntimeCompatibility({
|
||||||
const hasOpenCodeLeadMixedUnsupported = issues.some(
|
const hasOpenCodeLeadMixedUnsupported = issues.some(
|
||||||
(issue) => issue.reason === 'opencode-led-mixed-unsupported'
|
(issue) => issue.reason === 'opencode-led-mixed-unsupported'
|
||||||
);
|
);
|
||||||
if (tmuxReady && !hasOpenCodeLeadMixedUnsupported) {
|
const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode');
|
||||||
|
const hasExplicitInProcess = issues.some((issue) => issue.reason === 'explicit-in-process-mode');
|
||||||
|
if (!hasOpenCodeLeadMixedUnsupported && !hasExplicitTmux && !hasExplicitInProcess) {
|
||||||
return {
|
return {
|
||||||
visible: false,
|
visible: false,
|
||||||
blocksSubmission: false,
|
blocksSubmission: false,
|
||||||
|
|
@ -206,11 +216,28 @@ export function analyzeTeammateRuntimeCompatibility({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const checking = !hasOpenCodeLeadMixedUnsupported && tmuxStatusLoading && !tmuxStatus;
|
if (tmuxReady && hasExplicitTmux && !hasOpenCodeLeadMixedUnsupported && !hasExplicitInProcess) {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
blocksSubmission: false,
|
||||||
|
checking: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
details: [],
|
||||||
|
tmuxDetail: null,
|
||||||
|
memberWarningById: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const checking =
|
||||||
|
hasExplicitTmux &&
|
||||||
|
!hasOpenCodeLeadMixedUnsupported &&
|
||||||
|
!hasExplicitInProcess &&
|
||||||
|
tmuxStatusLoading &&
|
||||||
|
!tmuxStatus;
|
||||||
const blocksSubmission = true;
|
const blocksSubmission = true;
|
||||||
const hasMixedProviders = issues.some((issue) => issue.reason === 'mixed-provider');
|
const hasMixedProviders = issues.some((issue) => issue.reason === 'mixed-provider');
|
||||||
const hasCodexNative = issues.some((issue) => issue.reason === 'codex-native-runtime');
|
const hasCodexNative = issues.some((issue) => issue.reason === 'codex-native-runtime');
|
||||||
const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode');
|
|
||||||
const details: string[] = [];
|
const details: string[] = [];
|
||||||
const memberWarningById: Record<string, string> = {};
|
const memberWarningById: Record<string, string> = {};
|
||||||
|
|
||||||
|
|
@ -241,15 +268,20 @@ export function analyzeTeammateRuntimeCompatibility({
|
||||||
if (hasExplicitTmux) {
|
if (hasExplicitTmux) {
|
||||||
details.push('Custom CLI args force --teammate-mode tmux.');
|
details.push('Custom CLI args force --teammate-mode tmux.');
|
||||||
}
|
}
|
||||||
|
if (hasExplicitInProcess) {
|
||||||
|
details.push('Custom CLI args force --teammate-mode in-process.');
|
||||||
|
}
|
||||||
if (hasOpenCodeLeadMixedUnsupported) {
|
if (hasOpenCodeLeadMixedUnsupported) {
|
||||||
details.push(
|
details.push(
|
||||||
'Fix: keep the team lead on Anthropic, Codex, or Gemini when mixing OpenCode with other providers.'
|
'Fix: keep the team lead on Anthropic, Codex, or Gemini when mixing OpenCode with other providers.'
|
||||||
);
|
);
|
||||||
|
} else if (hasExplicitInProcess) {
|
||||||
|
details.push(
|
||||||
|
'Fix: remove --teammate-mode in-process so teammates can use native process transport.'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
details.push(
|
details.push(
|
||||||
hasCodexNative && !hasMixedProviders
|
'Fix: install tmux/WSL tmux, or remove --teammate-mode tmux so the app can use native process transport.'
|
||||||
? 'Fix: install tmux/WSL tmux, use Solo team, or choose a same-provider runtime that supports in-process teammates.'
|
|
||||||
: 'Fix: install tmux/WSL tmux, use Solo team, or keep every teammate on the same non-Codex-native provider as the lead.'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,10 +292,10 @@ export function analyzeTeammateRuntimeCompatibility({
|
||||||
if (issue.reason === 'mixed-provider') {
|
if (issue.reason === 'mixed-provider') {
|
||||||
memberWarningById[issue.memberId] =
|
memberWarningById[issue.memberId] =
|
||||||
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
|
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
|
||||||
`Without tmux, teammates must use the same provider as the ${getProviderLabel(leadProviderId)} lead.`;
|
`This teammate requires a separate process outside the ${getProviderLabel(leadProviderId)} lead.`;
|
||||||
} else if (issue.reason === 'codex-native-runtime') {
|
} else if (issue.reason === 'codex-native-runtime') {
|
||||||
memberWarningById[issue.memberId] =
|
memberWarningById[issue.memberId] =
|
||||||
`${issue.memberName} uses Codex native. Codex native teammates require a separate process, which currently needs tmux.`;
|
`${issue.memberName} uses Codex native. Codex native teammates require a separate Codex process.`;
|
||||||
} else if (issue.reason === 'opencode-led-mixed-unsupported') {
|
} else if (issue.reason === 'opencode-led-mixed-unsupported') {
|
||||||
memberWarningById[issue.memberId] =
|
memberWarningById[issue.memberId] =
|
||||||
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
|
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
|
||||||
|
|
@ -276,19 +308,19 @@ export function analyzeTeammateRuntimeCompatibility({
|
||||||
blocksSubmission,
|
blocksSubmission,
|
||||||
checking,
|
checking,
|
||||||
title: checking
|
title: checking
|
||||||
? 'Checking tmux runtime for teammate support'
|
? 'Checking tmux runtime for explicit teammate mode'
|
||||||
: hasOpenCodeLeadMixedUnsupported
|
: hasOpenCodeLeadMixedUnsupported
|
||||||
? 'OpenCode cannot lead mixed-provider teams'
|
? 'OpenCode cannot lead mixed-provider teams'
|
||||||
: hasCodexNative && !hasMixedProviders
|
: hasExplicitInProcess
|
||||||
? 'Codex teammates need tmux before they can run'
|
? 'This team cannot use in-process teammates'
|
||||||
: 'This team needs tmux before it can run',
|
: 'tmux is not ready for explicit teammate mode',
|
||||||
message: checking
|
message: checking
|
||||||
? 'Some teammates require separate processes. The app is checking whether tmux is available.'
|
? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.'
|
||||||
: hasOpenCodeLeadMixedUnsupported
|
: hasOpenCodeLeadMixedUnsupported
|
||||||
? 'OpenCode teammates can run as secondary runtime lanes under an Anthropic, Codex, or Gemini lead, but OpenCode-led mixed teams are not supported in this phase.'
|
? 'OpenCode teammates can run as secondary runtime lanes under an Anthropic, Codex, or Gemini lead, but OpenCode-led mixed teams are not supported in this phase.'
|
||||||
: hasCodexNative && !hasMixedProviders
|
: hasExplicitInProcess
|
||||||
? 'The Codex lead can run without tmux, but Codex native teammates cannot use the in-process teammate adapter. They must start as separate Codex processes, and this path currently needs tmux.'
|
? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.'
|
||||||
: 'tmux is not ready on this machine. Same-provider in-process teammates can run without tmux, but this team has teammates that require separate processes.',
|
: 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.',
|
||||||
details,
|
details,
|
||||||
tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError),
|
tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError),
|
||||||
memberWarningById,
|
memberWarningById,
|
||||||
|
|
|
||||||
|
|
@ -603,6 +603,41 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not send legacy process backend pane markers to tmux liveness lookup', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', model: 'gpt-5.4-mini' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
agentId: 'alice@runtime-team',
|
||||||
|
tmuxPaneId: 'process:4242',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
|
||||||
|
(svc as any).runs.set('run-1', {
|
||||||
|
runId: 'run-1',
|
||||||
|
child: { pid: 111 },
|
||||||
|
request: { model: 'gpt-5.4' },
|
||||||
|
processKilled: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
spawnContext: null,
|
||||||
|
});
|
||||||
|
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||||
|
'111': createPidusageStat(111, 123_000_000),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await svc.getTeamAgentRuntimeSnapshot('runtime-team');
|
||||||
|
|
||||||
|
expect(listTmuxPaneRuntimeInfoForCurrentPlatform).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes providerBackendId from the live run request when available', async () => {
|
it('exposes providerBackendId from the live run request when available', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,65 @@ describe('runtimeTeammateMode', () => {
|
||||||
expect(decision.injectedTeammateMode).toBe('tmux');
|
expect(decision.injectedTeammateMode).toBe('tmux');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps fallback mode when tmux runtime is not ready', async () => {
|
it('uses native process teammates when tmux runtime is not ready', async () => {
|
||||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(false);
|
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(false);
|
||||||
const { resolveDesktopTeammateModeDecision } =
|
const { resolveDesktopTeammateModeDecision } =
|
||||||
await import('@main/services/team/runtimeTeammateMode');
|
await import('@main/services/team/runtimeTeammateMode');
|
||||||
|
|
||||||
const decision = await resolveDesktopTeammateModeDecision(undefined);
|
const decision = await resolveDesktopTeammateModeDecision(undefined);
|
||||||
|
|
||||||
|
expect(decision.forceProcessTeammates).toBe(true);
|
||||||
|
expect(decision.injectedTeammateMode).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats explicit auto mode as automatic process teammate selection without injection', async () => {
|
||||||
|
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||||
|
const { resolveDesktopTeammateModeDecision } =
|
||||||
|
await import('@main/services/team/runtimeTeammateMode');
|
||||||
|
|
||||||
|
const decision = await resolveDesktopTeammateModeDecision('--teammate-mode auto');
|
||||||
|
const equalsDecision = await resolveDesktopTeammateModeDecision('--teammate-mode=auto');
|
||||||
|
|
||||||
|
expect(decision.forceProcessTeammates).toBe(true);
|
||||||
|
expect(decision.injectedTeammateMode).toBeNull();
|
||||||
|
expect(equalsDecision.forceProcessTeammates).toBe(true);
|
||||||
|
expect(equalsDecision.injectedTeammateMode).toBeNull();
|
||||||
|
expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors explicit in-process mode as an opt-out from process teammates', async () => {
|
||||||
|
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||||
|
const { resolveDesktopTeammateModeDecision } =
|
||||||
|
await import('@main/services/team/runtimeTeammateMode');
|
||||||
|
|
||||||
|
const decision = await resolveDesktopTeammateModeDecision('--teammate-mode=in-process');
|
||||||
|
|
||||||
expect(decision.forceProcessTeammates).toBe(false);
|
expect(decision.forceProcessTeammates).toBe(false);
|
||||||
expect(decision.injectedTeammateMode).toBeNull();
|
expect(decision.injectedTeammateMode).toBeNull();
|
||||||
|
expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes inherited process fallback env when explicit in-process mode opts out', async () => {
|
||||||
|
const { applyDesktopTeammateModeDecisionToEnv } =
|
||||||
|
await import('@main/services/team/runtimeTeammateMode');
|
||||||
|
const env = {
|
||||||
|
CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: '1',
|
||||||
|
};
|
||||||
|
|
||||||
|
applyDesktopTeammateModeDecisionToEnv(env, { forceProcessTeammates: false });
|
||||||
|
|
||||||
|
expect(env).not.toHaveProperty('CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds injected teammate mode cli args only when a mode was selected', async () => {
|
||||||
|
const { buildDesktopTeammateModeCliArgs } =
|
||||||
|
await import('@main/services/team/runtimeTeammateMode');
|
||||||
|
|
||||||
|
expect(buildDesktopTeammateModeCliArgs({ injectedTeammateMode: 'tmux' })).toEqual([
|
||||||
|
'--teammate-mode',
|
||||||
|
'tmux',
|
||||||
|
]);
|
||||||
|
expect(buildDesktopTeammateModeCliArgs({ injectedTeammateMode: null })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('re-checks tmux readiness after the environment changes instead of keeping a stale negative cache', async () => {
|
it('re-checks tmux readiness after the environment changes instead of keeping a stale negative cache', async () => {
|
||||||
|
|
@ -44,7 +94,7 @@ describe('runtimeTeammateMode', () => {
|
||||||
const firstDecision = await resolveDesktopTeammateModeDecision(undefined);
|
const firstDecision = await resolveDesktopTeammateModeDecision(undefined);
|
||||||
const secondDecision = await resolveDesktopTeammateModeDecision(undefined);
|
const secondDecision = await resolveDesktopTeammateModeDecision(undefined);
|
||||||
|
|
||||||
expect(firstDecision.forceProcessTeammates).toBe(false);
|
expect(firstDecision.forceProcessTeammates).toBe(true);
|
||||||
expect(firstDecision.injectedTeammateMode).toBeNull();
|
expect(firstDecision.injectedTeammateMode).toBeNull();
|
||||||
expect(secondDecision.forceProcessTeammates).toBe(true);
|
expect(secondDecision.forceProcessTeammates).toBe(true);
|
||||||
expect(secondDecision.injectedTeammateMode).toBe('tmux');
|
expect(secondDecision.injectedTeammateMode).toBe('tmux');
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,10 @@ async function flushReact(): Promise<void> {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withoutNumberGroupSeparators(value: string | null | undefined): string {
|
||||||
|
return (value ?? '').replace(/[\s,\u00a0\u202f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
describe('TokenUsageDisplay', () => {
|
describe('TokenUsageDisplay', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|
@ -90,7 +94,7 @@ describe('TokenUsageDisplay', () => {
|
||||||
|
|
||||||
const popover = document.querySelector('[role="tooltip"]');
|
const popover = document.querySelector('[role="tooltip"]');
|
||||||
expect(popover).toBeTruthy();
|
expect(popover).toBeTruthy();
|
||||||
expect(popover?.textContent).toContain('2,250');
|
expect(withoutNumberGroupSeparators(popover?.textContent)).toContain('2250');
|
||||||
expect(popover?.textContent).toContain('500 (25.0% of prompt input)');
|
expect(popover?.textContent).toContain('500 (25.0% of prompt input)');
|
||||||
expect(popover?.textContent).not.toContain('of context');
|
expect(popover?.textContent).not.toContain('of context');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
||||||
expect(result.memberWarningById).toEqual({});
|
expect(result.memberWarningById).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks mixed-provider teammates when tmux is unavailable', () => {
|
it('allows mixed-provider teammates through native process transport when tmux is unavailable', () => {
|
||||||
const result = analyzeTeammateRuntimeCompatibility({
|
const result = analyzeTeammateRuntimeCompatibility({
|
||||||
leadProviderId: 'anthropic',
|
leadProviderId: 'anthropic',
|
||||||
members: [{ id: 'bob', name: 'bob', providerId: 'codex' }],
|
members: [{ id: 'bob', name: 'bob', providerId: 'codex' }],
|
||||||
|
|
@ -64,9 +64,9 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
||||||
tmuxStatusError: null,
|
tmuxStatusError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.blocksSubmission).toBe(true);
|
expect(result.blocksSubmission).toBe(false);
|
||||||
expect(result.details.join('\n')).toContain('Mixed providers');
|
expect(result.visible).toBe(false);
|
||||||
expect(result.memberWarningById.bob).toContain('same provider as the Anthropic lead');
|
expect(result.memberWarningById).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows OpenCode secondary-lane teammates without tmux under a non-OpenCode lead', () => {
|
it('allows OpenCode secondary-lane teammates without tmux under a non-OpenCode lead', () => {
|
||||||
|
|
@ -98,7 +98,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
||||||
expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead');
|
expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks same-provider Codex native teammates when tmux is unavailable', () => {
|
it('allows same-provider Codex native teammates through native process transport when tmux is unavailable', () => {
|
||||||
const result = analyzeTeammateRuntimeCompatibility({
|
const result = analyzeTeammateRuntimeCompatibility({
|
||||||
leadProviderId: 'codex',
|
leadProviderId: 'codex',
|
||||||
leadProviderBackendId: 'codex-native',
|
leadProviderBackendId: 'codex-native',
|
||||||
|
|
@ -108,11 +108,9 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
||||||
tmuxStatusError: null,
|
tmuxStatusError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.blocksSubmission).toBe(true);
|
expect(result.blocksSubmission).toBe(false);
|
||||||
expect(result.title).toBe('Codex teammates need tmux before they can run');
|
expect(result.visible).toBe(false);
|
||||||
expect(result.message).toContain('The Codex lead can run without tmux');
|
expect(result.memberWarningById).toEqual({});
|
||||||
expect(result.details.join('\n')).toContain('Codex native teammates');
|
|
||||||
expect(result.memberWarningById.jack).toContain('Codex native teammates require');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows separate-process teammate requirements when tmux is ready', () => {
|
it('allows separate-process teammate requirements when tmux is ready', () => {
|
||||||
|
|
@ -155,5 +153,23 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
||||||
|
|
||||||
expect(result.blocksSubmission).toBe(true);
|
expect(result.blocksSubmission).toBe(true);
|
||||||
expect(result.details).toContain('Custom CLI args force --teammate-mode tmux.');
|
expect(result.details).toContain('Custom CLI args force --teammate-mode tmux.');
|
||||||
|
expect(result.message).toContain('native process transport');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks explicit in-process mode when a teammate requires a separate process', () => {
|
||||||
|
const result = analyzeTeammateRuntimeCompatibility({
|
||||||
|
leadProviderId: 'anthropic',
|
||||||
|
members: [{ id: 'bob', name: 'bob', providerId: 'codex' }],
|
||||||
|
extraCliArgs: '--teammate-mode=in-process',
|
||||||
|
tmuxStatus: buildTmuxStatus(true),
|
||||||
|
tmuxStatusLoading: false,
|
||||||
|
tmuxStatusError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.blocksSubmission).toBe(true);
|
||||||
|
expect(result.title).toBe('This team cannot use in-process teammates');
|
||||||
|
expect(result.details).toContain('Custom CLI args force --teammate-mode in-process.');
|
||||||
|
expect(result.message).toContain('native process transport');
|
||||||
|
expect(result.memberWarningById.bob).toContain('requires a separate process');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue