Add Claude Agent Teams native pane launcher (#4892)

* Add Claude Agent Teams native pane launcher

Co-authored-by: Orca <help@stably.ai>

* Fix Agent Teams CI coverage checks

Co-authored-by: Orca <help@stably.ai>

* Fix Claude Agent Teams split direction mapping

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-08 16:11:32 -04:00
committed by GitHub
co-authored by Orca
parent 9b819ce43f
commit 7ea359bd60
43 changed files with 1953 additions and 30 deletions
@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest'
import { ClaudeAgentTeamsService, type AgentTeamsTerminalApi } from './claude-agent-teams-service'
function createServiceWithLeader(): {
service: ClaudeAgentTeamsService
teamId: string
token: string
leaderPane: string
api: AgentTeamsTerminalApi
splitCalls: { handle: string; direction?: string; command?: string; envPane?: string }[]
} {
const service = new ClaudeAgentTeamsService()
const launch = service.createLaunchEnv({
leaderHandle: 'leader-handle',
baseEnv: { PATH: '/usr/bin' },
shimDir: '/tmp/orca-shim',
shimBin: '/usr/bin/orca'
})
expect(launch.env.ORCA_AGENT_TEAMS_SHIM_DIR).toBe('/tmp/orca-shim')
const splitCalls: { handle: string; direction?: string; command?: string; envPane?: string }[] =
[]
let splitCount = 0
const api: AgentTeamsTerminalApi = {
splitTerminal: vi.fn(async (handle, opts) => {
splitCount += 1
splitCalls.push({
handle,
direction: opts.direction,
command: opts.command,
envPane: opts.env?.TMUX_PANE
})
return { handle: `teammate-${splitCount}`, tabId: 'tab-1', paneRuntimeId: -1 }
}),
readTerminal: vi.fn(async (handle) => ({
handle,
status: 'running' as const,
tail: ['line one', 'line two'],
truncated: false,
nextCursor: null
})),
sendTerminal: vi.fn(async (handle, action) => ({
handle,
accepted: Boolean(action.text),
bytesWritten: action.text?.length ?? 0
})),
focusTerminal: vi.fn(async (handle) => ({ handle, tabId: 'tab-1', worktreeId: 'wt-1' })),
closeTerminal: vi.fn(async (handle) => ({ handle, tabId: 'tab-1', ptyKilled: true })),
showTerminal: vi.fn(async (handle) => ({
handle,
worktreeId: 'wt-1',
worktreePath: '/tmp/wt',
branch: 'main',
tabId: 'tab-1',
leafId: 'leaf-1',
title: null,
connected: true,
writable: true,
lastOutputAt: null,
preview: '',
paneRuntimeId: -1,
ptyId: 'pty-1',
rendererGraphEpoch: 1
}))
}
return {
service,
teamId: launch.teamId,
token: launch.token,
leaderPane: launch.leaderPane,
api,
splitCalls
}
}
describe('ClaudeAgentTeamsService', () => {
it('supports Claude core tmux teammate sequence with native splits', async () => {
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
const request = (argv: string[]) =>
service.handleTmuxCompat({ teamId, token, envPane: leaderPane, argv }, api)
await expect(
request(['display-message', '-t', leaderPane, '-p', '#{session_name}:#{window_index}'])
).resolves.toMatchObject({ stdout: 'orca:0\n', exitCode: 0 })
await expect(
request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}'])
).resolves.toMatchObject({ stdout: '%2\n', exitCode: 0 })
await request(['select-layout', '-t', 'orca:0', 'main-vertical'])
await request(['resize-pane', '-t', leaderPane, '-x', '30%'])
await expect(
request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}'])
).resolves.toMatchObject({
stdout: '%1\n%2\n'
})
expect(splitCalls).toEqual([
{ handle: 'leader-handle', direction: 'vertical', command: undefined, envPane: '%2' }
])
})
it('puts the first teammate on the right, then stacks repeated main-vertical teammates downward', async () => {
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
const request = (argv: string[]) =>
service.handleTmuxCompat({ teamId, token, envPane: leaderPane, argv }, api)
await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}'])
await request(['select-layout', '-t', 'orca:0', 'main-vertical'])
await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}'])
await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}'])
expect(splitCalls.map((call) => [call.handle, call.direction, call.envPane])).toEqual([
['leader-handle', 'vertical', '%2'],
['teammate-1', 'horizontal', '%3'],
['teammate-2', 'horizontal', '%4']
])
})
it('does not recycle fake pane ids after a teammate closes', async () => {
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
const request = (argv: string[], envPane = leaderPane) =>
service.handleTmuxCompat({ teamId, token, envPane, argv }, api)
await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}'])
await request(['select-layout', '-t', 'orca:0', 'main-vertical'])
await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}'])
await request(['kill-pane', '-t', '%3'])
await expect(
request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}'])
).resolves.toMatchObject({ stdout: '%4\n', exitCode: 0 })
await expect(
request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}'])
).resolves.toMatchObject({
stdout: '%1\n%2\n%4\n'
})
expect(splitCalls.map((call) => [call.handle, call.direction, call.envPane])).toEqual([
['leader-handle', 'vertical', '%2'],
['teammate-1', 'horizontal', '%3'],
['teammate-1', 'horizontal', '%4']
])
})
it('rejects stale or unauthorized shim calls', async () => {
const { service, teamId, leaderPane, api } = createServiceWithLeader()
await expect(
service.handleTmuxCompat(
{ teamId, token: 'wrong', envPane: leaderPane, argv: ['list-panes'] },
api
)
).resolves.toMatchObject({ ok: false, exitCode: 1 })
})
})
@@ -0,0 +1,109 @@
import { randomBytes, randomUUID } from 'crypto'
import { splitTmuxCommand } from '../../shared/claude-agent-teams-tmux-compat'
import { ClaudeAgentTeamsTmuxDispatcher } from './claude-agent-teams-tmux-dispatcher'
import type {
AgentTeam,
AgentTeamsLaunchEnv,
AgentTeamsTerminalApi,
AgentTeamsTmuxCompatRequest,
AgentTeamsTmuxCompatResponse,
TeamPane
} from './claude-agent-teams-types'
export type {
AgentTeamsLaunchEnv,
AgentTeamsTerminalApi,
AgentTeamsTmuxCompatRequest,
AgentTeamsTmuxCompatResponse
} from './claude-agent-teams-types'
export class ClaudeAgentTeamsService {
private readonly teams = new Map<string, AgentTeam>()
private readonly dispatcher = new ClaudeAgentTeamsTmuxDispatcher()
createLaunchEnv(args: {
leaderHandle: string
baseEnv: Record<string, string | undefined>
shimDir: string
shimBin: string
}): AgentTeamsLaunchEnv {
const teamId = `team-${randomUUID()}`
const token = randomBytes(32).toString('base64url')
const leaderPane = '%1'
const pathValue = [args.shimDir, args.baseEnv.PATH]
.filter(Boolean)
.join(process.platform === 'win32' ? ';' : ':')
const tmuxValue = `/tmp/orca-claude-agent-teams/${teamId},0,1`
const env: Record<string, string> = {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
PATH: pathValue,
TMUX: tmuxValue,
TMUX_PANE: leaderPane,
TERM: 'screen-256color',
COLORTERM: args.baseEnv.COLORTERM || 'truecolor',
ORCA_AGENT_TEAMS_TEAM_ID: teamId,
ORCA_AGENT_TEAMS_TOKEN: token,
ORCA_AGENT_TEAMS_LEADER_PANE: leaderPane,
ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir,
ORCA_AGENT_TEAMS_SHIM_BIN: args.shimBin
}
if (args.baseEnv.ORCA_PAIRING_CODE) {
env.ORCA_PAIRING_CODE = args.baseEnv.ORCA_PAIRING_CODE
}
if (args.baseEnv.ORCA_ENVIRONMENT) {
env.ORCA_ENVIRONMENT = args.baseEnv.ORCA_ENVIRONMENT
}
const leader: TeamPane = { fakePaneId: leaderPane, handle: args.leaderHandle, index: 0 }
this.teams.set(teamId, {
teamId,
token,
leaderPane,
leaderHandle: args.leaderHandle,
sessionName: 'orca',
windowIndex: '0',
tmuxValue,
baseEnv: env,
panes: new Map([[leaderPane, leader]]),
paneOrder: [leaderPane],
nextPaneNumber: 2,
mainVertical: null,
previouslyFocusedPane: null
})
return { teamId, token, leaderPane, env }
}
removeTeamForLeaderHandle(handle: string): void {
for (const [teamId, team] of this.teams) {
if (team.leaderHandle === handle) {
this.teams.delete(teamId)
}
}
}
async handleTmuxCompat(
request: AgentTeamsTmuxCompatRequest,
api: AgentTeamsTerminalApi
): Promise<AgentTeamsTmuxCompatResponse> {
try {
const team = this.resolveTeam(request)
const { command, args } = splitTmuxCommand(request.argv)
const stdout = await this.dispatcher.dispatch(team, command, args, request.envPane, api)
return { ok: true, stdout, stderr: '', exitCode: 0 }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { ok: false, stdout: '', stderr: `tmux: ${message}\n`, exitCode: 1 }
}
}
private resolveTeam(request: AgentTeamsTmuxCompatRequest): AgentTeam {
const team = this.teams.get(request.teamId)
if (!team || team.token !== request.token) {
throw new Error('stale or unauthorized agent team')
}
if (!team.panes.has(request.envPane)) {
throw new Error(`unknown pane: ${request.envPane}`)
}
return team
}
}
@@ -0,0 +1,82 @@
import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import {
buildClaudeAgentTeamsLaunchPlan,
ensureClaudeAgentTeamsShimDir,
resolveClaudeAgentTeamsShimBin
} from './claude-agent-teams-shim-env'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })))
roots.length = 0
})
describe('claude agent teams shim env', () => {
it('writes a private tmux shim that calls the Orca shim command', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-shim-'))
roots.push(root)
await ensureClaudeAgentTeamsShimDir(root)
await expect(readFile(join(root, 'tmux'), 'utf8')).resolves.toContain('agent-teams-tmux "$@"')
})
it('builds native shim env only for direct Claude commands', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-'))
roots.push(root)
const cliName = process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev'
const cliPath = join(root, cliName)
await writeFile(cliPath, '#!/usr/bin/env sh\n', 'utf8')
if (process.platform !== 'win32') {
await chmod(cliPath, 0o755)
}
let capturedShimBin = ''
const plan = await buildClaudeAgentTeamsLaunchPlan({
command: "claude 'hello'",
mode: 'native-panes-shim',
baseEnv: { PATH: root },
createTeamEnv: (shimDir, shimBin) => {
capturedShimBin = shimBin
return {
PATH: `${shimDir}:/usr/bin`,
TMUX: '/tmp/orca/fake,0,0',
TMUX_PANE: '%1'
}
}
})
expect(plan).toMatchObject({
command: "claude --teammate-mode auto 'hello'",
env: expect.objectContaining({ TMUX_PANE: '%1' }),
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
expect(capturedShimBin).toBe(cliPath)
await expect(
buildClaudeAgentTeamsLaunchPlan({
command: "echo ok; claude 'hello'",
mode: 'native-panes-shim',
baseEnv: {},
createTeamEnv: () => ({})
})
).resolves.toBeNull()
})
it('resolves the dev CLI wrapper for the tmux callback binary', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-'))
roots.push(root)
const cliName = process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev'
const cliPath = join(root, cliName)
await writeFile(cliPath, '#!/usr/bin/env sh\n', 'utf8')
if (process.platform !== 'win32') {
await chmod(cliPath, 0o755)
}
expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath)
})
})
@@ -0,0 +1,169 @@
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
import { accessSync, constants, existsSync } from 'fs'
import { homedir } from 'os'
import { delimiter, dirname, join } from 'path'
import {
addClaudeTeammateModeAuto,
addClaudeTeammateModeInProcess,
isDirectClaudeCommand,
type ClaudeAgentTeamsMode
} from '../../shared/claude-agent-teams-tmux-compat'
export type ClaudeAgentTeamsLaunchPlan = {
command: string
env: Record<string, string>
envToDelete?: string[]
}
export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): Promise<string> {
await mkdir(root, { recursive: true })
await writeIfChanged(join(root, 'tmux'), unixShimScript())
if (process.platform === 'win32') {
await writeIfChanged(join(root, 'tmux.cmd'), windowsShimScript())
}
return root
}
export async function buildClaudeAgentTeamsLaunchPlan(args: {
command: string | undefined
mode: ClaudeAgentTeamsMode | undefined
baseEnv: Record<string, string | undefined>
createTeamEnv: (shimDir: string, shimBin: string) => Record<string, string>
}): Promise<ClaudeAgentTeamsLaunchPlan | null> {
const mode = args.mode ?? 'off'
if (!args.command || mode === 'off' || !isDirectClaudeCommand(args.command)) {
return null
}
if (mode === 'in-process' || process.platform === 'win32') {
return {
command: addClaudeTeammateModeInProcess(args.command),
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }
}
}
const shimDir = await ensureClaudeAgentTeamsShimDir()
const shimBin = resolveClaudeAgentTeamsShimBin(args.baseEnv)
const env = args.createTeamEnv(shimDir, shimBin)
return {
command: addClaudeTeammateModeAuto(args.command),
env,
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
}
}
export function resolveClaudeAgentTeamsShimBin(
env: Record<string, string | undefined> = process.env
): string {
if (env.ORCA_AGENT_TEAMS_SHIM_BIN) {
return env.ORCA_AGENT_TEAMS_SHIM_BIN
}
const bundled = bundledLauncherPath()
if (bundled && isExecutableFile(bundled)) {
return bundled
}
return (
findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', env.PATH) ??
findExecutableOnPath(platformCliCommandName(), env.PATH) ??
platformCliCommandName()
)
}
function defaultShimRoot(): string {
return join(homedir(), '.orca', 'claude-agent-teams-bin')
}
function bundledLauncherPath(): string | null {
if (!process.resourcesPath) {
return null
}
if (process.platform === 'darwin') {
return join(process.resourcesPath, 'bin', 'orca')
}
if (process.platform === 'linux') {
return join(process.resourcesPath, 'bin', 'orca-ide')
}
if (process.platform === 'win32') {
return join(process.resourcesPath, 'bin', 'orca.cmd')
}
return null
}
function platformCliCommandName(): string {
if (process.platform === 'linux') {
return 'orca-ide'
}
if (process.platform === 'win32') {
return 'orca.cmd'
}
return 'orca'
}
function findExecutableOnPath(command: string, pathValue: string | undefined): string | null {
for (const directory of pathValue?.split(delimiter) ?? []) {
if (!directory) {
continue
}
const candidate = join(directory, command)
if (isExecutableFile(candidate)) {
return candidate
}
}
return null
}
function isExecutableFile(candidate: string): boolean {
try {
if (!existsSync(candidate)) {
return false
}
accessSync(candidate, process.platform === 'win32' ? constants.F_OK : constants.X_OK)
return true
} catch {
return false
}
}
function unixShimScript(): string {
return [
'#!/usr/bin/env sh',
'set -eu',
'exec "${ORCA_AGENT_TEAMS_SHIM_BIN:-orca}" agent-teams-tmux "$@"',
''
].join('\n')
}
function windowsShimScript(): string {
return [
'@echo off',
'setlocal',
'if "%ORCA_AGENT_TEAMS_SHIM_BIN%"=="" (',
' set "ORCA_AGENT_TEAMS_SHIM_BIN=orca"',
')',
'"%ORCA_AGENT_TEAMS_SHIM_BIN%" agent-teams-tmux %*',
''
].join('\r\n')
}
async function writeIfChanged(path: string, content: string): Promise<void> {
try {
if ((await readFile(path, 'utf8')) === content) {
return
}
} catch {
// rewrite below
}
await mkdir(dirname(path), { recursive: true })
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
let renamed = false
try {
await writeFile(tmp, content, 'utf8')
if (process.platform !== 'win32') {
await chmod(tmp, 0o755)
}
await rename(tmp, path)
renamed = true
} finally {
if (!renamed) {
await rm(tmp, { force: true })
}
}
}
@@ -0,0 +1,312 @@
import {
parseTmuxArgs,
renderTmuxFormat,
tmuxSendKeysText,
tmuxValue
} from '../../shared/claude-agent-teams-tmux-compat'
import type { AgentTeam, AgentTeamsTerminalApi, TeamPane } from './claude-agent-teams-types'
type ResolvedTarget = { type: 'pane'; pane: TeamPane } | { type: 'window' }
export class ClaudeAgentTeamsTmuxDispatcher {
async dispatch(
team: AgentTeam,
command: string,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
switch (command) {
case '-V':
case '-v':
return 'tmux 3.4\n'
case 'show-options':
case 'show-option':
case 'show':
return this.showOptions(args)
case 'display-message':
case 'display':
case 'displayp':
return this.displayMessage(team, args, envPane)
case 'split-window':
case 'splitw':
return await this.splitWindow(team, args, envPane, api)
case 'select-layout':
return this.selectLayout(team, args, envPane)
case 'resize-pane':
case 'resizep':
return ''
case 'list-panes':
case 'lsp':
return this.listPanes(team, args, envPane)
case 'send-keys':
case 'send':
return await this.sendKeys(team, args, envPane, api)
case 'capture-pane':
case 'capturep':
return await this.capturePane(team, args, envPane, api)
case 'select-pane':
case 'selectp':
return await this.selectPane(team, args, envPane, api)
case 'kill-pane':
case 'killp':
return await this.killPane(team, args, envPane, api)
case 'last-pane':
return await this.lastPane(team, args, api)
case 'set-option':
case 'set':
case 'set-window-option':
case 'setw':
case 'set-hook':
case 'refresh-client':
case 'attach-session':
case 'detach-client':
case 'source-file':
case 'wait-for':
case 'has-session':
case 'has':
return ''
default:
throw new Error(`unsupported command: ${command}`)
}
}
private showOptions(args: string[]): string {
const parsed = parseTmuxArgs(args, ['-t'], ['-g', '-q', '-s', '-v', '-w'])
const optionName = parsed.positional.at(-1) ?? ''
if (optionName !== 'extended-keys') {
throw new Error(`unsupported option: ${optionName}`)
}
return parsed.flags.has('-v') ? 'on\n' : 'extended-keys on\n'
}
private displayMessage(team: AgentTeam, args: string[], envPane: string): string {
const parsed = parseTmuxArgs(args, ['-F', '-t'], ['-p'])
const target = this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane)
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`
}
private async splitWindow(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(
args,
['-c', '-F', '-l', '-t'],
['-P', '-b', '-d', '-f', '-h', '-v']
)
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 split = await api.splitTerminal(splitTarget.pane.handle, {
direction: splitTarget.direction,
command: parsed.positional.join(' ') || undefined,
env,
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'],
activate: false
})
const pane: TeamPane = {
fakePaneId,
handle: split.handle,
index: team.paneOrder.length
}
team.panes.set(fakePaneId, pane)
team.paneOrder.push(fakePaneId)
this.updateMainVerticalAfterSplit(team, fakePaneId, splitTarget)
if (!parsed.flags.has('-P')) {
return ''
}
return `${renderTmuxFormat(tmuxValue(parsed, '-F'), this.formatContext(team, pane), fakePaneId)}\n`
}
private selectLayout(team: AgentTeam, args: string[], envPane: string): string {
const parsed = parseTmuxArgs(args, ['-t'], [])
const layout = parsed.positional[0] ?? ''
if (layout === 'main-vertical') {
const target = this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane)
const targetPane = target.type === 'pane' ? target.pane : null
team.mainVertical = {
mainPane: team.leaderPane,
lastColumnPane:
team.mainVertical?.lastColumnPane ??
(targetPane && targetPane.fakePaneId !== team.leaderPane ? targetPane.fakePaneId : null)
}
} else if (layout) {
team.mainVertical = null
}
return ''
}
private listPanes(team: AgentTeam, args: string[], envPane: string): string {
const parsed = parseTmuxArgs(args, ['-F', '-t'], [])
this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane)
return team.paneOrder
.map((paneId) => {
const pane = team.panes.get(paneId)!
return renderTmuxFormat(
tmuxValue(parsed, '-F'),
this.formatContext(team, pane),
pane.fakePaneId
)
})
.join('\n')
.concat('\n')
}
private async sendKeys(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(args, ['-t'], ['-l'])
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l'))
if (text) {
await api.sendTerminal(pane.handle, { text })
}
return ''
}
private async capturePane(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(args, ['-E', '-S', '-t'], ['-J', '-N', '-p'])
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
const read = await api.readTerminal(pane.handle, { limit: 1000 })
const text = read.tail.join('\n')
return parsed.flags.has('-p') ? `${text}\n` : ''
}
private async selectPane(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(args, ['-P', '-T', '-t'], [])
if (tmuxValue(parsed, '-P') || tmuxValue(parsed, '-T')) {
return ''
}
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
team.previouslyFocusedPane = envPane
await api.focusTerminal(pane.handle)
return ''
}
private async killPane(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(args, ['-t'], [])
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
if (pane.fakePaneId === team.leaderPane) {
throw new Error('refusing to kill leader pane')
}
await api.closeTerminal(pane.handle)
team.panes.delete(pane.fakePaneId)
team.paneOrder = team.paneOrder.filter((id) => id !== pane.fakePaneId)
if (team.mainVertical?.lastColumnPane === pane.fakePaneId) {
team.mainVertical.lastColumnPane =
[...team.paneOrder].reverse().find((id) => id !== team.leaderPane) ?? null
}
return ''
}
private async lastPane(
team: AgentTeam,
args: string[],
api: AgentTeamsTerminalApi
): Promise<string> {
parseTmuxArgs(args, ['-t'], [])
const pane = team.previouslyFocusedPane ? team.panes.get(team.previouslyFocusedPane) : null
if (pane) {
await api.focusTerminal(pane.handle)
}
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' }
}
return { type: 'pane', pane: this.resolvePane(team, target) }
}
private resolvePane(team: AgentTeam, target: string): TeamPane {
const pane = team.panes.get(target)
if (!pane) {
throw new Error(`unknown pane: ${target}`)
}
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: ''
}
}
}
@@ -0,0 +1,76 @@
import type {
RuntimeTerminalClose,
RuntimeTerminalFocus,
RuntimeTerminalRead,
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit
} from '../../shared/runtime-types'
export type AgentTeamsTmuxCompatRequest = {
teamId: string
token: string
envPane: string
cwd?: string
argv: string[]
}
export type AgentTeamsTmuxCompatResponse = {
ok: boolean
stdout: string
stderr: string
exitCode: number
}
export type AgentTeamsLaunchEnv = {
teamId: string
token: string
leaderPane: string
env: Record<string, string>
}
export type AgentTeamsTerminalApi = {
splitTerminal(
handle: string,
opts: {
direction?: 'horizontal' | 'vertical'
command?: string
env?: Record<string, string>
envToDelete?: string[]
activate?: boolean
}
): Promise<RuntimeTerminalSplit>
readTerminal(handle: string, opts?: { limit?: number }): Promise<RuntimeTerminalRead>
sendTerminal(
handle: string,
action: { text?: string; enter?: boolean; interrupt?: boolean }
): Promise<RuntimeTerminalSend>
focusTerminal(handle: string): Promise<RuntimeTerminalFocus>
closeTerminal(handle: string): Promise<RuntimeTerminalClose>
showTerminal(handle: string): Promise<RuntimeTerminalShow>
}
export type TeamPane = {
fakePaneId: string
handle: string
index: number
}
export type AgentTeam = {
teamId: string
token: string
leaderPane: string
leaderHandle: string
sessionName: string
windowIndex: string
tmuxValue: string
baseEnv: Record<string, string>
panes: Map<string, TeamPane>
paneOrder: string[]
nextPaneNumber: number
mainVertical: {
mainPane: string
lastColumnPane: string | null
} | null
previouslyFocusedPane: string | null
}
+53
View File
@@ -3852,6 +3852,59 @@ describe('OrcaRuntimeService', () => {
})
})
it('enables Claude Agent Teams only for direct Claude launches when configured in-process', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
claudeAgentTeamsMode: 'in-process' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: "claude 'hello'"
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: "echo ok; claude 'hello'"
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex'
})
const directClaude = spawn.mock.calls[0]?.[0] as {
command?: string
env?: Record<string, string>
}
const compoundClaude = spawn.mock.calls[1]?.[0] as {
command?: string
env?: Record<string, string>
}
const normalAgent = spawn.mock.calls[2]?.[0] as {
command?: string
env?: Record<string, string>
}
expect(directClaude.command).toBe("claude --teammate-mode in-process 'hello'")
expect(directClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1')
expect(directClaude.env?.TMUX).toBeUndefined()
expect(compoundClaude.command).toBe("echo ok; claude 'hello'")
expect(compoundClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
expect(compoundClaude.env?.TMUX).toBeUndefined()
expect(normalAgent.command).toBe('codex')
expect(normalAgent.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
expect(normalAgent.env?.TMUX).toBeUndefined()
})
it('adopts renderer pane identity for remote runtime terminal creates', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtime = new OrcaRuntimeService(store)
+75 -2
View File
@@ -182,6 +182,16 @@ import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher'
import type { EmulatorBridge } from '../emulator/emulator-bridge'
import { RuntimeFileCommands } from './orca-runtime-files'
import { RuntimeGitCommands } from './orca-runtime-git'
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
import type {
AgentTeamsTmuxCompatRequest,
AgentTeamsTmuxCompatResponse
} from './claude-agent-teams-service'
import {
buildClaudeAgentTeamsLaunchPlan,
ensureClaudeAgentTeamsShimDir,
resolveClaudeAgentTeamsShimBin
} from './claude-agent-teams-shim-env'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { collectMemorySnapshot } from '../memory/collector'
import { BrowserWindow, ipcMain } from 'electron'
@@ -559,6 +569,7 @@ type RuntimeStore = {
mobileEmulatorEnabled?: boolean
mobileEmulatorDefaultDeviceUdid?: string | null
voice?: VoiceSettings
claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode']
}
// Why: narrow to `unknown` return so test mocks can return void without
// a cast. The runtime never reads the return value — the persisted value
@@ -720,6 +731,7 @@ type RuntimePtyController = {
cwd?: string
command?: string
env?: Record<string, string>
envToDelete?: string[]
telemetry?: WorktreeStartupLaunch['telemetry']
connectionId?: string | null
worktreeId?: string
@@ -1570,6 +1582,7 @@ export class OrcaRuntimeService {
private accountServices: RuntimeAccountServices | null = null
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
private automationService: AutomationService | null = null
private readonly claudeAgentTeams = new ClaudeAgentTeamsService()
private mobileDictation: {
id: string
owner: string
@@ -10766,8 +10779,28 @@ export class OrcaRuntimeService {
const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
const leafId = canAdoptPaneIdentity ? (opts.leafId as string) : randomUUID()
const paneKey = makePaneKey(tabId, leafId)
const baseEnv = opts.env ?? {}
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
command: opts.command,
mode: this.store?.getSettings?.().claudeAgentTeamsMode,
baseEnv: {
...process.env,
...baseEnv
},
createTeamEnv: (shimDir, shimBin) =>
this.claudeAgentTeams.createLaunchEnv({
leaderHandle: preAllocatedHandle,
baseEnv: {
...process.env,
...baseEnv
},
shimDir,
shimBin
}).env
})
const env = {
...opts.env,
...baseEnv,
...agentTeamsPlan?.env,
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktree.id
@@ -10776,8 +10809,9 @@ export class OrcaRuntimeService {
cols: 120,
rows: 40,
cwd: worktree.path,
command: opts.command,
command: agentTeamsPlan?.command ?? opts.command,
env,
envToDelete: agentTeamsPlan?.envToDelete,
telemetry: opts.telemetry,
connectionId: repo?.connectionId ?? null,
worktreeId: worktree.id,
@@ -11291,6 +11325,7 @@ export class OrcaRuntimeService {
async closeTerminal(handle: string): Promise<RuntimeTerminalClose> {
this.assertGraphReady()
const pty = this.getLivePtyForHandle(handle)
this.claudeAgentTeams.removeTeamForLeaderHandle(handle)
if (pty) {
const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false
return { handle, tabId: pty.record.tabId, ptyKilled }
@@ -11320,6 +11355,7 @@ export class OrcaRuntimeService {
direction?: 'horizontal' | 'vertical'
command?: string
env?: Record<string, string>
envToDelete?: string[]
activate?: boolean
telemetrySource?: TerminalPaneSplitSource
} = {}
@@ -11357,6 +11393,7 @@ export class OrcaRuntimeService {
direction?: 'horizontal' | 'vertical'
command?: string
env?: Record<string, string>
envToDelete?: string[]
activate?: boolean
telemetrySource?: TerminalPaneSplitSource
} = {}
@@ -11389,6 +11426,7 @@ export class OrcaRuntimeService {
ORCA_TAB_ID: parentTabId,
ORCA_WORKTREE_ID: worktree.id
},
envToDelete: opts.envToDelete,
connectionId: repo?.connectionId ?? null,
worktreeId: worktree.id,
preAllocatedHandle
@@ -11420,6 +11458,41 @@ export class OrcaRuntimeService {
return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 }
}
async handleAgentTeamsTmuxCompat(
request: AgentTeamsTmuxCompatRequest
): Promise<AgentTeamsTmuxCompatResponse> {
return await this.claudeAgentTeams.handleTmuxCompat(request, {
splitTerminal: (handle, opts) => this.splitTerminal(handle, opts),
readTerminal: (handle, opts) => this.readTerminal(handle, opts),
sendTerminal: (handle, action) => this.sendTerminal(handle, action),
focusTerminal: (handle) => this.focusTerminal(handle),
closeTerminal: (handle) => this.closeTerminal(handle),
showTerminal: (handle) => this.showTerminal(handle)
})
}
async prepareClaudeAgentTeamsLeader(args: {
paneKey: string
baseEnv?: Record<string, string>
}): Promise<{ env: Record<string, string> }> {
const handle = this.getTerminalHandleForPaneKey(args.paneKey)
if (!handle) {
throw new Error('claude_agent_teams_requires_orca_terminal')
}
const baseEnv = {
...process.env,
...args.baseEnv
}
const shimDir = await ensureClaudeAgentTeamsShimDir()
const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv)
return this.claudeAgentTeams.createLaunchEnv({
leaderHandle: handle,
baseEnv,
shimDir,
shimBin
})
}
private waitForNewLeafInTab(
tabId: string,
existingLeafKeys: Set<string>,
+30
View File
@@ -485,6 +485,19 @@ const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
})
const AgentTeamsTmuxCompat = z.object({
teamId: requiredString('Missing agent team ID'),
token: requiredString('Missing agent team token'),
envPane: requiredString('Missing tmux pane identity'),
cwd: OptionalString,
argv: z.array(z.string())
})
const AgentTeamsPrepareLaunch = z.object({
paneKey: requiredString('Missing pane key'),
env: z.record(z.string(), z.string()).optional()
})
const TerminalResizeForClient = z.discriminatedUnion('mode', [
z.object({
terminal: requiredString('Missing terminal handle'),
@@ -771,6 +784,23 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
close: await runtime.closeTerminal(params.terminal)
})
}),
defineMethod({
name: 'agentTeams.tmuxCompat',
params: AgentTeamsTmuxCompat,
handler: async (params, { runtime }) => ({
tmux: await runtime.handleAgentTeamsTmuxCompat(params)
})
}),
defineMethod({
name: 'agentTeams.prepareLaunch',
params: AgentTeamsPrepareLaunch,
handler: async (params, { runtime }) => ({
launch: await runtime.prepareClaudeAgentTeamsLeader({
paneKey: params.paneKey,
baseEnv: params.env
})
})
}),
defineMethod({
name: 'terminal.setDisplayMode',
params: TerminalSetDisplayMode,
+2
View File
@@ -277,6 +277,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'speech.dictation.start',
'stats.summary',
'status.get',
'agentTeams.prepareLaunch',
'agentTeams.tmuxCompat',
'terminal.clearBuffer',
'terminal.close',
'terminal.create',