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:
parent
6e2b3911c1
commit
c80dc43ed2
|
|
@ -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: ''
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue