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:
parent
9b819ce43f
commit
7ea359bd60
|
|
@ -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 ?? ''}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(() => {})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue