Support respawn-pane in Claude Agent Teams tmux shim (#6771)

* Support respawn-pane in Claude Agent Teams tmux shim

Claude Code's pane backend spawns each teammate in two steps: it
`split-window`s a holding pane running `cat`, then `respawn-pane -k`s
that pane with the real teammate command. Orca's tmux dispatcher had no
`respawn-pane` case, so every teammate spawn failed with
`tmux: unsupported command: respawn-pane` and the real command was
dropped — agent teams were completely broken in Orca on current Claude
Code (2.1.x).

Honor respawn-pane by closing the placeholder terminal and re-splitting
from the pane's original origin/direction with the real command, keeping
the fake pane id stable so later send-keys/kill-pane/list-panes still
resolve. Panes now remember how they were split for faithful recreation.

Pure pane geometry/format helpers move to a sibling module to keep the
dispatcher within the 300-line cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* respawn-pane: create replacement before closing placeholder

Address review: closing the placeholder before the replacement split
succeeded could leave the fake pane id registered against a dead handle.
Split first; on cleanup failure, discard the new split and keep the
placeholder registered so send-keys/kill-pane/capture-pane still resolve.
Adds a regression test covering a failed respawn split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Adam Reed 2026-06-29 20:16:14 +00:00 committed by GitHub
parent 6e2b3911c1
commit c80dc43ed2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 221 additions and 65 deletions

View File

@ -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<string, string> {
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<string, string> {
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: ''
}
}

View File

@ -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()

View File

@ -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<string> {
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<string, string> {
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: ''
}
}
}

View File

@ -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 = {