diff --git a/src/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts index 0ae0c94e..08c4c859 100644 --- a/src/features/tmux-installer/main/composition/runtimeSupport.ts +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -40,6 +40,13 @@ export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise< return runtimeCommandExecutor.listRuntimeProcesses(); } +export async function sendKeysToTmuxPaneForCurrentPlatform( + paneId: string, + command: string +): Promise { + await runtimeCommandExecutor.sendKeysToPane(paneId, command); +} + export function killTmuxPaneForCurrentPlatformSync(paneId: string): void { runtimeCommandExecutor.killPaneSync(paneId); invalidateTmuxRuntimeStatusCache(); diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts index d18d99ea..9dbef51e 100644 --- a/src/features/tmux-installer/main/index.ts +++ b/src/features/tmux-installer/main/index.ts @@ -12,6 +12,7 @@ export { listRuntimeProcessesForCurrentTmuxPlatform, listTmuxPanePidsForCurrentPlatform, listTmuxPaneRuntimeInfoForCurrentPlatform, + sendKeysToTmuxPaneForCurrentPlatform, } from './composition/runtimeSupport'; export type { RuntimeProcessTableRow, diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts index 0500d252..d5e434a7 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -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 { + async execTmux(args: string[], timeout = 5_000, socketName?: string): Promise { + 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 { - 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> { @@ -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(); - 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 { + 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 { + const dirs = this.#getNativeTmuxSocketDirs(); + const names = new Set(); + 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(); + 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 { const platform = process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32' diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts index ded52232..bd9d3166 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -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 ); }); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8e087424..601d297e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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(); + 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; - 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 + >; + 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>; + agentId: string; + color: string; + prompt: string; + paneId: string; + cwd: string; + providerId: TeamProviderId; + joinedAt: number; + bootstrapExpectedAfter: string; + }): Promise { + 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[] }; + 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 = + 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 + >; + persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[]; + paneId: string; + }): Promise { + 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 { 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 | null; statuses: Record; @@ -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 { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index af8e478b..c41f9886 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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> }; + 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>; + 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({