diff --git a/src/main/runtime/claude-agent-teams-pane-layout.ts b/src/main/runtime/claude-agent-teams-pane-layout.ts new file mode 100644 index 000000000..8efd398de --- /dev/null +++ b/src/main/runtime/claude-agent-teams-pane-layout.ts @@ -0,0 +1,64 @@ +import type { AgentTeam, TeamPane } from './claude-agent-teams-types' + +export type SplitTarget = { pane: TeamPane; direction: 'horizontal' | 'vertical' } + +export function paneEnv(team: AgentTeam, fakePaneId: string): Record { + return { + ...team.baseEnv, + TMUX_PANE: fakePaneId, + ORCA_AGENT_TEAMS_LEADER_PANE: team.leaderPane + } +} + +export function resolveSplitTarget( + team: AgentTeam, + targetPane: TeamPane, + horizontal: boolean +): SplitTarget { + if (horizontal && team.mainVertical?.lastColumnPane) { + return { + pane: team.panes.get(team.mainVertical.lastColumnPane) ?? targetPane, + direction: 'horizontal' + } + } + // Why: tmux `split-window -h` means left/right panes; Orca names that + // layout by the vertical divider it creates. + return { pane: targetPane, direction: horizontal ? 'vertical' : 'horizontal' } +} + +export function updateMainVerticalAfterSplit( + team: AgentTeam, + fakePaneId: string, + splitTarget: SplitTarget +): void { + if (team.mainVertical) { + team.mainVertical.lastColumnPane = fakePaneId + } else if ( + splitTarget.direction === 'vertical' && + splitTarget.pane.fakePaneId === team.leaderPane + ) { + team.mainVertical = { mainPane: team.leaderPane, lastColumnPane: fakePaneId } + } +} + +export function formatContext(team: AgentTeam, pane: TeamPane): Record { + return { + session_name: team.sessionName, + session_id: '$0', + window_id: '@0', + window_index: team.windowIndex, + window_name: 'agent-teams', + window_active: '1', + window_flags: '*', + pane_id: pane.fakePaneId, + pane_index: String(pane.index), + pane_active: pane.fakePaneId === team.leaderPane ? '1' : '0', + pane_title: '', + pane_width: '', + pane_height: '', + pane_left: '', + pane_top: '', + window_width: '', + window_height: '' + } +} diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts index 56ba8567a..643b58761 100644 --- a/src/main/runtime/claude-agent-teams-service.test.ts +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -141,6 +141,95 @@ describe('ClaudeAgentTeamsService', () => { ]) }) + it('relaunches a teammate via respawn-pane after a cat holding split', async () => { + const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader() + const request = (argv: string[], envPane = leaderPane) => + service.handleTmuxCompat({ teamId, token, envPane, argv }, api) + + // Claude splits a holding pane running `cat`, then respawns it with the + // real teammate command (the failure mode before respawn-pane was supported). + await expect( + request([ + 'split-window', + '-d', + '-t', + leaderPane, + '-h', + '-l', + '70%', + '-P', + '-F', + '#{pane_id}', + '--', + 'cat' + ]) + ).resolves.toMatchObject({ stdout: '%2\n', exitCode: 0 }) + + await request(['set-option', '-p', '-t', '%2', 'remain-on-exit', 'failed']) + + const teammateCommand = + 'cd /repo && env CLAUDECODE=1 claude --agent-id a --teammate-mode auto' + await expect( + request(['respawn-pane', '-k', '-t', '%2', '--', teammateCommand]) + ).resolves.toMatchObject({ stdout: '', exitCode: 0 }) + + // the placeholder terminal is closed and the pane is recreated, from the same + // origin/direction, with the real teammate command. + expect(api.closeTerminal).toHaveBeenCalledWith('teammate-1') + expect(splitCalls).toEqual([ + { handle: 'leader-handle', direction: 'vertical', command: 'cat', envPane: '%2' }, + { handle: 'leader-handle', direction: 'vertical', command: teammateCommand, envPane: '%2' } + ]) + + // the fake pane id is preserved and now backed by the relaunched terminal. + await expect( + request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}']) + ).resolves.toMatchObject({ stdout: '%1\n%2\n' }) + + await request(['kill-pane', '-t', '%2']) + expect(api.closeTerminal).toHaveBeenLastCalledWith('teammate-2') + }) + + it('keeps the placeholder handle when the respawn split fails', async () => { + const { service, teamId, token, leaderPane, api } = createServiceWithLeader() + const request = (argv: string[], envPane = leaderPane) => + service.handleTmuxCompat({ teamId, token, envPane, argv }, api) + + await request([ + 'split-window', + '-d', + '-t', + leaderPane, + '-h', + '-P', + '-F', + '#{pane_id}', + '--', + 'cat' + ]) + + vi.mocked(api.splitTerminal).mockRejectedValueOnce(new Error('no space for new pane')) + await expect( + request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a']) + ).resolves.toMatchObject({ ok: false, exitCode: 1 }) + + // the placeholder terminal is left intact and the fake pane id still resolves. + expect(api.closeTerminal).not.toHaveBeenCalled() + await request(['kill-pane', '-t', '%2']) + expect(api.closeTerminal).toHaveBeenCalledWith('teammate-1') + }) + + it('refuses to respawn the leader pane', async () => { + const { service, teamId, token, leaderPane, api } = createServiceWithLeader() + + await expect( + service.handleTmuxCompat( + { teamId, token, envPane: leaderPane, argv: ['respawn-pane', '-k', '-t', leaderPane, '--', 'cat'] }, + api + ) + ).resolves.toMatchObject({ ok: false, exitCode: 1, stderr: 'tmux: refusing to respawn leader pane\n' }) + }) + it('rejects stale or unauthorized shim calls', async () => { const { service, teamId, leaderPane, api } = createServiceWithLeader() diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts index e73025b8e..c76975582 100644 --- a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -4,6 +4,12 @@ import { tmuxSendKeysText, tmuxValue } from '../../shared/claude-agent-teams-tmux-compat' +import { + formatContext, + paneEnv, + resolveSplitTarget, + updateMainVerticalAfterSplit +} from './claude-agent-teams-pane-layout' import type { AgentTeam, AgentTeamsTerminalApi, TeamPane } from './claude-agent-teams-types' type ResolvedTarget = { type: 'pane'; pane: TeamPane } | { type: 'window' } @@ -31,6 +37,9 @@ export class ClaudeAgentTeamsTmuxDispatcher { case 'split-window': case 'splitw': return await this.splitWindow(team, args, envPane, api) + case 'respawn-pane': + case 'respawnp': + return await this.respawnPane(team, args, envPane, api) case 'select-layout': return this.selectLayout(team, args, envPane) case 'resize-pane': @@ -86,7 +95,7 @@ export class ClaudeAgentTeamsTmuxDispatcher { const pane = target.type === 'window' ? this.resolvePane(team, envPane) : target.pane const format = parsed.positional.length > 0 ? parsed.positional.join(' ') : tmuxValue(parsed, '-F') - return `${renderTmuxFormat(format, this.formatContext(team, pane), '')}\n` + return `${renderTmuxFormat(format, formatContext(team, pane), '')}\n` } private async splitWindow( @@ -103,31 +112,73 @@ export class ClaudeAgentTeamsTmuxDispatcher { const targetPane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) const fakePaneId = `%${team.nextPaneNumber}` team.nextPaneNumber += 1 - const splitTarget = this.resolveSplitTarget(team, targetPane, parsed.flags.has('-h')) - const env = { - ...team.baseEnv, - TMUX_PANE: fakePaneId, - ORCA_AGENT_TEAMS_LEADER_PANE: team.leaderPane - } + const splitTarget = resolveSplitTarget(team, targetPane, parsed.flags.has('-h')) const split = await api.splitTerminal(splitTarget.pane.handle, { direction: splitTarget.direction, command: parsed.positional.join(' ') || undefined, - env, + env: paneEnv(team, fakePaneId), envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'], activate: false }) const pane: TeamPane = { fakePaneId, handle: split.handle, - index: team.paneOrder.length + index: team.paneOrder.length, + splitFromPane: splitTarget.pane.fakePaneId, + splitDirection: splitTarget.direction } team.panes.set(fakePaneId, pane) team.paneOrder.push(fakePaneId) - this.updateMainVerticalAfterSplit(team, fakePaneId, splitTarget) + updateMainVerticalAfterSplit(team, fakePaneId, splitTarget) if (!parsed.flags.has('-P')) { return '' } - return `${renderTmuxFormat(tmuxValue(parsed, '-F'), this.formatContext(team, pane), fakePaneId)}\n` + return `${renderTmuxFormat(tmuxValue(parsed, '-F'), formatContext(team, pane), fakePaneId)}\n` + } + + // Why: Claude Code's pane backend creates a teammate pane in two steps — it + // splits a holding pane running `cat`, then `respawn-pane -k`s it with the + // real teammate command. Orca panes are PTYs that cannot swap their program in + // place, so we honor respawn by closing the placeholder terminal and + // re-splitting from the same origin with the real command, keeping the fake + // pane id stable so later send-keys/kill-pane/list-panes still resolve. + private async respawnPane( + team: AgentTeam, + args: string[], + envPane: string, + api: AgentTeamsTerminalApi + ): Promise { + const parsed = parseTmuxArgs(args, ['-c', '-e', '-t'], ['-k']) + const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) + if (pane.fakePaneId === team.leaderPane) { + throw new Error('refusing to respawn leader pane') + } + const command = parsed.positional.join(' ') + if (!command) { + return '' + } + const origin = + (pane.splitFromPane ? team.panes.get(pane.splitFromPane) : undefined) ?? + team.panes.get(team.leaderPane)! + // Why: create the replacement before destroying the placeholder so a failed + // split leaves the fake pane id pointing at a still-live terminal; on cleanup + // failure, discard the new split and keep the placeholder registered. + const previousHandle = pane.handle + const split = await api.splitTerminal(origin.handle, { + direction: pane.splitDirection ?? 'horizontal', + command, + env: paneEnv(team, pane.fakePaneId), + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'], + activate: false + }) + try { + await api.closeTerminal(previousHandle) + } catch (error) { + await api.closeTerminal(split.handle).catch(() => {}) + throw error + } + pane.handle = split.handle + return '' } private selectLayout(team: AgentTeam, args: string[], envPane: string): string { @@ -156,7 +207,7 @@ export class ClaudeAgentTeamsTmuxDispatcher { const pane = team.panes.get(paneId)! return renderTmuxFormat( tmuxValue(parsed, '-F'), - this.formatContext(team, pane), + formatContext(team, pane), pane.fakePaneId ) }) @@ -242,37 +293,6 @@ export class ClaudeAgentTeamsTmuxDispatcher { return '' } - private updateMainVerticalAfterSplit( - team: AgentTeam, - fakePaneId: string, - splitTarget: { pane: TeamPane; direction: 'horizontal' | 'vertical' } - ): void { - if (team.mainVertical) { - team.mainVertical.lastColumnPane = fakePaneId - } else if ( - splitTarget.direction === 'vertical' && - splitTarget.pane.fakePaneId === team.leaderPane - ) { - team.mainVertical = { mainPane: team.leaderPane, lastColumnPane: fakePaneId } - } - } - - private resolveSplitTarget( - team: AgentTeam, - targetPane: TeamPane, - horizontal: boolean - ): { pane: TeamPane; direction: 'horizontal' | 'vertical' } { - if (horizontal && team.mainVertical?.lastColumnPane) { - return { - pane: team.panes.get(team.mainVertical.lastColumnPane) ?? targetPane, - direction: 'horizontal' - } - } - // Why: tmux `split-window -h` means left/right panes; Orca names that - // layout by the vertical divider it creates. - return { pane: targetPane, direction: horizontal ? 'vertical' : 'horizontal' } - } - private resolvePaneOrWindow(team: AgentTeam, target: string): ResolvedTarget { if (target.includes(':') || target === team.sessionName || target.startsWith('@')) { return { type: 'window' } @@ -287,26 +307,4 @@ export class ClaudeAgentTeamsTmuxDispatcher { } return pane } - - private formatContext(team: AgentTeam, pane: TeamPane): Record { - return { - session_name: team.sessionName, - session_id: '$0', - window_id: '@0', - window_index: team.windowIndex, - window_name: 'agent-teams', - window_active: '1', - window_flags: '*', - pane_id: pane.fakePaneId, - pane_index: String(pane.index), - pane_active: pane.fakePaneId === team.leaderPane ? '1' : '0', - pane_title: '', - pane_width: '', - pane_height: '', - pane_left: '', - pane_top: '', - window_width: '', - window_height: '' - } - } } diff --git a/src/main/runtime/claude-agent-teams-types.ts b/src/main/runtime/claude-agent-teams-types.ts index 6f98b25a8..981c9eee0 100644 --- a/src/main/runtime/claude-agent-teams-types.ts +++ b/src/main/runtime/claude-agent-teams-types.ts @@ -54,6 +54,11 @@ export type TeamPane = { fakePaneId: string handle: string index: number + // Why: Claude Code splits a holding pane (`-- cat`) then `respawn-pane`s it + // with the real teammate command. We remember how the pane was first split so + // respawn can recreate it in the same slot while preserving its fake pane id. + splitFromPane?: string + splitDirection?: 'horizontal' | 'vertical' } export type AgentTeam = {