mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user