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
parent 9b819ce43f
commit 7ea359bd60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1953 additions and 30 deletions

View File

@ -279,6 +279,7 @@ function prepareDevCliWrapper() {
const binDir = path.join(repoRoot, 'out', 'bin')
mkdirSync(binDir, { recursive: true })
const userDataPath = getDevUserDataPath()
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
const electronBin = getElectronExecutable()
@ -289,13 +290,20 @@ function prepareDevCliWrapper() {
'utf8'
)
} else {
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
const wrapperPath = path.join(binDir, 'orca-dev')
writeFileSync(
wrapperPath,
`#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`,
'utf8'
)
writeFileSync(wrapperPath, wrapperContent, 'utf8')
chmodSync(wrapperPath, 0o755)
mkdirSync(userDataBinDir, { recursive: true })
for (const commandName of ['orca-dev', 'orca']) {
const userDataWrapperPath = path.join(userDataBinDir, commandName)
// Why: dev Orca terminals prepend this directory to PATH; refreshing the
// `orca` alias prevents stale global/userData wrappers from hijacking
// Orca-owned commands such as `orca claude-teams`.
writeFileSync(userDataWrapperPath, wrapperContent, 'utf8')
chmodSync(userDataWrapperPath, 0o755)
}
}
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`

View File

@ -1,7 +1,41 @@
import { spawn } from 'child_process'
import type { CommandHandler } from '../dispatch'
import { formatCliStatus, formatStatus, printResult } from '../format'
import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
function envRecord(): Record<string, string> {
return Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)
)
}
function withTeammateModeAuto(args: string[]): string[] {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--teammate-mode' || arg.startsWith('--teammate-mode=')) {
return args
}
}
return ['--teammate-mode', 'auto', ...args]
}
async function runClaudeAgentTeams(env: Record<string, string>, args: string[]): Promise<number> {
return await new Promise((resolve, reject) => {
const child = spawn('claude', withTeammateModeAuto(args), {
stdio: 'inherit',
env
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (typeof code === 'number') {
resolve(code)
return
}
resolve(signal ? 1 : 0)
})
})
}
function getOptionalServePort(flags: Map<string, string | boolean>): string | null {
if (!flags.has('port')) {
return null
@ -18,6 +52,35 @@ function getOptionalServePort(flags: Map<string, string | boolean>): string | nu
}
export const CORE_HANDLERS: Record<string, CommandHandler> = {
'claude-teams': async ({ client }) => {
if (process.platform === 'win32') {
throw new RuntimeClientError(
'unsupported_platform',
'Claude Agent Teams native panes are not supported on Windows.'
)
}
const paneKey = process.env.ORCA_PANE_KEY
if (!paneKey) {
throw new RuntimeClientError(
'invalid_environment',
'orca claude-teams must be run inside an Orca terminal.'
)
}
const response = await client.call<{ launch: { env: Record<string, string> } }>(
'agentTeams.prepareLaunch',
{
paneKey,
env: envRecord()
}
)
process.exitCode = await runClaudeAgentTeams(
{
...envRecord(),
...response.result.launch.env
},
[]
)
},
open: async ({ client, json }) => {
const result = await client.openOrca()
printResult(result, json, formatCliStatus)

View File

@ -7,13 +7,15 @@ const {
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn()
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', () => {
@ -79,6 +81,17 @@ vi.mock('./runtime/environments', () => ({
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { EventEmitter } = await import('events')
return {
spawn: spawnMock.mockImplementation(() => {
const child = new EventEmitter()
process.nextTick(() => child.emit('exit', 0, null))
return child
})
}
})
import {
buildCurrentWorktreeSelector,
COMMAND_SPECS,
@ -147,6 +160,7 @@ describe('orca cli worktree awareness', () => {
getDefaultUserDataPathMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReset()
listEnvironmentsMock.mockReset()
spawnMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReturnValue({
id: 'env-1',
name: 'desk',
@ -240,6 +254,42 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledTimes(1)
})
it.skipIf(process.platform === 'win32')(
'prepares and starts Claude Agent Teams in the current Orca terminal',
async () => {
process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
queueFixtures(
callMock,
okFixture('req_agent_teams_prepare', {
launch: {
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1',
TMUX_PANE: '%1',
PATH: '/tmp/orca-shim:/usr/bin'
}
}
})
)
await main(['claude-teams'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('agentTeams.prepareLaunch', {
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
env: expect.objectContaining({
ORCA_PANE_KEY: 'tab-1:11111111-1111-4111-8111-111111111111'
})
})
expect(spawnMock).toHaveBeenCalledWith('claude', ['--teammate-mode', 'auto'], {
stdio: 'inherit',
env: expect.objectContaining({
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX_PANE: '%1'
})
})
}
)
it('rejects remote `worktree current` without listing worktrees from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

View File

@ -23,6 +23,10 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
}
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
if (argv[0] === 'agent-teams-tmux') {
await runAgentTeamsTmuxShim(argv.slice(1))
return
}
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv))
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
@ -76,6 +80,31 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
}
}
async function runAgentTeamsTmuxShim(argv: string[]): Promise<void> {
try {
const client = new RuntimeClient(undefined, 10_000)
const response = await client.call<{
tmux: { stdout: string; stderr: string; exitCode: number }
}>(
'agentTeams.tmuxCompat',
{
teamId: process.env.ORCA_AGENT_TEAMS_TEAM_ID,
token: process.env.ORCA_AGENT_TEAMS_TOKEN,
envPane: process.env.TMUX_PANE,
cwd: process.cwd(),
argv
},
{ timeoutMs: 10_000 }
)
process.stdout.write(response.result.tmux.stdout)
process.stderr.write(response.result.tmux.stderr)
process.exitCode = response.result.tmux.exitCode
} catch (error) {
reportCliError(error, false, { commandPath: ['agent-teams-tmux'] })
process.exitCode = 1
}
}
if (require.main === module) {
void main()
}

View File

@ -35,6 +35,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca status', 'orca status --json']
},
{
path: ['claude-teams'],
summary: 'Start Claude Code Agent Teams in the current Orca terminal',
usage: 'orca claude-teams',
allowedFlags: [...GLOBAL_FLAGS],
notes: [
'Must be run from inside an Orca terminal. Starts Claude Code Agent Teams in the current pane and opens teammates as native Orca splits.'
],
examples: ['orca claude-teams']
},
{
path: ['repo', 'list'],
summary: 'List repos registered in Orca',

View File

@ -154,6 +154,9 @@ describe('CliInstaller', () => {
expect(installed.commandPath).toBe(join(commandDir, 'orca-dev'))
expect(installed.launcherPath).toBe(join(fixture.userDataPath, 'cli', 'bin', 'orca-dev'))
await expect(readlink(installed.commandPath as string)).resolves.toBe(installed.launcherPath)
await expect(
readFile(join(fixture.userDataPath, 'cli', 'bin', 'orca'), 'utf8')
).resolves.toBe(await readFile(installed.launcherPath as string, 'utf8'))
}
)

View File

@ -801,6 +801,15 @@ async function ensureDevLauncher(args: {
encoding: 'utf8',
mode: args.platform === 'win32' ? undefined : 0o755
})
if (args.commandName === DEV_COMMAND_NAME && args.platform !== 'win32') {
// Why: dev PTYs prepend userData/cli/bin to PATH, and product-owned
// commands are documented as `orca ...`. Keep that local alias fresh
// without claiming the global production command.
await writeFile(join(dirname(launcherPath), 'orca'), content, {
encoding: 'utf8',
mode: 0o755
})
}
return launcherPath
}

View File

@ -613,6 +613,36 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('uses shell wrapper when Agent Teams shim path must survive shell startup', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/zsh',
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
ORCA_AGENT_TEAMS_SHIM_DIR: '/tmp/orca-agent-teams-bin'
}
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const lastCall = spawnMock.mock.calls.at(-1)!
expect(lastCall[1]).toEqual(['-l'])
expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR)
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('deletes requested env keys after merging daemon process env', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@ -639,6 +669,31 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.CODEX_HOME).toBeUndefined()
})
it('honors explicit terminal env overrides after deleting requested defaults', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/bash',
TERM: 'screen-256color',
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
const lastCall = spawnMock.mock.calls.at(-1)!
expect(lastCall[2].name).toBe('screen-256color')
expect(lastCall[2].env.TERM).toBe('screen-256color')
expect(lastCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
expect(lastCall[2].env.TERM_PROGRAM).toBeUndefined()
expect(lastCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
})
it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)

View File

@ -2,7 +2,7 @@
preflight validation, and lifecycle guards that must stay in one execution path. */
import * as pty from 'node-pty'
import { statSync } from 'fs'
import { win32 as pathWin32 } from 'path'
import { delimiter, win32 as pathWin32 } from 'path'
import type { SubprocessHandle } from './session'
import { DaemonProtocolError } from './types'
import {
@ -74,6 +74,21 @@ function removeUnspecifiedPaneIdentityEnv(
}
}
function promoteAgentTeamsShimPath(
env: Record<string, string>,
requestedPath: string | undefined
): void {
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
return
}
const shimDir = requestedPath.split(delimiter)[0]
if (!shimDir) {
return
}
const currentParts = env.PATH?.split(delimiter).filter(Boolean) ?? []
env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter)
}
function removeInheritedDevAgentHookEndpoint(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
@ -248,6 +263,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
// Why: the daemon is forked from Electron and can inherit the pane identity
// of the terminal that launched `pn dev`; each PTY must opt into its own.
removeUnspecifiedPaneIdentityEnv(env, opts.env)
@ -377,6 +395,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
addOrcaWslInteropEnv(env)
}
} else {
// Why: relay-side launch modes can ask for host defaults to stay scrubbed
// even after environment normalization above.
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
// Why: any Orca-injected overlay env that user rc files can clobber
// needs the wrapper so the post-rc restore line runs.
const shellLaunch = opts.command
@ -385,7 +411,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
env.ORCA_OPENCODE_CONFIG_DIR ||
env.ORCA_PI_CODING_AGENT_DIR ||
env.ORCA_OMP_CODING_AGENT_DIR ||
env.ORCA_CODEX_HOME
env.ORCA_CODEX_HOME ||
env.ORCA_AGENT_TEAMS_SHIM_DIR
? getAttributionShellLaunchConfig(shellPath)
: null
if (shellLaunch) {
@ -393,6 +420,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
shellArgs = shellLaunch?.args ?? ['-l']
}
promoteAgentTeamsShimPath(env, opts.env?.PATH)
// Why: asar packaging can strip the +x bit from node-pty's spawn-helper
// binary. The main process fixes this via LocalPtyProvider, but the daemon
@ -407,7 +435,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let proc: pty.IPty
try {
proc = pty.spawn(shellPath, shellArgs, {
name: 'xterm-256color',
name: env.TERM ?? 'xterm-256color',
cols: size.cols,
rows: size.rows,
cwd: spawnCwd,

View File

@ -252,6 +252,7 @@ describePosix('daemon shell-ready launch config', () => {
'[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompRestoreLine =
'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
@ -263,6 +264,9 @@ describePosix('daemon shell-ready launch config', () => {
expect(bashRc).toContain(piRestoreLine)
expect(zshrc).toContain(codexRestoreLine)
expect(zlogin).toContain(codexRestoreLine)
expect(zshrc).toContain(agentTeamsPathRestoreLine)
expect(zlogin).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(codexRestoreLine)
// OMP launches use ORCA_OMP_CODING_AGENT_DIR; both restore lines must be
// present so a PTY of either kind has its overlay restored after rc files.

View File

@ -102,6 +102,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the PTY-scoped overlay before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@ -212,6 +220,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_agent_teams_path
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@ -273,6 +289,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"

View File

@ -977,7 +977,12 @@ describe('registerPtyHandlers', () => {
processEnvOverrides?: Record<string, string | undefined>,
// Why: daemon spawn tests need to exercise both WSL launch metadata
// from main and PR #2662 command threading for OMP overlay selection.
spawnArgs?: { cwd?: string; shellOverride?: string; command?: string }
spawnArgs?: {
cwd?: string
shellOverride?: string
command?: string
envToDelete?: string[]
}
): Promise<DaemonSpawnCall> {
const daemonSpawn = setupDaemonAdapter()
const savedEnv: Record<string, string | undefined> = {}
@ -1221,6 +1226,8 @@ describe('registerPtyHandlers', () => {
rows: number
worktreeId?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
@ -1248,6 +1255,57 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims for runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, undefined, (() => ({
enableGitHubAttribution: true
})) as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
command: 'claude',
env: {
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(
'/tmp/orca-user-data/orca-terminal-attribution/posix'
)
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
})
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
@ -1273,6 +1331,34 @@ describe('registerPtyHandlers', () => {
expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => {
const spawnOptions = await daemonSpawnAndGetOptions(
{
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
undefined,
() => ({ enableGitHubAttribution: true }),
undefined,
{
command: 'claude',
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
}
)
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(
'/tmp/orca-user-data/orca-terminal-attribution/posix'
)
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
})
it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => {
// Why: the mocked `app` (see vi.mock at the top of the file) is a
// plain object, so we can flip isPackaged for the scope of the test.

View File

@ -325,6 +325,44 @@ function readInheritedPath(baseEnv: Record<string, string>): string {
return baseEnv.PATH ?? baseEnv.Path ?? process.env.PATH ?? process.env.Path ?? ''
}
function firstPathEntry(pathValue: string | undefined): string | null {
const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0)
return first ?? null
}
function promoteAgentTeamsShimPath(
env: Record<string, string> | undefined,
requestedPath: string | undefined
): void {
if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) {
return
}
const shimPath = firstPathEntry(requestedPath)
if (!shimPath) {
return
}
const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path'
const currentPath = env[currentPathKey] ?? ''
const remaining = currentPath
.split(delimiter)
.filter((entry) => entry.length > 0 && entry !== shimPath)
// Why: host env injection can prepend Orca's attribution/dev shims. Claude
// Agent Teams must still resolve our fake tmux before any real tmux.
env[currentPathKey] = [shimPath, ...remaining].join(delimiter)
}
function deleteRequestedEnvKeys(
env: Record<string, string> | undefined,
keys: string[] | undefined
): void {
if (!env || !keys) {
return
}
for (const key of keys) {
delete env[key]
}
}
function isWslShellName(shellPath: string | undefined): boolean {
const shellName = shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase()
return shellName === 'wsl.exe' || shellName === 'wsl'
@ -1587,6 +1625,7 @@ export function registerPtyHandlers(
let env: Record<string, string> | undefined = claudeAuth
? { ...sshScopedEnv, ...claudeAuth.envPatch }
: sshScopedEnv
const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID ? env.PATH : undefined
if (args.preAllocatedHandle) {
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
}
@ -1616,8 +1655,12 @@ export function registerPtyHandlers(
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
}
const authEnvToDelete = claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,
@ -1626,10 +1669,8 @@ export function registerPtyHandlers(
...(isMintedSessionId ? { isNewSession: true } : {})
}
spawnOptions.envToDelete = mergePtyEnvDeletions(
claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined,
args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(env)
mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : []
)
if (skipCodexHomeEnv) {
spawnOptions.envToDelete = mergePtyEnvDeletions(
@ -1637,6 +1678,8 @@ export function registerPtyHandlers(
CODEX_HOME_ENV_KEYS
)
}
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
if (args.command !== undefined) {
spawnOptions.command = args.command
}
@ -1939,6 +1982,7 @@ export function registerPtyHandlers(
rows: number
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
connectionId?: string | null
worktreeId?: string
@ -2064,6 +2108,7 @@ export function registerPtyHandlers(
: null
const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey
const baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined
const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined
if (baseEnv && stablePaneKey) {
baseEnv.ORCA_PANE_KEY = stablePaneKey
if (typeof args.tabId === 'string') {
@ -2142,6 +2187,7 @@ export function registerPtyHandlers(
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
} catch (err) {
// Why: buildPtyHostEnv has filesystem side-effects (Pi overlay
// materialization). If it throws before we reach provider.spawn,
@ -2165,11 +2211,13 @@ export function registerPtyHandlers(
: undefined
const combinedEnvToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(
envToDelete,
args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(spawnEnv)
mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : []
),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : []
)
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath)
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,

View File

@ -2398,7 +2398,7 @@ describe('Store', () => {
)
const store = await createStore()
expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude'])
expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude', 'claude-agent-teams'])
const updated = store.updateSettings({
disabledTuiAgents: ['gemini', 'not-real', 'gemini', 'opencode'] as never

View File

@ -1868,6 +1868,20 @@ export class Store {
if (!visibleTaskProvidersDefaultedForJira) {
this.loadNeedsSave = true
}
const claudeAgentTeamsDefaultDisabledMigrated =
parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true
if (!claudeAgentTeamsDefaultDisabledMigrated) {
this.loadNeedsSave = true
}
const migratedDisabledTuiAgents = normalizeDisabledTuiAgents(
parsed.settings?.disabledTuiAgents
)
if (
!claudeAgentTeamsDefaultDisabledMigrated &&
!migratedDisabledTuiAgents.includes('claude-agent-teams')
) {
migratedDisabledTuiAgents.push('claude-agent-teams')
}
if (!autoRenameBranchFromWorkDefaultedOn) {
this.loadNeedsSave = true
}
@ -1923,7 +1937,8 @@ export class Store {
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
parsed.settings?.terminalShortcutPolicy
),
disabledTuiAgents: normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents),
disabledTuiAgents: migratedDisabledTuiAgents,
claudeAgentTeamsDefaultDisabledMigrated: true,
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
seedDefaults: true
}),

View File

@ -181,6 +181,35 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
})
it('honors explicit terminal env overrides after deleting requested defaults', async () => {
provider.configure({
buildSpawnEnv: (_id, env) => {
env.TERM_PROGRAM = 'Orca'
env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-attribution'
env.PATH = `/tmp/orca-attribution:${env.PATH ?? ''}`
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
env: {
TERM: 'screen-256color',
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].name).toBe('screen-256color')
expect(spawnCall[2].env.TERM).toBe('screen-256color')
expect(spawnCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnCall[2].env.TERM_PROGRAM).toBeUndefined()
expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
})
it('does not pass a Windows Codex home into WSL terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({

View File

@ -2,7 +2,7 @@
~70 lines of scanner/promise wiring to spawn(). Splitting the method would scatter
tightly coupled PTY lifecycle logic (scan ready write exit cleanup) across
files without a cleaner ownership seam. */
import { basename } from 'path'
import { basename, delimiter } from 'path'
import { win32 as pathWin32 } from 'path'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
import { resolveEffectiveWindowsPowerShell } from './windows-powershell'
@ -88,6 +88,21 @@ function removeUnspecifiedPaneIdentityEnv(
}
}
function promoteAgentTeamsShimPath(
env: Record<string, string>,
requestedPath: string | undefined
): void {
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
return
}
const shimDir = requestedPath.split(delimiter)[0]
if (!shimDir) {
return
}
const currentParts = env.PATH?.split(delimiter).filter(Boolean) ?? []
env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter)
}
function disposePtyListeners(id: string): void {
const disposables = ptyDisposables.get(id)
if (disposables) {
@ -341,6 +356,9 @@ export class LocalPtyProvider implements IPtyProvider {
for (const key of args.envToDelete ?? []) {
delete spawnEnv[key]
}
if (args.env?.TERM) {
spawnEnv.TERM = args.env.TERM
}
spawnEnv.LANG ??= 'en_US.UTF-8'
@ -369,6 +387,15 @@ export class LocalPtyProvider implements IPtyProvider {
wslDistro: launchWslDistro
})
: spawnEnv
// Why: app-level env hooks can reintroduce vars that special launch modes
// explicitly scrubbed. Apply deletions last so shims like Claude Agent
// Teams keep their PATH and terminal-detection contract.
for (const key of args.envToDelete ?? []) {
delete finalEnv[key]
}
if (args.env?.TERM) {
finalEnv.TERM = args.env.TERM
}
if (process.platform === 'win32') {
const codexHomeWslInfo = finalEnv.CODEX_HOME ? parseWslPath(finalEnv.CODEX_HOME) : null
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
@ -419,7 +446,8 @@ export class LocalPtyProvider implements IPtyProvider {
finalEnv.ORCA_OPENCODE_CONFIG_DIR ||
finalEnv.ORCA_PI_CODING_AGENT_DIR ||
finalEnv.ORCA_OMP_CODING_AGENT_DIR ||
finalEnv.ORCA_CODEX_HOME
finalEnv.ORCA_CODEX_HOME ||
finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR
getFallbackShellReadyConfig = args.command
? (shell) => getShellReadyLaunchConfig(shell)
: needsNoMarkerWrapper
@ -436,6 +464,7 @@ export class LocalPtyProvider implements IPtyProvider {
shellReadyLaunch = args.command ? shellLaunch : null
}
}
promoteAgentTeamsShimPath(finalEnv, args.env?.PATH)
// ── Worktree-scoped shell history (§7§10 of terminal-history-scope-design) ──
// Why: without this, all worktree terminals share a single global HISTFILE
@ -458,6 +487,7 @@ export class LocalPtyProvider implements IPtyProvider {
rows: args.rows,
cwd: effectiveCwd,
env: finalEnv,
termName: finalEnv.TERM,
ptySpawn: pty.spawn,
getShellReadyConfig: getFallbackShellReadyConfig,
// Why: if zsh failed and bash took over, HISTFILE still points to

View File

@ -327,6 +327,7 @@ describePosix('local PTY shell-ready launch config', () => {
'[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompRestoreLine =
'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
@ -338,6 +339,9 @@ describePosix('local PTY shell-ready launch config', () => {
expect(bashRc).toContain(piRestoreLine)
expect(zshrc).toContain(codexRestoreLine)
expect(zlogin).toContain(codexRestoreLine)
expect(zshrc).toContain(agentTeamsPathRestoreLine)
expect(zlogin).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(codexRestoreLine)
expect(zshrc).toContain(ompRestoreLine)
expect(zlogin).toContain(ompRestoreLine)

View File

@ -163,6 +163,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the PTY-scoped overlay before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@ -276,6 +284,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_agent_teams_path
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@ -338,6 +354,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"

View File

@ -94,6 +94,7 @@ export function validateWorkingDirectory(cwd: string): void {
export type ShellSpawnParams = {
shellPath: string
shellArgs: string[]
termName?: string
cols: number
rows: number
cwd: string
@ -120,6 +121,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
const {
shellPath,
shellArgs,
termName = 'xterm-256color',
cols,
rows,
cwd,
@ -137,7 +139,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
if (!primaryError) {
try {
return {
process: ptySpawn(shellPath, shellArgs, { name: 'xterm-256color', cols, rows, cwd, env }),
process: ptySpawn(shellPath, shellArgs, { name: termName, cols, rows, cwd, env }),
shellPath
}
} catch (err) {
@ -158,7 +160,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
onBeforeFallbackSpawn?.(env, fallback)
Object.assign(env, fallbackReady?.env ?? {})
const proc = ptySpawn(fallback, fallbackReady?.args ?? ['-l'], {
name: 'xterm-256color',
name: termName,
cols,
rows,
cwd,

View File

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

View File

@ -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
}
}

View File

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

View File

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

View File

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

View File

@ -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
}

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)

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>,

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,

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',

View File

@ -240,6 +240,51 @@ describe('run-electron-vite-dev', () => {
await stopWrapperAndTrackedPids(wrapper, trackedPids)
})
it.skipIf(process.platform === 'win32')(
'prepares userData orca and orca-dev wrappers for dev terminals',
async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-'))
const userDataPath = join(tempDir, 'userData')
const pidFile = join(tempDir, 'grandchild.pid')
const envFile = join(tempDir, 'env.json')
const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs')
const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs')
const wrapper = spawn(process.execPath, [wrapperPath], {
cwd: resolve('.'),
env: devWrapperTestEnv({
ORCA_DEV_USER_DATA_PATH: userDataPath,
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile,
ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile
}),
stdio: 'ignore'
})
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
})
const trackedPids = trackPidFile(pidFile)
const devWrapper = readFileSync(join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8')
const publicAliasWrapper = readFileSync(join(userDataPath, 'cli', 'bin', 'orca'), 'utf8')
expect(publicAliasWrapper).toBe(devWrapper)
expect(publicAliasWrapper).toContain('ORCA_USER_DATA_PATH')
expect(publicAliasWrapper).toContain('out/cli/index.js')
await stopWrapperAndTrackedPids(wrapper, trackedPids)
}
)
it('consumes the stable-name flag before forwarding args to electron-vite', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-'))
const pidFile = join(tempDir, 'grandchild.pid')

View File

@ -31,6 +31,12 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
cmd: 'claude',
homepageUrl: 'https://docs.anthropic.com/claude/docs/claude-code'
},
{
id: 'claude-agent-teams',
label: 'Claude Agent Teams',
cmd: 'orca claude-teams',
homepageUrl: 'https://code.claude.com/docs/agent-teams'
},
{
id: 'openclaude',
label: 'OpenClaude',
@ -261,7 +267,7 @@ export function AgentIcon({
if (!agent) {
return <AgentLetterIcon letter="?" size={size} />
}
if (agent === 'claude') {
if (agent === 'claude' || agent === 'claude-agent-teams') {
return <ClaudeIcon size={size} />
}
if (agent === 'codex') {

View File

@ -151,6 +151,7 @@ export function formatAgentTypeLabel(agentType: AgentType | null | undefined): s
// would silently accept a subset of the union.
const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
claude: true,
'claude-agent-teams': true,
openclaude: true,
codex: true,
autohand: true,

View File

@ -124,7 +124,10 @@ export function agentHasOrchestrationSkill(
}
export function sortOrchestrationAgents(agents: readonly TuiAgent[]): TuiAgent[] {
const order = new Map(TUI_AGENT_AUTO_PICK_ORDER.map((agent, index) => [agent, index]))
const order = new Map<TuiAgent, number>()
for (const [index, agent] of TUI_AGENT_AUTO_PICK_ORDER.entries()) {
order.set(agent, index)
}
return [...agents].sort(
(left, right) =>
(order.get(left) ?? Number.MAX_SAFE_INTEGER) - (order.get(right) ?? Number.MAX_SAFE_INTEGER)

View File

@ -14,8 +14,11 @@ describe('pickQuickWorkspaceAgent', () => {
it('uses the first enabled catalog agent while detection is pending', () => {
expect(pickQuickWorkspaceAgent(null, null, [])).toBe('claude')
expect(pickQuickWorkspaceAgent(null, null, ['claude'])).toBe('openclaude')
expect(pickQuickWorkspaceAgent(null, null, ['claude', 'openclaude'])).toBe('codex')
expect(pickQuickWorkspaceAgent(null, null, ['claude'])).toBe('claude-agent-teams')
expect(pickQuickWorkspaceAgent(null, null, ['claude', 'claude-agent-teams'])).toBe('openclaude')
expect(
pickQuickWorkspaceAgent(null, null, ['claude', 'claude-agent-teams', 'openclaude'])
).toBe('codex')
})
it('respects blank and disabled preferred agents', () => {

View File

@ -15,6 +15,7 @@ type ConcreteAgentKind = Exclude<AgentKind, 'other'>
const TUI_AGENT_KIND_BY_AGENT = {
claude: 'claude-code',
'claude-agent-teams': 'claude-agent-teams',
openclaude: 'openclaude',
codex: 'codex',
autohand: 'autohand',

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
addClaudeTeammateModeAuto,
isDirectClaudeCommand,
parseTmuxArgs,
renderTmuxFormat,
splitTmuxCommand,
tmuxSendKeysText,
tmuxValue
} from './claude-agent-teams-tmux-compat'
describe('claude agent teams tmux compat primitives', () => {
it('parses clustered tmux flags and keeps split size out of positional command text', () => {
const parsed = parseTmuxArgs(
['-t', '%1', '-hPl', '70%', '-F', '#{pane_id}', 'echo hi'],
['-t', '-l', '-F'],
['-h', '-P', '-d']
)
expect(parsed.flags.has('-h')).toBe(true)
expect(parsed.flags.has('-P')).toBe(true)
expect(tmuxValue(parsed, '-l')).toBe('70%')
expect(tmuxValue(parsed, '-F')).toBe('#{pane_id}')
expect(parsed.positional).toEqual(['echo hi'])
})
it('recognizes top-level tmux version probes separately from subcommand flags', () => {
expect(splitTmuxCommand(['-V'])).toEqual({ command: '-V', args: [] })
expect(splitTmuxCommand(['split-window', '-v'])).toEqual({
command: 'split-window',
args: ['-v']
})
})
it('renders supported tmux format variables and removes unknown variables', () => {
expect(
renderTmuxFormat(
'#{session_name}:#{window_index}:#{missing}',
{
session_name: 'orca',
window_index: '0'
},
'fallback'
)
).toBe('orca:0:')
})
it('maps send-keys tokens using practical tmux semantics', () => {
expect(tmuxSendKeysText(['hello', 'Space', 'world', 'Enter'], false)).toBe('hello world\r')
expect(tmuxSendKeysText(['hello', 'Space', 'world'], true)).toBe('hello Space world')
})
it('only rewrites direct Claude launch commands', () => {
expect(isDirectClaudeCommand("claude 'fix it'")).toBe(true)
expect(isDirectClaudeCommand("echo ok; claude 'fix it'")).toBe(false)
expect(addClaudeTeammateModeAuto("claude 'fix it'")).toBe(
"claude --teammate-mode auto 'fix it'"
)
expect(addClaudeTeammateModeAuto('claude --teammate-mode in-process')).toBe(
'claude --teammate-mode in-process'
)
})
})

View File

@ -0,0 +1,193 @@
export type ClaudeAgentTeamsMode = 'off' | 'in-process' | 'native-panes-shim'
export type ParsedTmuxCommand = {
command: string
args: string[]
}
export type ParsedTmuxArgs = {
flags: Set<string>
values: Map<string, string[]>
positional: string[]
}
const TMUX_FORMAT_VAR_RE = /#\{[^}]+\}/g
export function splitTmuxCommand(argv: string[]): ParsedTmuxCommand {
const globalValueFlags = new Set(['-L', '-S', '-f'])
const globalBoolFlags = new Set(['-V', '-v'])
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] ?? ''
if (arg === '--') {
break
}
if (!arg.startsWith('-') || arg === '-') {
return { command: arg.toLowerCase(), args: argv.slice(i + 1) }
}
if (globalBoolFlags.has(arg)) {
return { command: arg, args: [] }
}
if (globalValueFlags.has(arg)) {
i += 1
}
}
throw new Error('tmux shim requires a command')
}
export function parseTmuxArgs(
args: string[],
valueFlags: string[],
boolFlags: string[]
): ParsedTmuxArgs {
const valueSet = new Set(valueFlags)
const boolSet = new Set(boolFlags)
const flags = new Set<string>()
const values = new Map<string, string[]>()
const positional: string[] = []
let pastTerminator = false
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? ''
if (pastTerminator) {
positional.push(arg)
continue
}
if (arg === '--') {
pastTerminator = true
continue
}
if (!arg.startsWith('-') || arg === '-' || arg.startsWith('--')) {
positional.push(arg)
continue
}
const cluster = arg.slice(1)
let cursor = 0
let recognized = false
while (cursor < cluster.length) {
const flag = `-${cluster[cursor]}`
if (boolSet.has(flag)) {
flags.add(flag)
cursor += 1
recognized = true
continue
}
if (valueSet.has(flag)) {
const remainder = cluster.slice(cursor + 1)
const value = remainder || args[++i] || ''
values.set(flag, [...(values.get(flag) ?? []), value])
recognized = true
cursor = cluster.length
continue
}
recognized = false
break
}
if (!recognized) {
positional.push(arg)
}
}
return { flags, values, positional }
}
export function tmuxValue(parsed: ParsedTmuxArgs, flag: string): string | undefined {
return parsed.values.get(flag)?.at(-1)
}
export function renderTmuxFormat(
format: string | undefined,
context: Record<string, string>,
fallback: string
): string {
if (!format) {
return fallback
}
let rendered = format
for (const [key, value] of Object.entries(context)) {
rendered = rendered.replaceAll(`#{${key}}`, value)
}
rendered = rendered.replace(TMUX_FORMAT_VAR_RE, '').trim()
return rendered || fallback
}
export function tmuxSendKeysText(tokens: string[], literal: boolean): string {
if (literal) {
return tokens.join(' ')
}
let result = ''
let pendingSpace = false
for (const token of tokens) {
const special = tmuxSpecialKeyText(token)
if (special !== null) {
result += special
pendingSpace = false
continue
}
if (pendingSpace) {
result += ' '
}
result += token
pendingSpace = true
}
return result
}
function tmuxSpecialKeyText(token: string): string | null {
switch (token.toLowerCase()) {
case 'enter':
case 'c-m':
case 'kpenter':
return '\r'
case 'tab':
case 'c-i':
return '\t'
case 'space':
return ' '
case 'bspace':
case 'backspace':
return '\x7f'
case 'escape':
case 'esc':
case 'c-[':
return '\x1b'
case 'c-c':
return '\x03'
case 'c-d':
return '\x04'
case 'c-z':
return '\x1a'
case 'c-l':
return '\x0c'
default:
return null
}
}
export function isDirectClaudeCommand(command: string | undefined): boolean {
const trimmed = command?.trim() ?? ''
if (!trimmed) {
return false
}
if (/[;&|<>`]/.test(trimmed)) {
return false
}
const first = trimmed.match(/^\S+/)?.[0] ?? ''
return first === 'claude' || first.endsWith('/claude')
}
export function addClaudeTeammateModeAuto(command: string): string {
if (/(^|\s)--teammate-mode(?:\s|=|$)/.test(command)) {
return command
}
return command.replace(/^(\S+)/, '$1 --teammate-mode auto')
}
export function addClaudeTeammateModeInProcess(command: string): string {
if (/(^|\s)--teammate-mode(?:\s|=|$)/.test(command)) {
return command
}
return command.replace(/^(\S+)/, '$1 --teammate-mode in-process')
}

View File

@ -21,6 +21,7 @@ import { getDefaultSourceControlAiSettings } from './source-control-ai'
import { DEFAULT_APP_ICON_ID } from './app-icon'
import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications'
import { DEFAULT_BROWSER_PAGE_ZOOM_LEVEL } from './browser-page-zoom'
import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection'
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
export {
@ -233,6 +234,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
windowBackgroundBlur: false,
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
claudeAgentTeamsMode: 'off',
setupScriptLaunchMode: 'new-tab',
terminalScrollbackBytes: 10_000_000,
httpProxyUrl: '',
@ -269,7 +271,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
disabledTuiAgents: [],
disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS],
claudeAgentTeamsDefaultDisabledMigrated: true,
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',

View File

@ -58,6 +58,7 @@ import type {
// should map to concrete values; see `tuiAgentToAgentKind`.
export const AGENT_KIND_VALUES = [
'claude-code',
'claude-agent-teams',
'openclaude',
'codex',
'autohand',

View File

@ -69,6 +69,16 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
// See PR https://github.com/stablyai/orca/pull/926 for context.
draftPromptFlag: '--prefill'
},
'claude-agent-teams': {
// Why: this is an Orca-provided launch mode, not a separate upstream
// binary. Detection follows the Orca CLI, while the wrapper validates the
// real Claude binary when it starts.
detectCmd: 'orca',
detectCmdAliases: ['orca-dev', 'orca-ide'],
launchCmd: 'orca claude-teams',
expectedProcess: 'claude',
promptInjectionMode: 'stdin-after-start'
},
openclaude: {
detectCmd: 'openclaude',
launchCmd: 'openclaude',

View File

@ -5,6 +5,7 @@ import { isTuiAgent } from './tui-agent-config'
// automatic fallback priority when the user has not chosen a default agent.
export const TUI_AGENT_AUTO_PICK_ORDER = [
'claude',
'claude-agent-teams',
'openclaude',
'codex',
'grok',
@ -36,6 +37,10 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
'openclaw'
] as const satisfies readonly TuiAgent[]
export const DEFAULT_DISABLED_TUI_AGENTS = [
'claude-agent-teams'
] as const satisfies readonly TuiAgent[]
export function pickTuiAgent(
preferred: TuiAgent | 'blank' | null | undefined,
detected: Iterable<TuiAgent>,

View File

@ -25,6 +25,7 @@ import type {
} from './source-control-ai-types'
import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events'
import type { SleepingAgentSessionRecord } from './agent-session-resume'
import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
@ -1842,6 +1843,7 @@ export type ClaudeManagedAccountRuntimeSelection = {
* flow and for the default-agent setting. Extend this union as new agents are added. */
export type TuiAgent =
| 'claude' // Claude Code
| 'claude-agent-teams' // Claude Code Agent Teams via Orca native panes
| 'openclaude' // OpenClaude
| 'codex' // OpenAI Codex
| 'autohand' // Autohand Code CLI
@ -2069,6 +2071,9 @@ export type GlobalSettings = {
* can silently rewrite the user's clipboard). Opt-in preserves the
* conservative default while making the capability one toggle away. */
terminalAllowOsc52Clipboard: boolean
/** Experimental Claude Code Agent Teams integration. Native panes use a
* tmux-compatible shim so teammate output stays on Orca's normal PTY path. */
claudeAgentTeamsMode?: ClaudeAgentTeamsMode
/** Where the repo setup script runs on workspace create. Defaults to a
* background "Setup" tab so the user's main terminal stays immediately
* usable without the setup output crowding the initial pane. */
@ -2169,6 +2174,9 @@ export type GlobalSettings = {
/** Agents hidden from future picker and automatic launch choices. Detection
* remains a raw PATH capability snapshot. */
disabledTuiAgents: TuiAgent[]
/** One-shot guard so the experimental Claude Agent Teams launch mode starts
* hidden for existing profiles without overriding later user opt-ins. */
claudeAgentTeamsDefaultDisabledMigrated?: boolean
/** Why: worktree deletion is destructive (git worktree remove + rm -rf of the
* working directory), so Orca shows a confirmation dialog by default. Users
* who delete frequently can opt into skipping the dialog via a "Don't ask