diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index a818f3e64..063cc5770 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -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 ?? ''}` diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 4ad992ac1..0f8628645 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -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 { + 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, args: string[]): Promise { + 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 | null { if (!flags.has('port')) { return null @@ -18,6 +52,35 @@ function getOptionalServePort(flags: Map): string | nu } export const CORE_HANDLERS: Record = { + '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 } }>( + '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) diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index fd17fb4dc..787556311 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -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(() => {}) diff --git a/src/cli/index.ts b/src/cli/index.ts index 7df357fef..3e57eb97b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,6 +23,10 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { } export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { + 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 { + 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() } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 32fc229e9..2459ee293 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -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', diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 001f88164..f3c0cb8a8 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -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')) } ) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index e7dae5f38..7155f6e61 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -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 } diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 0612251bd..a9a5fef32 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 4278a83ef..8f6fb96a3 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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, + 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, explicitEnv: Record | 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, diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 47ad5adc5..280405b70 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -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. diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index cf850c9cf..6d213ba83 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -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}" diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 8789be0b3..37aabd52e 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -977,7 +977,12 @@ describe('registerPtyHandlers', () => { processEnvOverrides?: Record, // 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 { const daemonSpawn = setupDaemonAdapter() const savedEnv: Record = {} @@ -1221,6 +1226,8 @@ describe('registerPtyHandlers', () => { rows: number worktreeId?: string env?: Record + 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 + 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. diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 60dad8f05..152237090 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -325,6 +325,44 @@ function readInheritedPath(baseEnv: Record): 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 | 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 | 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 | 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 + 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, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index a7ba01e8d..fc0d64fac 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e0ba281e0..3ded949d6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 }), diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index d88c6cbf4..381c4a214 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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({ diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index e858af265..4e9735b0d 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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, + 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 diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index f42aff7b8..7b9770afd 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -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) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index e6f504169..720b2c89b 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -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}" diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 0c5235b92..4dc42c3c0 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -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, diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts new file mode 100644 index 000000000..56ba8567a --- /dev/null +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -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 }) + }) +}) diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts new file mode 100644 index 000000000..acc71b5d3 --- /dev/null +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -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() + private readonly dispatcher = new ClaudeAgentTeamsTmuxDispatcher() + + createLaunchEnv(args: { + leaderHandle: string + baseEnv: Record + 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 = { + 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 { + 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 + } +} diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts new file mode 100644 index 000000000..5eb3abcf1 --- /dev/null +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts new file mode 100644 index 000000000..2b5f472bd --- /dev/null +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -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 + envToDelete?: string[] +} + +export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): Promise { + 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 + createTeamEnv: (shimDir: string, shimBin: string) => Record +}): Promise { + 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 = 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 { + 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 }) + } + } +} diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts new file mode 100644 index 000000000..e73025b8e --- /dev/null +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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: '' + } + } +} diff --git a/src/main/runtime/claude-agent-teams-types.ts b/src/main/runtime/claude-agent-teams-types.ts new file mode 100644 index 000000000..6f98b25a8 --- /dev/null +++ b/src/main/runtime/claude-agent-teams-types.ts @@ -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 +} + +export type AgentTeamsTerminalApi = { + splitTerminal( + handle: string, + opts: { + direction?: 'horizontal' | 'vertical' + command?: string + env?: Record + envToDelete?: string[] + activate?: boolean + } + ): Promise + readTerminal(handle: string, opts?: { limit?: number }): Promise + sendTerminal( + handle: string, + action: { text?: string; enter?: boolean; interrupt?: boolean } + ): Promise + focusTerminal(handle: string): Promise + closeTerminal(handle: string): Promise + showTerminal(handle: string): Promise +} + +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 + panes: Map + paneOrder: string[] + nextPaneNumber: number + mainVertical: { + mainPane: string + lastColumnPane: string | null + } | null + previouslyFocusedPane: string | null +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7cc17201d..a47cbad9a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 + } + const compoundClaude = spawn.mock.calls[1]?.[0] as { + command?: string + env?: Record + } + const normalAgent = spawn.mock.calls[2]?.[0] as { + command?: string + env?: Record + } + + 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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 329f647de..d87c53bd0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 + 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 { 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 + envToDelete?: string[] activate?: boolean telemetrySource?: TerminalPaneSplitSource } = {} @@ -11357,6 +11393,7 @@ export class OrcaRuntimeService { direction?: 'horizontal' | 'vertical' command?: string env?: Record + 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 { + 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 + }): Promise<{ env: Record }> { + 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, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 21d4791c5..fc22d899e 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index e692a10f7..68994b1d0 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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', diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index f9e93c9c4..7a30d115b 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -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') diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 963f19306..67ebe8dab 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -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 } - if (agent === 'claude') { + if (agent === 'claude' || agent === 'claude-agent-teams') { return } if (agent === 'codex') { diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index ecb634f45..5b64edfcf 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -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 = { claude: true, + 'claude-agent-teams': true, openclaude: true, codex: true, autohand: true, diff --git a/src/renderer/src/lib/orchestration-skill-coverage.ts b/src/renderer/src/lib/orchestration-skill-coverage.ts index a3d1e2d2a..e314cccbf 100644 --- a/src/renderer/src/lib/orchestration-skill-coverage.ts +++ b/src/renderer/src/lib/orchestration-skill-coverage.ts @@ -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() + 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) diff --git a/src/renderer/src/lib/quick-workspace-agent-selection.test.ts b/src/renderer/src/lib/quick-workspace-agent-selection.test.ts index 69af08074..2a9bf259f 100644 --- a/src/renderer/src/lib/quick-workspace-agent-selection.test.ts +++ b/src/renderer/src/lib/quick-workspace-agent-selection.test.ts @@ -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', () => { diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 3e26a61de..1ac5f96a5 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -15,6 +15,7 @@ type ConcreteAgentKind = Exclude const TUI_AGENT_KIND_BY_AGENT = { claude: 'claude-code', + 'claude-agent-teams': 'claude-agent-teams', openclaude: 'openclaude', codex: 'codex', autohand: 'autohand', diff --git a/src/shared/claude-agent-teams-tmux-compat.test.ts b/src/shared/claude-agent-teams-tmux-compat.test.ts new file mode 100644 index 000000000..0b82e1eff --- /dev/null +++ b/src/shared/claude-agent-teams-tmux-compat.test.ts @@ -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' + ) + }) +}) diff --git a/src/shared/claude-agent-teams-tmux-compat.ts b/src/shared/claude-agent-teams-tmux-compat.ts new file mode 100644 index 000000000..6d1f5eafb --- /dev/null +++ b/src/shared/claude-agent-teams-tmux-compat.ts @@ -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 + values: Map + 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() + const values = new Map() + 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, + 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') +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e53d648ae..bfd2406ab 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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', diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 8a5d1c43f..fdbbd3d16 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -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', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index fabdfd9e9..2e9f28c50 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -69,6 +69,16 @@ export const TUI_AGENT_CONFIG: Record = { // 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', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 0955dfe53..be7a6c37d 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 80f8cc661..cdb8a1fc5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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