fix(team): restart shell-only tmux teammates directly

This commit is contained in:
777genius 2026-04-28 19:06:36 +03:00
parent 4d5533585c
commit 42c9cbd227
6 changed files with 850 additions and 65 deletions

View file

@ -40,6 +40,13 @@ export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
return runtimeCommandExecutor.listRuntimeProcesses();
}
export async function sendKeysToTmuxPaneForCurrentPlatform(
paneId: string,
command: string
): Promise<void> {
await runtimeCommandExecutor.sendKeysToPane(paneId, command);
}
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
runtimeCommandExecutor.killPaneSync(paneId);
invalidateTmuxRuntimeStatusCache();

View file

@ -12,6 +12,7 @@ export {
listRuntimeProcessesForCurrentTmuxPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
sendKeysToTmuxPaneForCurrentPlatform,
} from './composition/runtimeSupport';
export type {
RuntimeProcessTableRow,

View file

@ -1,4 +1,7 @@
import { execFile, execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
@ -19,6 +22,7 @@ export interface TmuxPaneRuntimeInfo {
currentPath?: string;
sessionName?: string;
windowName?: string;
socketName?: string;
}
export interface RuntimeProcessTableRow {
@ -61,16 +65,17 @@ export class TmuxPlatformCommandExecutor {
this.#packageManagerResolver = packageManagerResolver;
}
async execTmux(args: string[], timeout = 5_000): Promise<ExecResult> {
async execTmux(args: string[], timeout = 5_000, socketName?: string): Promise<ExecResult> {
const effectiveArgs = socketName ? ['-L', socketName, ...args] : args;
if (process.platform === 'win32') {
return this.#wslService.execTmux(args, null, timeout);
return this.#wslService.execTmux(effectiveArgs, null, timeout);
}
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
const executable = await this.#resolveNativeTmuxExecutable(env);
return new Promise((resolve) => {
execFile(executable, args, { env, timeout }, (error, stdout, stderr) => {
execFile(executable, effectiveArgs, { env, timeout }, (error, stdout, stderr) => {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as NodeJS.ErrnoException).code
@ -85,10 +90,16 @@ export class TmuxPlatformCommandExecutor {
}
async killPane(paneId: string): Promise<void> {
const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000);
if (result.exitCode !== 0) {
throw new Error(result.stderr || `Failed to kill tmux pane ${paneId}`);
const candidates = await this.#getTmuxSocketCandidates();
let lastError = '';
for (const socketName of candidates) {
const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000, socketName);
if (result.exitCode === 0) {
return;
}
lastError = result.stderr || `Failed to kill tmux pane ${paneId}`;
}
throw new Error(lastError || `Failed to kill tmux pane ${paneId}`);
}
async listPaneRuntimeInfo(paneIds: readonly string[]): Promise<Map<string, TmuxPaneRuntimeInfo>> {
@ -106,37 +117,48 @@ export class TmuxPlatformCommandExecutor {
'#{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 paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
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) {
paneInfoById.set(normalizedPaneId, {
paneId: normalizedPaneId,
panePid: pid,
currentCommand: currentCommand.trim() || undefined,
currentPath: currentPath.trim() || undefined,
sessionName: sessionName.trim() || undefined,
windowName: windowName.trim() || undefined,
});
const candidates = await this.#getTmuxSocketCandidates();
let sawSuccessfulList = false;
let lastError = '';
for (const socketName of candidates) {
const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000, socketName);
if (result.exitCode !== 0) {
lastError = result.stderr || 'Failed to list tmux panes';
continue;
}
sawSuccessfulList = true;
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const [
paneId = '',
rawPid = '',
currentCommand = '',
currentPath = '',
sessionName = '',
windowName = '',
] = trimmed.split('\t');
const normalizedPaneId = paneId.trim();
if (!wanted.has(normalizedPaneId) || paneInfoById.has(normalizedPaneId)) continue;
const pid = Number.parseInt(rawPid.trim(), 10);
if (Number.isFinite(pid) && pid > 0) {
paneInfoById.set(normalizedPaneId, {
paneId: normalizedPaneId,
panePid: pid,
currentCommand: currentCommand.trim() || undefined,
currentPath: currentPath.trim() || undefined,
sessionName: sessionName.trim() || undefined,
windowName: windowName.trim() || undefined,
...(socketName ? { socketName } : {}),
});
}
}
}
if (!sawSuccessfulList) {
throw new Error(lastError || 'Failed to list tmux panes');
}
return paneInfoById;
}
@ -157,6 +179,19 @@ export class TmuxPlatformCommandExecutor {
return parseRuntimeProcessTable(result.stdout);
}
async sendKeysToPane(paneId: string, command: string): Promise<void> {
const paneInfo = await this.listPaneRuntimeInfo([paneId]);
const socketName = paneInfo.get(paneId)?.socketName;
const result = await this.execTmux(
['send-keys', '-t', paneId, command, 'Enter'],
3_000,
socketName
);
if (result.exitCode !== 0) {
throw new Error(result.stderr || `Failed to send command to tmux pane ${paneId}`);
}
}
killPaneSync(paneId: string): void {
if (process.platform === 'win32') {
const preferredDistro = this.#wslService.getPersistedPreferredDistroSync();
@ -183,8 +218,24 @@ export class TmuxPlatformCommandExecutor {
throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`);
}
// eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used
execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' });
const candidates = this.#getTmuxSocketCandidatesSync();
let lastError: Error | null = null;
for (const socketName of candidates) {
try {
execFileSync(
// eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used
'tmux',
[...(socketName ? ['-L', socketName] : []), 'kill-pane', '-t', paneId],
{
stdio: 'ignore',
}
);
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`);
}
#getWslExecutableCandidates(): string[] {
@ -221,6 +272,69 @@ export class TmuxPlatformCommandExecutor {
});
}
async #getTmuxSocketCandidates(): Promise<(string | undefined)[]> {
if (process.platform === 'win32') {
return [undefined];
}
return [...(await this.#listNativeSwarmSocketNames()), undefined];
}
#getTmuxSocketCandidatesSync(): (string | undefined)[] {
if (process.platform === 'win32') {
return [undefined];
}
return [...this.#listNativeSwarmSocketNamesSync(), undefined];
}
async #listNativeSwarmSocketNames(): Promise<string[]> {
const dirs = this.#getNativeTmuxSocketDirs();
const names = new Set<string>();
await Promise.all(
dirs.map(async (dir) => {
let entries: string[];
try {
entries = await fs.promises.readdir(dir);
} catch {
return;
}
for (const entry of entries) {
if (entry.startsWith('claude-swarm-')) {
names.add(entry);
}
}
})
);
return [...names].sort((left, right) => left.localeCompare(right));
}
#listNativeSwarmSocketNamesSync(): string[] {
const names = new Set<string>();
for (const dir of this.#getNativeTmuxSocketDirs()) {
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch {
continue;
}
for (const entry of entries) {
if (entry.startsWith('claude-swarm-')) {
names.add(entry);
}
}
}
return [...names].sort((left, right) => left.localeCompare(right));
}
#getNativeTmuxSocketDirs(): string[] {
const uid = typeof process.getuid === 'function' ? process.getuid() : os.userInfo().uid;
const candidates = [
path.join('/tmp', `tmux-${uid}`),
path.join('/private/tmp', `tmux-${uid}`),
path.join(os.tmpdir(), `tmux-${uid}`),
];
return [...new Set(candidates)];
}
async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise<string> {
const platform =
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'

View file

@ -11,6 +11,7 @@ vi.mock('node:child_process', async () => {
});
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs';
import { TmuxPlatformCommandExecutor } from '../TmuxPlatformCommandExecutor';
@ -28,6 +29,7 @@ const originalWindir = process.env.WINDIR;
describe('TmuxPlatformCommandExecutor', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(fs.promises, 'readdir').mockRejectedValue(new Error('ENOENT'));
});
afterEach(() => {
@ -76,7 +78,7 @@ describe('TmuxPlatformCommandExecutor', () => {
} as never,
{} as never
);
vi.spyOn(executor, 'execTmux').mockResolvedValue({
const execTmux = vi.spyOn(executor, 'execTmux').mockResolvedValue({
exitCode: 0,
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',
@ -86,14 +88,15 @@ describe('TmuxPlatformCommandExecutor', () => {
await expect(executor.listPanePids(['%2', '%3', '%2'])).resolves.toEqual(
new Map([['%2', 222]])
);
expect(executor.execTmux).toHaveBeenCalledWith(
expect(execTmux).toHaveBeenCalledWith(
[
'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
3_000,
undefined
);
});

View file

@ -19,6 +19,7 @@ import {
listRuntimeProcessesForCurrentTmuxPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
sendKeysToTmuxPaneForCurrentPlatform,
type TmuxPaneRuntimeInfo,
} from '@features/tmux-installer/main';
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
@ -642,6 +643,60 @@ const STALL_CHECK_INTERVAL_MS = 10_000;
const STALL_WARNING_THRESHOLD_MS = 20_000;
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS =
'TeamDelete,TodoWrite,TaskCreate,TaskUpdate,mcp__agent-teams__team_launch,mcp__agent-teams__team_stop';
const DIRECT_TMUX_RESTART_ENV_KEYS = [
'CLAUDE_CONFIG_DIR',
'CLAUDE_TEAM_CONTROL_URL',
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_ENTRY_PROVIDER',
'CLAUDE_CODE_GEMINI_BACKEND',
'CLAUDE_CODE_CODEX_BACKEND',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'GEMINI_BASE_URL',
'GEMINI_API_VERSION',
'GEMINI_API_KEY',
'CODEX_API_KEY',
'OPENAI_API_KEY',
'GOOGLE_APPLICATION_CREDENTIALS',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_PROJECT_ID',
'GCLOUD_PROJECT',
'HTTPS_PROXY',
'https_proxy',
'HTTP_PROXY',
'http_proxy',
'NO_PROXY',
'no_proxy',
'SSL_CERT_FILE',
'NODE_EXTRA_CA_CERTS',
'REQUESTS_CA_BUNDLE',
'CURL_CA_BUNDLE',
] as const;
const DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS = [
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_ENTRY_PROVIDER',
] as const;
const INTERACTIVE_SHELL_COMMANDS = new Set([
'bash',
'zsh',
'sh',
'fish',
'nu',
'pwsh',
'powershell',
'cmd',
'cmd.exe',
]);
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
@ -712,6 +767,69 @@ function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): st
return mergeJsonSettingsArgs([...providerArgs, ...args]);
}
function shellQuote(value: string): string {
if (value.length === 0) {
return "''";
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function isInteractiveShellCommand(command: string | undefined): boolean {
const normalized = command?.trim().toLowerCase();
if (!normalized) {
return false;
}
return INTERACTIVE_SHELL_COMMANDS.has(path.basename(normalized));
}
function getDirectRestartEntryProvider(providerId: TeamProviderId): string {
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
}
function buildDirectTmuxRestartEnvAssignments(
env: NodeJS.ProcessEnv,
providerId: TeamProviderId
): string {
const assignments = new Map<string, string>();
assignments.set('CLAUDECODE', '1');
assignments.set('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '1');
for (const key of DIRECT_TMUX_RESTART_ENV_KEYS) {
const value = env[key];
if (typeof value === 'string' && value.length > 0) {
assignments.set(key, value);
}
}
for (const key of DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS) {
assignments.set(key, '');
}
assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId));
return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ');
}
function buildDirectTmuxRestartCommand(input: {
cwd: string;
env: NodeJS.ProcessEnv;
providerId: TeamProviderId;
binaryPath: string;
args: string[];
}): string {
const envAssignments = buildDirectTmuxRestartEnvAssignments(input.env, input.providerId);
const command = [
'cd',
shellQuote(input.cwd),
'&&',
'env',
envAssignments,
shellQuote(input.binaryPath),
...input.args.map(shellQuote),
].join(' ');
return `(${command}); __claude_teammate_exit=$?; printf '\\n__CLAUDE_TEAMMATE_EXIT__:%s\\n' "$__claude_teammate_exit"`;
}
interface ProviderModelListCommandResponse {
schemaVersion?: number;
providers?: Record<
@ -4781,8 +4899,7 @@ export class TeamProvisioningService {
if (existing) {
return existing;
}
let cleanup!: Promise<number>;
cleanup = this.stopOpenCodeRuntimeLanesForStoppedTeamInternal(teamName).finally(() => {
const cleanup = this.stopOpenCodeRuntimeLanesForStoppedTeamInternal(teamName).finally(() => {
if (this.stoppedTeamOpenCodeRuntimeCleanupInFlight.get(teamName) === cleanup) {
this.stoppedTeamOpenCodeRuntimeCleanupInFlight.delete(teamName);
}
@ -9536,6 +9653,282 @@ export class TeamProvisioningService {
return snapshot;
}
private getDirectTmuxRestartPaneId(
persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[],
memberName: string
): string | null {
for (const persistedRuntimeMember of persistedRuntimeMembers) {
const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase();
const paneId =
typeof persistedRuntimeMember.tmuxPaneId === 'string'
? persistedRuntimeMember.tmuxPaneId.trim()
: '';
const runtimeMemberName =
typeof persistedRuntimeMember.name === 'string' ? persistedRuntimeMember.name : '';
if (
backendType === 'tmux' &&
paneId &&
matchesMemberNameOrBase(runtimeMemberName, memberName)
) {
return paneId;
}
}
return null;
}
private resolveDirectRestartRuntimeCwd(params: {
configuredMember: NonNullable<
ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>
>;
persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[];
config: TeamConfig;
run: ProvisioningRun;
}): string {
const configuredCwd = params.configuredMember.cwd?.trim();
if (configuredCwd) {
return path.resolve(configuredCwd);
}
for (const runtimeMember of params.persistedRuntimeMembers) {
const cwd = typeof runtimeMember.cwd === 'string' ? runtimeMember.cwd.trim() : '';
if (cwd) {
return path.resolve(cwd);
}
}
const projectPath = params.config.projectPath?.trim();
if (projectPath) {
return path.resolve(projectPath);
}
const runCwd = this.getRunTrackedCwd(params.run);
if (runCwd) {
return path.resolve(runCwd);
}
throw new Error('Cannot restart teammate because its runtime cwd is unavailable');
}
private async updateDirectTmuxRestartMemberConfig(input: {
teamName: string;
memberName: string;
member: NonNullable<ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>>;
agentId: string;
color: string;
prompt: string;
paneId: string;
cwd: string;
providerId: TeamProviderId;
joinedAt: number;
bootstrapExpectedAfter: string;
}): Promise<void> {
const configPath = path.join(getTeamsBasePath(), input.teamName, 'config.json');
const raw = await tryReadRegularFileUtf8(configPath, {
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
maxBytes: TEAM_CONFIG_MAX_BYTES,
});
if (!raw) {
throw new Error(`Team "${input.teamName}" configuration is no longer available`);
}
const parsed = JSON.parse(raw) as TeamConfig & { members?: Record<string, unknown>[] };
const members = Array.isArray(parsed.members) ? parsed.members : [];
const existingIndex = members.findIndex((member) => {
const candidateName = typeof member?.name === 'string' ? member.name.trim() : '';
return (
candidateName.length > 0 && matchesExactTeamMemberName(candidateName, input.memberName)
);
});
const existing: Record<string, unknown> =
existingIndex >= 0 ? (members[existingIndex] ?? {}) : {};
const nextMember = {
...existing,
agentId: input.agentId,
name: input.member.name,
...(input.member.role ? { role: input.member.role } : {}),
...(input.member.workflow ? { workflow: input.member.workflow } : {}),
...(input.member.agentType ? { agentType: input.member.agentType } : {}),
provider: input.providerId,
providerId: input.providerId,
...(input.member.model ? { model: input.member.model } : {}),
...(input.member.effort ? { effort: input.member.effort } : {}),
prompt: input.prompt,
color: input.color,
joinedAt: input.joinedAt,
bootstrapExpectedAfter: input.bootstrapExpectedAfter,
tmuxPaneId: input.paneId,
cwd: input.cwd,
subscriptions: Array.isArray(existing.subscriptions) ? existing.subscriptions : [],
backendType: 'tmux',
};
if (existingIndex >= 0) {
members[existingIndex] = nextMember;
} else {
members.push(nextMember);
}
parsed.members = members;
await atomicWriteAsync(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
}
private enqueueDirectRestartPrompt(input: {
teamName: string;
memberName: string;
leadName: string;
leadSessionId: string | null;
prompt: string;
}): void {
const timestamp = nowIso();
this.persistInboxMessage(input.teamName, input.memberName, {
from: input.leadName,
to: input.memberName,
text: input.prompt,
timestamp,
read: false,
source: 'system_notification',
leadSessionId: input.leadSessionId ?? undefined,
messageId: `direct-restart-${input.memberName}-${randomUUID()}`,
summary: `Restart bootstrap instructions for ${input.memberName}`,
});
}
private async launchDirectTmuxMemberRestart(input: {
run: ProvisioningRun;
teamName: string;
displayName: string;
leadName: string;
memberName: string;
config: TeamConfig;
configuredMember: NonNullable<
ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>
>;
persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[];
paneId: string;
}): Promise<void> {
const paneInfo = (await listTmuxPaneRuntimeInfoForCurrentPlatform([input.paneId])).get(
input.paneId
);
if (!paneInfo) {
throw new Error(
`Cannot restart teammate "${input.memberName}" because tmux pane ${input.paneId} is not available`
);
}
if (!isInteractiveShellCommand(paneInfo.currentCommand)) {
throw new Error(
`Cannot restart teammate "${input.memberName}" because tmux pane ${input.paneId} is busy (${paneInfo.currentCommand ?? 'unknown command'})`
);
}
const providerId = resolveTeamProviderId(input.configuredMember.providerId);
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
const cwd = this.resolveDirectRestartRuntimeCwd({
configuredMember: input.configuredMember,
persistedRuntimeMembers: input.persistedRuntimeMembers,
config: input.config,
run: input.run,
});
await ensureCwdExists(cwd);
const provisioningEnv = await this.buildProvisioningEnv(
providerId,
input.configuredMember.providerBackendId
);
if (provisioningEnv.warning) {
throw new Error(provisioningEnv.warning);
}
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd);
const agentId = `${input.configuredMember.name}@${input.teamName}`;
const color =
input.config.members
?.find((member) => matchesExactTeamMemberName(member.name, input.memberName))
?.color?.trim() || getMemberColorByName(input.configuredMember.name);
const parentSessionId =
input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId;
const prompt = buildMemberSpawnPrompt(
{
name: input.configuredMember.name,
...(input.configuredMember.role ? { role: input.configuredMember.role } : {}),
...(input.configuredMember.workflow ? { workflow: input.configuredMember.workflow } : {}),
...(input.configuredMember.providerId
? { providerId: input.configuredMember.providerId }
: {}),
...(input.configuredMember.model ? { model: input.configuredMember.model } : {}),
...(input.configuredMember.effort ? { effort: input.configuredMember.effort } : {}),
},
input.displayName,
input.teamName,
input.leadName
);
const bootstrapExpectedAfter = nowIso();
const runtimeArgs = mergeJsonSettingsArgs([
'--agent-id',
agentId,
'--agent-name',
input.configuredMember.name,
'--team-name',
input.teamName,
'--agent-color',
color,
'--parent-session-id',
parentSessionId,
...(input.configuredMember.agentType
? ['--agent-type', input.configuredMember.agentType]
: []),
'--mcp-config',
mcpConfigPath,
'--strict-mcp-config',
'--disallowedTools',
APP_TEAM_RUNTIME_DISALLOWED_TOOLS,
...(input.run.request.skipPermissions !== false
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []),
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []),
...(provisioningEnv.providerArgs ?? []),
]);
const command = buildDirectTmuxRestartCommand({
cwd,
env: provisioningEnv.env,
providerId,
binaryPath: claudePath,
args: runtimeArgs,
});
await this.updateDirectTmuxRestartMemberConfig({
teamName: input.teamName,
memberName: input.memberName,
member: input.configuredMember,
agentId,
color,
prompt,
paneId: input.paneId,
cwd,
providerId,
joinedAt: Date.now(),
bootstrapExpectedAfter,
});
this.enqueueDirectRestartPrompt({
teamName: input.teamName,
memberName: input.configuredMember.name,
leadName: input.leadName,
leadSessionId: parentSessionId,
prompt,
});
await sendKeysToTmuxPaneForCurrentPlatform(input.paneId, command);
this.appendMemberBootstrapDiagnostic(
input.run,
input.memberName,
`restart command delivered to tmux pane ${input.paneId}`
);
this.setMemberSpawnStatus(input.run, input.memberName, 'waiting');
}
async restartMember(teamName: string, memberName: string): Promise<void> {
const runId = this.getAliveRunId(teamName);
if (!runId) {
@ -9573,8 +9966,9 @@ export class TeamProvisioningService {
};
};
let { config, configuredMembers, metaMembers, configuredMember } =
await readCurrentConfiguredMember();
let currentConfiguredMemberState = await readCurrentConfiguredMember();
let config = currentConfiguredMemberState.config;
let configuredMember = currentConfiguredMemberState.configuredMember;
if (!config) {
throw new Error(`Team "${teamName}" configuration is no longer available`);
}
@ -9609,6 +10003,10 @@ export class TeamProvisioningService {
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName);
});
const directTmuxRestartCandidatePaneId = this.getDirectTmuxRestartPaneId(
persistedRuntimeMembers,
memberName
);
const backendTypes = new Set(
persistedRuntimeMembers
@ -9645,31 +10043,51 @@ export class TeamProvisioningService {
);
}
const tmuxPaneIdsToVerify: string[] = [];
for (const persistedRuntimeMember of persistedRuntimeMembers) {
const paneId =
typeof persistedRuntimeMember.tmuxPaneId === 'string'
? persistedRuntimeMember.tmuxPaneId.trim()
: '';
const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase();
if (!paneId || backendType !== 'tmux') {
continue;
}
tmuxPaneIdsToVerify.push(paneId);
let directTmuxRestartPaneId: string | null = null;
if (directTmuxRestartCandidatePaneId) {
try {
killTmuxPaneForCurrentPlatformSync(paneId);
logger.info(
`[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart`
);
const paneInfo = (
await listTmuxPaneRuntimeInfoForCurrentPlatform([directTmuxRestartCandidatePaneId])
).get(directTmuxRestartCandidatePaneId);
if (paneInfo && isInteractiveShellCommand(paneInfo.currentCommand)) {
directTmuxRestartPaneId = directTmuxRestartCandidatePaneId;
}
} catch (error) {
logger.debug(
`[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${
`[${teamName}] Direct tmux restart probe failed for ${memberName}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
const tmuxPaneIdsToVerify: string[] = [];
if (!directTmuxRestartPaneId) {
for (const persistedRuntimeMember of persistedRuntimeMembers) {
const paneId =
typeof persistedRuntimeMember.tmuxPaneId === 'string'
? persistedRuntimeMember.tmuxPaneId.trim()
: '';
const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase();
if (!paneId || backendType !== 'tmux') {
continue;
}
tmuxPaneIdsToVerify.push(paneId);
try {
killTmuxPaneForCurrentPlatformSync(paneId);
logger.info(
`[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart`
);
} catch (error) {
logger.debug(
`[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
}
for (const pid of livePids) {
try {
killProcessByPid(pid);
@ -9729,8 +10147,9 @@ export class TeamProvisioningService {
throw new Error(`Team "${teamName}" is not currently running`);
}
({ config, configuredMembers, metaMembers, configuredMember } =
await readCurrentConfiguredMember());
currentConfiguredMemberState = await readCurrentConfiguredMember();
config = currentConfiguredMemberState.config;
configuredMember = currentConfiguredMemberState.configuredMember;
if (!config) {
throw new Error(`Team "${teamName}" configuration disappeared while restart was in progress`);
}
@ -9765,7 +10184,36 @@ export class TeamProvisioningService {
},
});
const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers);
const leadName = this.resolveLeadMemberName(
currentConfiguredMemberState.configuredMembers,
currentConfiguredMemberState.metaMembers
);
if (directTmuxRestartPaneId) {
try {
await this.launchDirectTmuxMemberRestart({
run,
teamName,
displayName: config?.name?.trim() || teamName,
leadName,
memberName,
config,
configuredMember,
persistedRuntimeMembers,
paneId: directTmuxRestartPaneId,
});
return;
} catch (error) {
run.pendingMemberRestarts.delete(memberName);
this.setMemberSpawnStatus(
run,
memberName,
'error',
error instanceof Error ? error.message : String(error)
);
throw error;
}
}
const restartMessage = buildRestartMemberSpawnMessage(
teamName,
config?.name?.trim() || teamName,
@ -17730,6 +18178,37 @@ export class TeamProvisioningService {
return false;
}
private needsBootstrapAcceptanceReconcile(
snapshot: PersistedTeamLaunchSnapshot | null,
bootstrapSnapshot: PersistedTeamLaunchSnapshot | null
): boolean {
if (!snapshot || !bootstrapSnapshot) {
return false;
}
for (const expected of this.getPersistedLaunchMemberNames(snapshot)) {
const current = snapshot.members[expected];
const bootstrapMember = bootstrapSnapshot.members[expected];
if (!current || !bootstrapMember) {
continue;
}
const bootstrapProvesSpawnAcceptance =
bootstrapMember.agentToolAccepted === true ||
typeof bootstrapMember.firstSpawnAcceptedAt === 'string';
if (!bootstrapProvesSpawnAcceptance) {
continue;
}
const currentProvesSpawnAcceptance =
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
if (!currentProvesSpawnAcceptance) {
return true;
}
if (isNeverSpawnedDuringLaunchReason(current.hardFailureReason)) {
return true;
}
}
return false;
}
private async reconcilePersistedLaunchState(teamName: string): Promise<{
snapshot: ReturnType<typeof createPersistedLaunchSnapshot> | null;
statuses: Record<string, MemberSpawnStatusEntry>;
@ -17745,8 +18224,15 @@ export class TeamProvisioningService {
const filteredRecoveredMixedSnapshot = recoveredMixedSnapshot
? this.filterRemovedMembersFromLaunchSnapshot(recoveredMixedSnapshot, metaMembers)
: null;
const filteredBootstrapSnapshot = bootstrapSnapshot
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
: null;
if (
filteredRecoveredMixedSnapshot &&
!this.needsBootstrapAcceptanceReconcile(
filteredRecoveredMixedSnapshot,
filteredBootstrapSnapshot
) &&
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(filteredRecoveredMixedSnapshot))
) {
return {
@ -17754,9 +18240,6 @@ export class TeamProvisioningService {
statuses: snapshotToMemberSpawnStatuses(filteredRecoveredMixedSnapshot),
};
}
const filteredBootstrapSnapshot = bootstrapSnapshot
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
: null;
const filteredPersisted =
filteredRecoveredMixedSnapshot ??
(persisted ? this.filterRemovedMembersFromLaunchSnapshot(persisted, metaMembers) : null);
@ -17805,6 +18288,7 @@ export class TeamProvisioningService {
if (
this.hasPrimaryOnlyLaneAwareLaunchMetadata(filteredPersisted) &&
!this.hasLeadInboxLaunchReconcileHeartbeat(filteredPersisted, leadInboxMessages) &&
!this.needsBootstrapAcceptanceReconcile(filteredPersisted, filteredBootstrapSnapshot) &&
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(filteredPersisted))
) {
return {

View file

@ -30,6 +30,7 @@ vi.mock('@features/tmux-installer/main', () => ({
listRuntimeProcessesForCurrentTmuxPlatform: vi.fn(async () => []),
listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()),
listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()),
sendKeysToTmuxPaneForCurrentPlatform: vi.fn(async () => undefined),
isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true),
}));
@ -152,6 +153,7 @@ import {
listRuntimeProcessesForCurrentTmuxPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
sendKeysToTmuxPaneForCurrentPlatform,
} from '@features/tmux-installer/main';
import pidusage from 'pidusage';
@ -444,6 +446,8 @@ describe('TeamProvisioningService', () => {
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValue(new Map());
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReset();
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue(new Map());
vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mockReset();
vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mockResolvedValue(undefined);
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-provisioning-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
@ -1685,6 +1689,178 @@ describe('TeamProvisioningService', () => {
);
});
it('restarts a tmux teammate directly in its shell-only pane after the runtime process disappeared', async () => {
const teamName = 'forge-labs-10';
const teamDir = path.join(tempTeamsBase, teamName);
const projectPath = path.join(tempClaudeRoot, 'forge-project');
fs.mkdirSync(teamDir, { recursive: true });
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify(
{
name: 'Forge Labs 10',
projectPath,
leadSessionId: 'lead-session-1',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
agentType: 'general-purpose',
tmuxPaneId: '%1',
backendType: 'tmux',
},
],
},
null,
2
),
'utf8'
);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue(
new Map([
[
'%1',
{
paneId: '%1',
panePid: 4242,
currentCommand: 'zsh',
currentPath: projectPath,
},
],
])
);
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
} as any);
const run = createMemberSpawnRun({
teamName,
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'error',
launchState: 'failed_to_start',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: 'Teammate was never spawned during launch.',
error: 'Teammate was never spawned during launch.',
agentToolAccepted: false,
firstSpawnAcceptedAt: undefined,
}),
],
]),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
run.detectedSessionId = 'lead-session-1';
run.request = { providerId: 'codex', skipPermissions: true };
const sendMessageToRun = vi.fn(async () => {});
(svc as any).sendMessageToRun = sendMessageToRun;
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { OPENAI_API_KEY: 'test-openai-key' },
authSource: 'openai_api_key',
providerArgs: [],
}));
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
name: 'Forge Labs 10',
projectPath,
leadSessionId: 'lead-session-1',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
},
],
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
agentType: 'general-purpose',
},
]),
};
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
{
name: 'bob',
agentId: 'bob@forge-labs-10',
backendType: 'tmux',
tmuxPaneId: '%1',
cwd: projectPath,
},
]);
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
(svc as any).aliveRunByTeam.set(teamName, run.runId);
(svc as any).runs.set(run.runId, run);
await svc.restartMember(teamName, 'bob');
expect(killTmuxPaneForCurrentPlatformSync).not.toHaveBeenCalled();
expect(sendMessageToRun).not.toHaveBeenCalled();
expect(sendKeysToTmuxPaneForCurrentPlatform).toHaveBeenCalledTimes(1);
const [paneId, command] = vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mock.calls[0] ?? [];
expect(paneId).toBe('%1');
expect(command).toContain("cd '");
expect(command).toContain(projectPath);
expect(command).toContain("'/mock/claude'");
expect(command).toContain("'--agent-id' 'bob@forge-labs-10'");
expect(command).toContain("'--team-name' 'forge-labs-10'");
expect(command).toContain("'--parent-session-id' 'lead-session-1'");
expect(command).toContain("'--mcp-config' '/mock/mcp-config.json'");
expect(command).toContain("'--model' 'gpt-5.4'");
expect(command).toContain("'--effort' 'high'");
expect(command).toContain('__CLAUDE_TEAMMATE_EXIT__');
expect(run.pendingMemberRestarts.has('bob')).toBe(true);
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
hardFailure: false,
});
const updatedConfig = JSON.parse(
fs.readFileSync(path.join(teamDir, 'config.json'), 'utf8')
) as { members: Array<Record<string, unknown>> };
expect(updatedConfig.members.find((member) => member.name === 'bob')).toMatchObject({
agentId: 'bob@forge-labs-10',
tmuxPaneId: '%1',
backendType: 'tmux',
providerId: 'codex',
model: 'gpt-5.4',
effort: 'high',
});
const inbox = JSON.parse(
fs.readFileSync(path.join(teamDir, 'inboxes', 'bob.json'), 'utf8')
) as Array<Record<string, unknown>>;
expect(inbox.at(-1)).toMatchObject({
from: 'team-lead',
to: 'bob',
source: 'system_notification',
leadSessionId: 'lead-session-1',
});
});
it('skips a failed teammate for the current launch without marking it alive', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({