feat(team): make tmux optional for desktop teammates Merge pull request #89 from 777genius/feat/tmux-optional-process-backend

Merge pull request #89 from 777genius/feat/tmux-optional-process-backend
This commit is contained in:
Илия 2026-04-28 16:42:43 +03:00 committed by GitHub
commit 2c2f84a00e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 209 additions and 45 deletions

View file

@ -186,7 +186,11 @@ import {
buildProgressAssistantOutput,
buildProgressLogsTail,
} from './progressPayload';
import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode';
import {
applyDesktopTeammateModeDecisionToEnv,
buildDesktopTeammateModeCliArgs,
resolveDesktopTeammateModeDecision,
} from './runtimeTeammateMode';
import {
choosePreferredLaunchSnapshot,
clearBootstrapState,
@ -1101,6 +1105,9 @@ function buildRuntimeLaunchWarning(
if (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 flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : '';
const geminiAuth = options?.geminiRuntimeAuth;
@ -1164,6 +1171,7 @@ function logRuntimeLaunchSnapshot(
CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null,
CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_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_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null,
},
@ -11788,9 +11796,7 @@ export class TeamProvisioningService {
let child: ReturnType<typeof spawn>;
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
if (teammateModeDecision.forceProcessTeammates) {
shellEnv.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1';
}
applyDesktopTeammateModeDecisionToEnv(shellEnv, teammateModeDecision);
let mcpConfigPath: string;
let bootstrapSpecPath: string;
let bootstrapUserPromptPath: string | null = null;
@ -11854,6 +11860,7 @@ export class TeamProvisioningService {
...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []),
...providerFastModeArgs,
...(request.worktree ? ['--worktree', request.worktree] : []),
...buildDesktopTeammateModeCliArgs(teammateModeDecision),
...parseCliArgs(request.extraCliArgs),
...providerArgs,
]);
@ -12863,9 +12870,7 @@ export class TeamProvisioningService {
let child: ReturnType<typeof spawn>;
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
if (teammateModeDecision.forceProcessTeammates) {
shellEnv.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1';
}
applyDesktopTeammateModeDecisionToEnv(shellEnv, teammateModeDecision);
let mcpConfigPath: string;
let bootstrapSpecPath: string;
let bootstrapUserPromptPath: string | null = null;
@ -12945,6 +12950,7 @@ export class TeamProvisioningService {
if (request.worktree) {
launchArgs.push('--worktree', request.worktree);
}
launchArgs.push(...buildDesktopTeammateModeCliArgs(teammateModeDecision));
launchArgs.push(...parseCliArgs(request.extraCliArgs));
launchArgs.push(...providerArgs);
// When the lead uses a different provider than some teammates (e.g., anthropic lead
@ -15500,8 +15506,9 @@ export class TeamProvisioningService {
}
const paneIds = [...metadataByMember.values()]
.filter((metadata) => metadata.backendType === 'tmux' || metadata.backendType === undefined)
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
.filter((paneId) => paneId.length > 0);
.filter((paneId) => paneId.length > 0 && !paneId.startsWith('process:'));
let paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
if (paneIds.length > 0) {
try {

View file

@ -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 {
injectedTeammateMode: null,
forceProcessTeammates: false,
};
}
if (!(await isTmuxAvailable())) {
return {
injectedTeammateMode: null,
forceProcessTeammates: false,
};
}
const tmuxAvailable = await isTmuxAvailable();
return {
injectedTeammateMode: 'tmux',
injectedTeammateMode: tmuxAvailable ? 'tmux' : null,
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] : [];
}

View file

@ -12,6 +12,7 @@ type TeammateRuntimeIssueReason =
| 'mixed-provider'
| 'codex-native-runtime'
| 'explicit-tmux-mode'
| 'explicit-in-process-mode'
| 'opencode-led-mixed-unsupported';
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) {
return {
visible: false,
@ -193,7 +201,9 @@ export function analyzeTeammateRuntimeCompatibility({
const hasOpenCodeLeadMixedUnsupported = issues.some(
(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 {
visible: 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 hasMixedProviders = issues.some((issue) => issue.reason === 'mixed-provider');
const hasCodexNative = issues.some((issue) => issue.reason === 'codex-native-runtime');
const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode');
const details: string[] = [];
const memberWarningById: Record<string, string> = {};
@ -241,15 +268,20 @@ export function analyzeTeammateRuntimeCompatibility({
if (hasExplicitTmux) {
details.push('Custom CLI args force --teammate-mode tmux.');
}
if (hasExplicitInProcess) {
details.push('Custom CLI args force --teammate-mode in-process.');
}
if (hasOpenCodeLeadMixedUnsupported) {
details.push(
'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 {
details.push(
hasCodexNative && !hasMixedProviders
? '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.'
'Fix: install tmux/WSL tmux, or remove --teammate-mode tmux so the app can use native process transport.'
);
}
@ -260,10 +292,10 @@ export function analyzeTeammateRuntimeCompatibility({
if (issue.reason === 'mixed-provider') {
memberWarningById[issue.memberId] =
`${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') {
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') {
memberWarningById[issue.memberId] =
`${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` +
@ -276,19 +308,19 @@ export function analyzeTeammateRuntimeCompatibility({
blocksSubmission,
checking,
title: checking
? 'Checking tmux runtime for teammate support'
? 'Checking tmux runtime for explicit teammate mode'
: hasOpenCodeLeadMixedUnsupported
? 'OpenCode cannot lead mixed-provider teams'
: hasCodexNative && !hasMixedProviders
? 'Codex teammates need tmux before they can run'
: 'This team needs tmux before it can run',
: hasExplicitInProcess
? 'This team cannot use in-process teammates'
: 'tmux is not ready for explicit teammate mode',
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
? '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
? '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.'
: '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.',
: hasExplicitInProcess
? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.'
: '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,
tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError),
memberWarningById,

View file

@ -604,6 +604,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 () => {
const svc = new TeamProvisioningService();
(svc as any).configReader = {

View file

@ -23,15 +23,65 @@ describe('runtimeTeammateMode', () => {
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);
const { resolveDesktopTeammateModeDecision } =
await import('@main/services/team/runtimeTeammateMode');
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.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 () => {
@ -44,7 +94,7 @@ describe('runtimeTeammateMode', () => {
const firstDecision = await resolveDesktopTeammateModeDecision(undefined);
const secondDecision = await resolveDesktopTeammateModeDecision(undefined);
expect(firstDecision.forceProcessTeammates).toBe(false);
expect(firstDecision.forceProcessTeammates).toBe(true);
expect(firstDecision.injectedTeammateMode).toBeNull();
expect(secondDecision.forceProcessTeammates).toBe(true);
expect(secondDecision.injectedTeammateMode).toBe('tmux');

View file

@ -54,6 +54,10 @@ async function flushReact(): Promise<void> {
await Promise.resolve();
}
function withoutNumberGroupSeparators(value: string | null | undefined): string {
return (value ?? '').replace(/[\s,\u00a0\u202f]/g, '');
}
describe('TokenUsageDisplay', () => {
afterEach(() => {
document.body.innerHTML = '';
@ -90,7 +94,7 @@ describe('TokenUsageDisplay', () => {
const popover = document.querySelector('[role="tooltip"]');
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).not.toContain('of context');

View file

@ -55,7 +55,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
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({
leadProviderId: 'anthropic',
members: [{ id: 'bob', name: 'bob', providerId: 'codex' }],
@ -64,9 +64,9 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
tmuxStatusError: null,
});
expect(result.blocksSubmission).toBe(true);
expect(result.details.join('\n')).toContain('Mixed providers');
expect(result.memberWarningById.bob).toContain('same provider as the Anthropic lead');
expect(result.blocksSubmission).toBe(false);
expect(result.visible).toBe(false);
expect(result.memberWarningById).toEqual({});
});
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');
});
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({
leadProviderId: 'codex',
leadProviderBackendId: 'codex-native',
@ -108,11 +108,9 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
tmuxStatusError: null,
});
expect(result.blocksSubmission).toBe(true);
expect(result.title).toBe('Codex teammates need tmux before they can run');
expect(result.message).toContain('The Codex lead can run without tmux');
expect(result.details.join('\n')).toContain('Codex native teammates');
expect(result.memberWarningById.jack).toContain('Codex native teammates require');
expect(result.blocksSubmission).toBe(false);
expect(result.visible).toBe(false);
expect(result.memberWarningById).toEqual({});
});
it('allows separate-process teammate requirements when tmux is ready', () => {
@ -155,5 +153,23 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
expect(result.blocksSubmission).toBe(true);
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');
});
});