feat: Agent Team
Browse files- README.md +7 -2
- assets/agentmate.png +3 -0
- assets/agentmate2.png +3 -0
- src/main.tsx +1 -1
- src/tools/shared/spawnMultiAgent.ts +103 -92
- src/utils/agentSwarmsEnabled.ts +4 -32
- src/utils/swarm/backends/ITermBackend.ts +2 -1
- src/utils/swarm/backends/TmuxBackend.ts +56 -6
- src/utils/swarm/backends/types.ts +18 -0
- src/utils/swarm/spawnUtils.ts +54 -8
- src/utils/swarm/teamHelpers.ts +47 -0
- src/utils/swarm/teammateLayoutManager.ts +3 -2
README.md
CHANGED
|
@@ -83,7 +83,8 @@ cp VersperClaw ~/.local/bin
|
|
| 83 |
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "200000",
|
| 84 |
"ENABLE_TOOL_SEARCH": "true",
|
| 85 |
"TAVILY_API_KEY": "",
|
| 86 |
-
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
# set auto-dream config
|
|
@@ -141,7 +142,11 @@ cd desktop && bun run tauri dev
|
|
| 141 |
|
| 142 |
### Agent Team and View via Tmux
|
| 143 |
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
### Auto Compact & Dream & SubAgent
|
| 147 |
|
|
|
|
| 83 |
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "200000",
|
| 84 |
"ENABLE_TOOL_SEARCH": "true",
|
| 85 |
"TAVILY_API_KEY": "",
|
| 86 |
+
# "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "0", default 1
|
| 87 |
+
"teammateMode": "tmux" # or in-process
|
| 88 |
}
|
| 89 |
|
| 90 |
# set auto-dream config
|
|
|
|
| 142 |
|
| 143 |
### Agent Team and View via Tmux
|
| 144 |
|
| 145 |
+
<table align="center">
|
| 146 |
+
<tr>
|
| 147 |
+
<td><img src="assets/agentmate2.png"></td>
|
| 148 |
+
</tr>
|
| 149 |
+
</table>
|
| 150 |
|
| 151 |
### Auto Compact & Dream & SubAgent
|
| 152 |
|
assets/agentmate.png
ADDED
|
Git LFS Details
|
assets/agentmate2.png
ADDED
|
Git LFS Details
|
src/main.tsx
CHANGED
|
@@ -3824,8 +3824,8 @@ async function run(): Promise<CommanderCommand> {
|
|
| 3824 |
permissionMode: 'auto'
|
| 3825 |
}));
|
| 3826 |
program.addOption(new Option('--tasks [id]', '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").').argParser(String).hideHelp());
|
| 3827 |
-
program.option('--agent-teams', '[ANT-ONLY] Force Claude to use multi-agent mode for solving problems', () => true);
|
| 3828 |
}
|
|
|
|
| 3829 |
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
| 3830 |
program.addOption(new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp());
|
| 3831 |
}
|
|
|
|
| 3824 |
permissionMode: 'auto'
|
| 3825 |
}));
|
| 3826 |
program.addOption(new Option('--tasks [id]', '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").').argParser(String).hideHelp());
|
|
|
|
| 3827 |
}
|
| 3828 |
+
program.option('--agent-teams', 'Force Claude to use multi-agent mode for solving problems', () => true);
|
| 3829 |
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
| 3830 |
program.addOption(new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp());
|
| 3831 |
}
|
src/tools/shared/spawnMultiAgent.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
* Extracted from TeammateTool to allow reuse by AgentTool.
|
| 4 |
*/
|
| 5 |
|
|
|
|
| 6 |
import React from 'react'
|
| 7 |
import {
|
| 8 |
getChromeFlagOverride,
|
|
@@ -35,7 +36,7 @@ import {
|
|
| 35 |
resetBackendDetection,
|
| 36 |
} from '../../utils/swarm/backends/registry.js'
|
| 37 |
import { getTeammateModeFromSnapshot } from '../../utils/swarm/backends/teammateModeSnapshot.js'
|
| 38 |
-
import type { BackendType } from '../../utils/swarm/backends/types.js'
|
| 39 |
import { isPaneBackend } from '../../utils/swarm/backends/types.js'
|
| 40 |
import {
|
| 41 |
SWARM_SESSION_NAME,
|
|
@@ -49,8 +50,9 @@ import {
|
|
| 49 |
type InProcessSpawnConfig,
|
| 50 |
spawnInProcessTeammate,
|
| 51 |
} from '../../utils/swarm/spawnInProcess.js'
|
| 52 |
-
import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js'
|
| 53 |
import {
|
|
|
|
| 54 |
readTeamFileAsync,
|
| 55 |
sanitizeAgentName,
|
| 56 |
sanitizeName,
|
|
@@ -185,6 +187,23 @@ async function ensureSession(sessionName: string): Promise<void> {
|
|
| 185 |
}
|
| 186 |
}
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
/**
|
| 189 |
* Gets the command to spawn a teammate.
|
| 190 |
* For native builds (compiled binaries), use process.execPath.
|
|
@@ -194,7 +213,7 @@ function getTeammateCommand(): string {
|
|
| 194 |
if (process.env[TEAMMATE_COMMAND_ENV_VAR]) {
|
| 195 |
return process.env[TEAMMATE_COMMAND_ENV_VAR]
|
| 196 |
}
|
| 197 |
-
return isInBundledMode() ?
|
| 198 |
}
|
| 199 |
|
| 200 |
/**
|
|
@@ -381,23 +400,9 @@ async function handleSpawnSplitPane(
|
|
| 381 |
// Assign a unique color to this teammate
|
| 382 |
const teammateColor = assignTeammateColor(teammateId)
|
| 383 |
|
| 384 |
-
// Create a pane in the swarm view
|
| 385 |
-
// - Inside tmux: splits current window (leader on left, teammates on right)
|
| 386 |
-
// - In iTerm2 with it2: uses native iTerm2 split panes
|
| 387 |
-
// - Outside both: creates claude-swarm session with tiled teammates
|
| 388 |
-
const { paneId, isFirstTeammate } = await createTeammatePaneInSwarmView(
|
| 389 |
-
sanitizedName,
|
| 390 |
-
teammateColor,
|
| 391 |
-
)
|
| 392 |
-
|
| 393 |
-
// Enable pane border status on first teammate when inside tmux
|
| 394 |
-
// (outside tmux, this is handled in createTeammatePaneInSwarmView)
|
| 395 |
-
if (isFirstTeammate && insideTmux) {
|
| 396 |
-
await enablePaneBorderStatus()
|
| 397 |
-
}
|
| 398 |
-
|
| 399 |
// Build the command to spawn Claude Code with teammate identity
|
| 400 |
-
//
|
|
|
|
| 401 |
const binaryPath = getTeammateCommand()
|
| 402 |
|
| 403 |
// Build teammate identity CLI args (replaces CLAUDE_CODE_* env vars)
|
|
@@ -414,7 +419,6 @@ async function handleSpawnSplitPane(
|
|
| 414 |
.join(' ')
|
| 415 |
|
| 416 |
// Build CLI flags to propagate to teammate
|
| 417 |
-
// Pass plan_mode_required to prevent inheriting bypass permissions
|
| 418 |
let inheritedFlags = buildInheritedCliFlags({
|
| 419 |
planModeRequired: plan_mode_required,
|
| 420 |
permissionMode: appState.toolPermissionContext.mode,
|
|
@@ -422,26 +426,45 @@ async function handleSpawnSplitPane(
|
|
| 422 |
|
| 423 |
// If teammate has a custom model, add --model flag (or replace inherited one)
|
| 424 |
if (model) {
|
| 425 |
-
// Remove any inherited --model flag first
|
| 426 |
inheritedFlags = inheritedFlags
|
| 427 |
.split(' ')
|
| 428 |
.filter((flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model')
|
| 429 |
.join(' ')
|
| 430 |
-
// Add the teammate's model
|
| 431 |
inheritedFlags = inheritedFlags
|
| 432 |
? `${inheritedFlags} --model ${quote([model])}`
|
| 433 |
: `--model ${quote([model])}`
|
| 434 |
}
|
| 435 |
|
| 436 |
const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : ''
|
| 437 |
-
//
|
| 438 |
-
|
| 439 |
-
const envStr = buildInheritedEnvVars()
|
| 440 |
-
const spawnCommand = `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
-
//
|
| 443 |
-
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
|
| 446 |
// Determine session/window names for output
|
| 447 |
const sessionName = insideTmux ? 'current' : SWARM_SESSION_NAME
|
|
@@ -485,28 +508,24 @@ async function handleSpawnSplitPane(
|
|
| 485 |
toolUseId: context.toolUseId,
|
| 486 |
})
|
| 487 |
|
| 488 |
-
// Register agent in the team file
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
cwd: workingDir,
|
| 506 |
-
subscriptions: [],
|
| 507 |
-
backendType: detectionResult.backend.type,
|
| 508 |
})
|
| 509 |
-
await writeTeamFileAsync(teamName, teamFile)
|
| 510 |
|
| 511 |
// Send initial instructions to teammate via mailbox
|
| 512 |
// The teammate's inbox poller will pick this up and submit it as their first turn
|
|
@@ -699,28 +718,24 @@ async function handleSpawnSeparateWindow(
|
|
| 699 |
toolUseId: context.toolUseId,
|
| 700 |
})
|
| 701 |
|
| 702 |
-
// Register agent in the team file
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
cwd: workingDir,
|
| 720 |
-
subscriptions: [],
|
| 721 |
-
backendType: 'tmux', // This handler always uses tmux directly
|
| 722 |
})
|
| 723 |
-
await writeTeamFileAsync(teamName, teamFile)
|
| 724 |
|
| 725 |
// Send initial instructions to teammate via mailbox
|
| 726 |
// The teammate's inbox poller will pick this up and submit it as their first turn
|
|
@@ -985,28 +1000,24 @@ async function handleSpawnInProcess(
|
|
| 985 |
}
|
| 986 |
})
|
| 987 |
|
| 988 |
-
// Register agent in the team file
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
cwd: getCwd(),
|
| 1006 |
-
subscriptions: [],
|
| 1007 |
-
backendType: 'in-process',
|
| 1008 |
})
|
| 1009 |
-
await writeTeamFileAsync(teamName, teamFile)
|
| 1010 |
|
| 1011 |
// Note: Do NOT send the prompt via mailbox for in-process teammates.
|
| 1012 |
// In-process teammates receive the prompt directly via startInProcessTeammate().
|
|
|
|
| 3 |
* Extracted from TeammateTool to allow reuse by AgentTool.
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
import { readlinkSync } from 'fs'
|
| 7 |
import React from 'react'
|
| 8 |
import {
|
| 9 |
getChromeFlagOverride,
|
|
|
|
| 36 |
resetBackendDetection,
|
| 37 |
} from '../../utils/swarm/backends/registry.js'
|
| 38 |
import { getTeammateModeFromSnapshot } from '../../utils/swarm/backends/teammateModeSnapshot.js'
|
| 39 |
+
import type { BackendType, PaneSpawnOptions } from '../../utils/swarm/backends/types.js'
|
| 40 |
import { isPaneBackend } from '../../utils/swarm/backends/types.js'
|
| 41 |
import {
|
| 42 |
SWARM_SESSION_NAME,
|
|
|
|
| 50 |
type InProcessSpawnConfig,
|
| 51 |
spawnInProcessTeammate,
|
| 52 |
} from '../../utils/swarm/spawnInProcess.js'
|
| 53 |
+
import { buildInheritedEnvMap, buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js'
|
| 54 |
import {
|
| 55 |
+
mutateTeamFileAsync,
|
| 56 |
readTeamFileAsync,
|
| 57 |
sanitizeAgentName,
|
| 58 |
sanitizeName,
|
|
|
|
| 187 |
}
|
| 188 |
}
|
| 189 |
|
| 190 |
+
/**
|
| 191 |
+
* Resolves the real executable path for the current process.
|
| 192 |
+
* In bun-compiled mode, process.execPath returns a bunfs virtual path
|
| 193 |
+
* (e.g., /$bunfs/root/VersperClaw). Uses /proc/self/exe on Linux to
|
| 194 |
+
* resolve the real binary path so spawned tmux panes can find it.
|
| 195 |
+
*/
|
| 196 |
+
function resolveRealExecPath(): string {
|
| 197 |
+
try {
|
| 198 |
+
if (process.platform === 'linux') {
|
| 199 |
+
return readlinkSync('/proc/self/exe')
|
| 200 |
+
}
|
| 201 |
+
} catch {
|
| 202 |
+
// Not on Linux or /proc not available — fall through
|
| 203 |
+
}
|
| 204 |
+
return process.execPath
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
/**
|
| 208 |
* Gets the command to spawn a teammate.
|
| 209 |
* For native builds (compiled binaries), use process.execPath.
|
|
|
|
| 213 |
if (process.env[TEAMMATE_COMMAND_ENV_VAR]) {
|
| 214 |
return process.env[TEAMMATE_COMMAND_ENV_VAR]
|
| 215 |
}
|
| 216 |
+
return isInBundledMode() ? resolveRealExecPath() : process.argv[1]!
|
| 217 |
}
|
| 218 |
|
| 219 |
/**
|
|
|
|
| 400 |
// Assign a unique color to this teammate
|
| 401 |
const teammateColor = assignTeammateColor(teammateId)
|
| 402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
// Build the command to spawn Claude Code with teammate identity
|
| 404 |
+
// We build this BEFORE creating the pane so it can be passed directly
|
| 405 |
+
// to split-window (TmuxBackend), bypassing the shell intermediary.
|
| 406 |
const binaryPath = getTeammateCommand()
|
| 407 |
|
| 408 |
// Build teammate identity CLI args (replaces CLAUDE_CODE_* env vars)
|
|
|
|
| 419 |
.join(' ')
|
| 420 |
|
| 421 |
// Build CLI flags to propagate to teammate
|
|
|
|
| 422 |
let inheritedFlags = buildInheritedCliFlags({
|
| 423 |
planModeRequired: plan_mode_required,
|
| 424 |
permissionMode: appState.toolPermissionContext.mode,
|
|
|
|
| 426 |
|
| 427 |
// If teammate has a custom model, add --model flag (or replace inherited one)
|
| 428 |
if (model) {
|
|
|
|
| 429 |
inheritedFlags = inheritedFlags
|
| 430 |
.split(' ')
|
| 431 |
.filter((flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model')
|
| 432 |
.join(' ')
|
|
|
|
| 433 |
inheritedFlags = inheritedFlags
|
| 434 |
? `${inheritedFlags} --model ${quote([model])}`
|
| 435 |
: `--model ${quote([model])}`
|
| 436 |
}
|
| 437 |
|
| 438 |
const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : ''
|
| 439 |
+
// Build env var map (skips vars already in settings.json / tmux global env)
|
| 440 |
+
const envMap = buildInheritedEnvMap()
|
| 441 |
+
const envStr = buildInheritedEnvVars() // string form for send-keys fallback
|
| 442 |
+
const spawnCommand = `${quote([binaryPath])} ${teammateArgs}${flagsStr}`
|
| 443 |
+
|
| 444 |
+
// Create a pane in the swarm view, passing the command directly
|
| 445 |
+
// TmuxBackend runs it via split-window <shell-command> (no shell needed).
|
| 446 |
+
// ITermBackend ignores spawnOptions and falls back to send-keys.
|
| 447 |
+
const { paneId, isFirstTeammate } = await createTeammatePaneInSwarmView(
|
| 448 |
+
sanitizedName,
|
| 449 |
+
teammateColor,
|
| 450 |
+
{
|
| 451 |
+
command: spawnCommand,
|
| 452 |
+
cwd: workingDir,
|
| 453 |
+
envVars: envMap,
|
| 454 |
+
} satisfies PaneSpawnOptions,
|
| 455 |
+
)
|
| 456 |
|
| 457 |
+
// Enable pane border status on first teammate when inside tmux
|
| 458 |
+
if (isFirstTeammate && insideTmux) {
|
| 459 |
+
await enablePaneBorderStatus()
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
// For non-tmux backends (iTerm2), fall back to send-keys with the
|
| 463 |
+
// full shell command (cd + env prefix). TmuxBackend already ran it.
|
| 464 |
+
if (detectionResult.backend.type !== 'tmux') {
|
| 465 |
+
const shellCommand = `cd ${quote([workingDir])} && env ${envStr} ${spawnCommand}`
|
| 466 |
+
await sendCommandToPane(paneId, shellCommand, !insideTmux)
|
| 467 |
+
}
|
| 468 |
|
| 469 |
// Determine session/window names for output
|
| 470 |
const sessionName = insideTmux ? 'current' : SWARM_SESSION_NAME
|
|
|
|
| 508 |
toolUseId: context.toolUseId,
|
| 509 |
})
|
| 510 |
|
| 511 |
+
// Register agent in the team file using atomic read-modify-write to avoid
|
| 512 |
+
// concurrent spawn clobbering other members.
|
| 513 |
+
await mutateTeamFileAsync(teamName, (teamFile) => {
|
| 514 |
+
teamFile.members.push({
|
| 515 |
+
agentId: teammateId,
|
| 516 |
+
name: sanitizedName,
|
| 517 |
+
agentType: agent_type,
|
| 518 |
+
model,
|
| 519 |
+
prompt,
|
| 520 |
+
color: teammateColor,
|
| 521 |
+
planModeRequired: plan_mode_required,
|
| 522 |
+
joinedAt: Date.now(),
|
| 523 |
+
tmuxPaneId: paneId,
|
| 524 |
+
cwd: workingDir,
|
| 525 |
+
subscriptions: [],
|
| 526 |
+
backendType: detectionResult.backend.type,
|
| 527 |
+
})
|
|
|
|
|
|
|
|
|
|
| 528 |
})
|
|
|
|
| 529 |
|
| 530 |
// Send initial instructions to teammate via mailbox
|
| 531 |
// The teammate's inbox poller will pick this up and submit it as their first turn
|
|
|
|
| 718 |
toolUseId: context.toolUseId,
|
| 719 |
})
|
| 720 |
|
| 721 |
+
// Register agent in the team file using atomic read-modify-write to avoid
|
| 722 |
+
// concurrent spawn clobbering other members.
|
| 723 |
+
await mutateTeamFileAsync(teamName, (teamFile) => {
|
| 724 |
+
teamFile.members.push({
|
| 725 |
+
agentId: teammateId,
|
| 726 |
+
name: sanitizedName,
|
| 727 |
+
agentType: agent_type,
|
| 728 |
+
model,
|
| 729 |
+
prompt,
|
| 730 |
+
color: teammateColor,
|
| 731 |
+
planModeRequired: plan_mode_required,
|
| 732 |
+
joinedAt: Date.now(),
|
| 733 |
+
tmuxPaneId: paneId,
|
| 734 |
+
cwd: workingDir,
|
| 735 |
+
subscriptions: [],
|
| 736 |
+
backendType: 'tmux', // This handler always uses tmux directly
|
| 737 |
+
})
|
|
|
|
|
|
|
|
|
|
| 738 |
})
|
|
|
|
| 739 |
|
| 740 |
// Send initial instructions to teammate via mailbox
|
| 741 |
// The teammate's inbox poller will pick this up and submit it as their first turn
|
|
|
|
| 1000 |
}
|
| 1001 |
})
|
| 1002 |
|
| 1003 |
+
// Register agent in the team file using atomic read-modify-write to avoid
|
| 1004 |
+
// concurrent spawn clobbering other members.
|
| 1005 |
+
await mutateTeamFileAsync(teamName, (teamFile) => {
|
| 1006 |
+
teamFile.members.push({
|
| 1007 |
+
agentId: teammateId,
|
| 1008 |
+
name: sanitizedName,
|
| 1009 |
+
agentType: agent_type,
|
| 1010 |
+
model,
|
| 1011 |
+
prompt,
|
| 1012 |
+
color: teammateColor,
|
| 1013 |
+
planModeRequired: plan_mode_required,
|
| 1014 |
+
joinedAt: Date.now(),
|
| 1015 |
+
tmuxPaneId: 'in-process',
|
| 1016 |
+
cwd: getCwd(),
|
| 1017 |
+
subscriptions: [],
|
| 1018 |
+
backendType: 'in-process',
|
| 1019 |
+
})
|
|
|
|
|
|
|
|
|
|
| 1020 |
})
|
|
|
|
| 1021 |
|
| 1022 |
// Note: Do NOT send the prompt via mailbox for in-process teammates.
|
| 1023 |
// In-process teammates receive the prompt directly via startInProcessTeammate().
|
src/utils/agentSwarmsEnabled.ts
CHANGED
|
@@ -1,42 +1,14 @@
|
|
| 1 |
-
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
| 2 |
-
import { isEnvTruthy } from './envUtils.js'
|
| 3 |
-
|
| 4 |
-
/**
|
| 5 |
-
* Check if --agent-teams flag is provided via CLI.
|
| 6 |
-
* Checks process.argv directly to avoid import cycles with bootstrap/state.
|
| 7 |
-
* Note: The flag is only shown in help for ant users, but if external users
|
| 8 |
-
* pass it anyway, it will work (subject to the killswitch).
|
| 9 |
-
*/
|
| 10 |
-
function isAgentTeamsFlagSet(): boolean {
|
| 11 |
-
return process.argv.includes('--agent-teams')
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
/**
|
| 15 |
* Centralized runtime check for agent teams/teammate features.
|
| 16 |
* This is the single gate that should be checked everywhere teammates
|
| 17 |
* are referenced (prompts, code, tools isEnabled, UI, etc.).
|
| 18 |
*
|
| 19 |
-
*
|
| 20 |
-
*
|
| 21 |
-
* 1. Opt-in via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var OR --agent-teams flag
|
| 22 |
-
* 2. GrowthBook gate 'tengu_amber_flint' enabled (killswitch)
|
| 23 |
*/
|
| 24 |
export function isAgentSwarmsEnabled(): boolean {
|
| 25 |
-
//
|
| 26 |
-
if (process.env.
|
| 27 |
-
return true
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
// External: require opt-in via env var or --agent-teams flag
|
| 31 |
-
if (
|
| 32 |
-
!isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) &&
|
| 33 |
-
!isAgentTeamsFlagSet()
|
| 34 |
-
) {
|
| 35 |
-
return false
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
// Killswitch — always respected for external users
|
| 39 |
-
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_flint', true)) {
|
| 40 |
return false
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
* Centralized runtime check for agent teams/teammate features.
|
| 3 |
* This is the single gate that should be checked everywhere teammates
|
| 4 |
* are referenced (prompts, code, tools isEnabled, UI, etc.).
|
| 5 |
*
|
| 6 |
+
* Free-code: always enabled by default.
|
| 7 |
+
* Set CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 to explicitly disable.
|
|
|
|
|
|
|
| 8 |
*/
|
| 9 |
export function isAgentSwarmsEnabled(): boolean {
|
| 10 |
+
// Explicit opt-out
|
| 11 |
+
if (process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '0') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
return false
|
| 13 |
}
|
| 14 |
|
src/utils/swarm/backends/ITermBackend.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { logForDebugging } from '../../../utils/debug.js'
|
|
| 3 |
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
|
| 4 |
import { IT2_COMMAND, isInITerm2, isIt2CliAvailable } from './detection.js'
|
| 5 |
import { registerITermBackend } from './registry.js'
|
| 6 |
-
import type { CreatePaneResult, PaneBackend, PaneId } from './types.js'
|
| 7 |
|
| 8 |
// Track session IDs for teammates
|
| 9 |
const teammateSessionIds: string[] = []
|
|
@@ -114,6 +114,7 @@ export class ITermBackend implements PaneBackend {
|
|
| 114 |
async createTeammatePaneInSwarmView(
|
| 115 |
name: string,
|
| 116 |
color: AgentColorName,
|
|
|
|
| 117 |
): Promise<CreatePaneResult> {
|
| 118 |
logForDebugging(
|
| 119 |
`[ITermBackend] createTeammatePaneInSwarmView called for ${name} with color ${color}`,
|
|
|
|
| 3 |
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
|
| 4 |
import { IT2_COMMAND, isInITerm2, isIt2CliAvailable } from './detection.js'
|
| 5 |
import { registerITermBackend } from './registry.js'
|
| 6 |
+
import type { CreatePaneResult, PaneBackend, PaneId, PaneSpawnOptions } from './types.js'
|
| 7 |
|
| 8 |
// Track session IDs for teammates
|
| 9 |
const teammateSessionIds: string[] = []
|
|
|
|
| 114 |
async createTeammatePaneInSwarmView(
|
| 115 |
name: string,
|
| 116 |
color: AgentColorName,
|
| 117 |
+
_spawnOptions?: PaneSpawnOptions,
|
| 118 |
): Promise<CreatePaneResult> {
|
| 119 |
logForDebugging(
|
| 120 |
`[ITermBackend] createTeammatePaneInSwarmView called for ${name} with color ${color}`,
|
src/utils/swarm/backends/TmuxBackend.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
| 17 |
isTmuxAvailable,
|
| 18 |
} from './detection.js'
|
| 19 |
import { registerTmuxBackend } from './registry.js'
|
| 20 |
-
import type { CreatePaneResult, PaneBackend, PaneId } from './types.js'
|
| 21 |
|
| 22 |
// Track whether the first pane has been used for external swarm session
|
| 23 |
let firstPaneUsedForExternal = false
|
|
@@ -129,6 +129,7 @@ export class TmuxBackend implements PaneBackend {
|
|
| 129 |
async createTeammatePaneInSwarmView(
|
| 130 |
name: string,
|
| 131 |
color: AgentColorName,
|
|
|
|
| 132 |
): Promise<CreatePaneResult> {
|
| 133 |
const releaseLock = await acquirePaneCreationLock()
|
| 134 |
|
|
@@ -136,7 +137,7 @@ export class TmuxBackend implements PaneBackend {
|
|
| 136 |
const insideTmux = await this.isRunningInside()
|
| 137 |
|
| 138 |
if (insideTmux) {
|
| 139 |
-
return await this.createTeammatePaneWithLeader(name, color)
|
| 140 |
}
|
| 141 |
|
| 142 |
return await this.createTeammatePaneExternal(name, color)
|
|
@@ -547,10 +548,13 @@ export class TmuxBackend implements PaneBackend {
|
|
| 547 |
|
| 548 |
/**
|
| 549 |
* Creates a teammate pane when running inside tmux (with leader).
|
|
|
|
|
|
|
| 550 |
*/
|
| 551 |
private async createTeammatePaneWithLeader(
|
| 552 |
teammateName: string,
|
| 553 |
teammateColor: AgentColorName,
|
|
|
|
| 554 |
): Promise<CreatePaneResult> {
|
| 555 |
const currentPaneId = await this.getCurrentPaneId()
|
| 556 |
const windowTarget = await this.getCurrentWindowTarget()
|
|
@@ -565,6 +569,24 @@ export class TmuxBackend implements PaneBackend {
|
|
| 565 |
}
|
| 566 |
const isFirstTeammate = paneCount === 1
|
| 567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
let splitResult
|
| 569 |
if (isFirstTeammate) {
|
| 570 |
// First teammate: split horizontally from the leader pane
|
|
@@ -578,6 +600,8 @@ export class TmuxBackend implements PaneBackend {
|
|
| 578 |
'-P',
|
| 579 |
'-F',
|
| 580 |
'#{pane_id}',
|
|
|
|
|
|
|
| 581 |
])
|
| 582 |
} else {
|
| 583 |
// Additional teammates: split from an existing teammate pane
|
|
@@ -607,6 +631,8 @@ export class TmuxBackend implements PaneBackend {
|
|
| 607 |
'-P',
|
| 608 |
'-F',
|
| 609 |
'#{pane_id}',
|
|
|
|
|
|
|
| 610 |
])
|
| 611 |
}
|
| 612 |
|
|
@@ -623,8 +649,11 @@ export class TmuxBackend implements PaneBackend {
|
|
| 623 |
await this.setPaneTitle(paneId, teammateName, teammateColor)
|
| 624 |
await this.rebalancePanesWithLeader(windowTarget)
|
| 625 |
|
| 626 |
-
//
|
| 627 |
-
|
|
|
|
|
|
|
|
|
|
| 628 |
|
| 629 |
return { paneId, isFirstTeammate }
|
| 630 |
}
|
|
@@ -635,6 +664,7 @@ export class TmuxBackend implements PaneBackend {
|
|
| 635 |
private async createTeammatePaneExternal(
|
| 636 |
teammateName: string,
|
| 637 |
teammateColor: AgentColorName,
|
|
|
|
| 638 |
): Promise<CreatePaneResult> {
|
| 639 |
const { windowTarget, paneId: firstPaneId } =
|
| 640 |
await this.createExternalSwarmSession()
|
|
@@ -645,6 +675,19 @@ export class TmuxBackend implements PaneBackend {
|
|
| 645 |
}
|
| 646 |
const isFirstTeammate = !firstPaneUsedForExternal && paneCount === 1
|
| 647 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
let paneId: string
|
| 649 |
|
| 650 |
if (isFirstTeammate) {
|
|
@@ -655,6 +698,12 @@ export class TmuxBackend implements PaneBackend {
|
|
| 655 |
)
|
| 656 |
|
| 657 |
await this.enablePaneBorderStatus(windowTarget, true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
} else {
|
| 659 |
const listResult = await runTmuxInSwarm([
|
| 660 |
'list-panes',
|
|
@@ -695,8 +744,9 @@ export class TmuxBackend implements PaneBackend {
|
|
| 695 |
await this.setPaneTitle(paneId, teammateName, teammateColor, true)
|
| 696 |
await this.rebalancePanesTiled(windowTarget)
|
| 697 |
|
| 698 |
-
|
| 699 |
-
|
|
|
|
| 700 |
|
| 701 |
return { paneId, isFirstTeammate }
|
| 702 |
}
|
|
|
|
| 17 |
isTmuxAvailable,
|
| 18 |
} from './detection.js'
|
| 19 |
import { registerTmuxBackend } from './registry.js'
|
| 20 |
+
import type { CreatePaneResult, PaneBackend, PaneId, PaneSpawnOptions } from './types.js'
|
| 21 |
|
| 22 |
// Track whether the first pane has been used for external swarm session
|
| 23 |
let firstPaneUsedForExternal = false
|
|
|
|
| 129 |
async createTeammatePaneInSwarmView(
|
| 130 |
name: string,
|
| 131 |
color: AgentColorName,
|
| 132 |
+
spawnOptions?: PaneSpawnOptions,
|
| 133 |
): Promise<CreatePaneResult> {
|
| 134 |
const releaseLock = await acquirePaneCreationLock()
|
| 135 |
|
|
|
|
| 137 |
const insideTmux = await this.isRunningInside()
|
| 138 |
|
| 139 |
if (insideTmux) {
|
| 140 |
+
return await this.createTeammatePaneWithLeader(name, color, spawnOptions)
|
| 141 |
}
|
| 142 |
|
| 143 |
return await this.createTeammatePaneExternal(name, color)
|
|
|
|
| 548 |
|
| 549 |
/**
|
| 550 |
* Creates a teammate pane when running inside tmux (with leader).
|
| 551 |
+
* If spawnOptions.command is provided, runs it directly in the pane
|
| 552 |
+
* via split-window <shell-command>, bypassing the shell intermediary.
|
| 553 |
*/
|
| 554 |
private async createTeammatePaneWithLeader(
|
| 555 |
teammateName: string,
|
| 556 |
teammateColor: AgentColorName,
|
| 557 |
+
spawnOptions?: PaneSpawnOptions,
|
| 558 |
): Promise<CreatePaneResult> {
|
| 559 |
const currentPaneId = await this.getCurrentPaneId()
|
| 560 |
const windowTarget = await this.getCurrentWindowTarget()
|
|
|
|
| 569 |
}
|
| 570 |
const isFirstTeammate = paneCount === 1
|
| 571 |
|
| 572 |
+
// Build split-window args
|
| 573 |
+
// If spawnOptions.command is provided, pass it directly as the
|
| 574 |
+
// shell-command argument to split-window. This avoids the bash
|
| 575 |
+
// intermediary and the subsequent send-keys step.
|
| 576 |
+
const splitArgs: string[] = []
|
| 577 |
+
if (spawnOptions?.command) {
|
| 578 |
+
// Working directory via tmux -c flag (no need for cd ... &&)
|
| 579 |
+
if (spawnOptions.cwd) {
|
| 580 |
+
splitArgs.push('-c', spawnOptions.cwd)
|
| 581 |
+
}
|
| 582 |
+
// Environment vars via tmux -e flags (no need for env KEY=VALUE prefix)
|
| 583 |
+
if (spawnOptions.envVars) {
|
| 584 |
+
for (const [key, val] of Object.entries(spawnOptions.envVars)) {
|
| 585 |
+
splitArgs.push('-e', `${key}=${val}`)
|
| 586 |
+
}
|
| 587 |
+
}
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
let splitResult
|
| 591 |
if (isFirstTeammate) {
|
| 592 |
// First teammate: split horizontally from the leader pane
|
|
|
|
| 600 |
'-P',
|
| 601 |
'-F',
|
| 602 |
'#{pane_id}',
|
| 603 |
+
...splitArgs,
|
| 604 |
+
...(spawnOptions?.command ? [spawnOptions.command] : []),
|
| 605 |
])
|
| 606 |
} else {
|
| 607 |
// Additional teammates: split from an existing teammate pane
|
|
|
|
| 631 |
'-P',
|
| 632 |
'-F',
|
| 633 |
'#{pane_id}',
|
| 634 |
+
...splitArgs,
|
| 635 |
+
...(spawnOptions?.command ? [spawnOptions.command] : []),
|
| 636 |
])
|
| 637 |
}
|
| 638 |
|
|
|
|
| 649 |
await this.setPaneTitle(paneId, teammateName, teammateColor)
|
| 650 |
await this.rebalancePanesWithLeader(windowTarget)
|
| 651 |
|
| 652 |
+
// If we passed a command directly, there's no shell waiting for
|
| 653 |
+
// input — skip the shell-init delay.
|
| 654 |
+
if (!spawnOptions?.command) {
|
| 655 |
+
await waitForPaneShellReady()
|
| 656 |
+
}
|
| 657 |
|
| 658 |
return { paneId, isFirstTeammate }
|
| 659 |
}
|
|
|
|
| 664 |
private async createTeammatePaneExternal(
|
| 665 |
teammateName: string,
|
| 666 |
teammateColor: AgentColorName,
|
| 667 |
+
spawnOptions?: PaneSpawnOptions,
|
| 668 |
): Promise<CreatePaneResult> {
|
| 669 |
const { windowTarget, paneId: firstPaneId } =
|
| 670 |
await this.createExternalSwarmSession()
|
|
|
|
| 675 |
}
|
| 676 |
const isFirstTeammate = !firstPaneUsedForExternal && paneCount === 1
|
| 677 |
|
| 678 |
+
// Build split-window args (same logic as createTeammatePaneWithLeader)
|
| 679 |
+
const splitArgs: string[] = []
|
| 680 |
+
if (spawnOptions?.command) {
|
| 681 |
+
if (spawnOptions.cwd) {
|
| 682 |
+
splitArgs.push('-c', spawnOptions.cwd)
|
| 683 |
+
}
|
| 684 |
+
if (spawnOptions.envVars) {
|
| 685 |
+
for (const [key, val] of Object.entries(spawnOptions.envVars)) {
|
| 686 |
+
splitArgs.push('-e', `${key}=${val}`)
|
| 687 |
+
}
|
| 688 |
+
}
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
let paneId: string
|
| 692 |
|
| 693 |
if (isFirstTeammate) {
|
|
|
|
| 698 |
)
|
| 699 |
|
| 700 |
await this.enablePaneBorderStatus(windowTarget, true)
|
| 701 |
+
|
| 702 |
+
// First pane in external session can't use split-window with command,
|
| 703 |
+
// so fall back to send-keys.
|
| 704 |
+
if (spawnOptions?.command) {
|
| 705 |
+
await this.sendCommandToPane(paneId, spawnOptions.command, true)
|
| 706 |
+
}
|
| 707 |
} else {
|
| 708 |
const listResult = await runTmuxInSwarm([
|
| 709 |
'list-panes',
|
|
|
|
| 744 |
await this.setPaneTitle(paneId, teammateName, teammateColor, true)
|
| 745 |
await this.rebalancePanesTiled(windowTarget)
|
| 746 |
|
| 747 |
+
if (!spawnOptions?.command) {
|
| 748 |
+
await waitForPaneShellReady()
|
| 749 |
+
}
|
| 750 |
|
| 751 |
return { paneId, isFirstTeammate }
|
| 752 |
}
|
src/utils/swarm/backends/types.ts
CHANGED
|
@@ -31,6 +31,21 @@ export type CreatePaneResult = {
|
|
| 31 |
isFirstTeammate: boolean
|
| 32 |
}
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
/**
|
| 35 |
* Interface for pane management backends.
|
| 36 |
* Abstracts operations for creating and managing terminal panes
|
|
@@ -66,11 +81,14 @@ export type PaneBackend = {
|
|
| 66 |
*
|
| 67 |
* @param name - The teammate's name for display
|
| 68 |
* @param color - The color to use for the pane border/title
|
|
|
|
|
|
|
| 69 |
* @returns The pane ID and whether this was the first teammate
|
| 70 |
*/
|
| 71 |
createTeammatePaneInSwarmView(
|
| 72 |
name: string,
|
| 73 |
color: AgentColorName,
|
|
|
|
| 74 |
): Promise<CreatePaneResult>
|
| 75 |
|
| 76 |
/**
|
|
|
|
| 31 |
isFirstTeammate: boolean
|
| 32 |
}
|
| 33 |
|
| 34 |
+
/**
|
| 35 |
+
* Optional parameters for directly executing a command in a newly created pane,
|
| 36 |
+
* bypassing the shell intermediary and subsequent send-keys step.
|
| 37 |
+
* Supported by TmuxBackend via split-window <shell-command>; ignored by
|
| 38 |
+
* ITermBackend (falls back to send-keys).
|
| 39 |
+
*/
|
| 40 |
+
export type PaneSpawnOptions = {
|
| 41 |
+
/** The command to execute directly in the pane (instead of a shell) */
|
| 42 |
+
command: string
|
| 43 |
+
/** Working directory for the pane (tmux -c flag) */
|
| 44 |
+
cwd: string
|
| 45 |
+
/** Environment variables to set on the pane (tmux -e flags) */
|
| 46 |
+
envVars?: Record<string, string>
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
/**
|
| 50 |
* Interface for pane management backends.
|
| 51 |
* Abstracts operations for creating and managing terminal panes
|
|
|
|
| 81 |
*
|
| 82 |
* @param name - The teammate's name for display
|
| 83 |
* @param color - The color to use for the pane border/title
|
| 84 |
+
* @param spawnOptions - Optional command to run directly in the pane
|
| 85 |
+
* (TmuxBackend only; ITermBackend ignores this and falls back to send-keys)
|
| 86 |
* @returns The pane ID and whether this was the first teammate
|
| 87 |
*/
|
| 88 |
createTeammatePaneInSwarmView(
|
| 89 |
name: string,
|
| 90 |
color: AgentColorName,
|
| 91 |
+
spawnOptions?: PaneSpawnOptions,
|
| 92 |
): Promise<CreatePaneResult>
|
| 93 |
|
| 94 |
/**
|
src/utils/swarm/spawnUtils.ts
CHANGED
|
@@ -9,12 +9,35 @@ import {
|
|
| 9 |
getMainLoopModelOverride,
|
| 10 |
getSessionBypassPermissionsMode,
|
| 11 |
} from '../../bootstrap/state.js'
|
|
|
|
| 12 |
import { quote } from '../bash/shellQuote.js'
|
| 13 |
import { isInBundledMode } from '../bundledMode.js'
|
| 14 |
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
| 15 |
import { getTeammateModeFromSnapshot } from './backends/teammateModeSnapshot.js'
|
| 16 |
import { TEAMMATE_COMMAND_ENV_VAR } from './constants.js'
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
/**
|
| 19 |
* Gets the command to use for spawning teammate processes.
|
| 20 |
* Uses TEAMMATE_COMMAND_ENV_VAR if set, otherwise falls back to the
|
|
@@ -24,7 +47,7 @@ export function getTeammateCommand(): string {
|
|
| 24 |
if (process.env[TEAMMATE_COMMAND_ENV_VAR]) {
|
| 25 |
return process.env[TEAMMATE_COMMAND_ENV_VAR]
|
| 26 |
}
|
| 27 |
-
return isInBundledMode() ?
|
| 28 |
}
|
| 29 |
|
| 30 |
/**
|
|
@@ -141,19 +164,42 @@ const TEAMMATE_ENV_VARS = [
|
|
| 141 |
] as const
|
| 142 |
|
| 143 |
/**
|
| 144 |
-
* Builds
|
| 145 |
-
*
|
| 146 |
-
*
|
|
|
|
|
|
|
|
|
|
| 147 |
*/
|
| 148 |
-
export function
|
| 149 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
for (const key of TEAMMATE_ENV_VARS) {
|
| 152 |
const value = process.env[key]
|
| 153 |
if (value !== undefined && value !== '') {
|
| 154 |
-
|
| 155 |
}
|
| 156 |
}
|
| 157 |
|
| 158 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
}
|
|
|
|
| 9 |
getMainLoopModelOverride,
|
| 10 |
getSessionBypassPermissionsMode,
|
| 11 |
} from '../../bootstrap/state.js'
|
| 12 |
+
import { readlinkSync } from 'fs'
|
| 13 |
import { quote } from '../bash/shellQuote.js'
|
| 14 |
import { isInBundledMode } from '../bundledMode.js'
|
| 15 |
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
| 16 |
import { getTeammateModeFromSnapshot } from './backends/teammateModeSnapshot.js'
|
| 17 |
import { TEAMMATE_COMMAND_ENV_VAR } from './constants.js'
|
| 18 |
|
| 19 |
+
/**
|
| 20 |
+
* Resolves the real executable path for the current process.
|
| 21 |
+
*
|
| 22 |
+
* In bun-compiled mode, process.execPath may return a bunfs virtual path
|
| 23 |
+
* (e.g., /$bunfs/root/VersperClaw) that only exists within the bun runtime.
|
| 24 |
+
* When a tmux pane tries to execute this path in a regular shell, it fails
|
| 25 |
+
* with "No such file or directory".
|
| 26 |
+
*
|
| 27 |
+
* Fix: use /proc/self/exe on Linux to resolve the real binary path.
|
| 28 |
+
* On unsupported platforms, falls back to process.execPath.
|
| 29 |
+
*/
|
| 30 |
+
function resolveRealExecPath(): string {
|
| 31 |
+
try {
|
| 32 |
+
if (process.platform === 'linux') {
|
| 33 |
+
return readlinkSync('/proc/self/exe')
|
| 34 |
+
}
|
| 35 |
+
} catch {
|
| 36 |
+
// Not on Linux or /proc not available — fall through
|
| 37 |
+
}
|
| 38 |
+
return process.execPath
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
/**
|
| 42 |
* Gets the command to use for spawning teammate processes.
|
| 43 |
* Uses TEAMMATE_COMMAND_ENV_VAR if set, otherwise falls back to the
|
|
|
|
| 47 |
if (process.env[TEAMMATE_COMMAND_ENV_VAR]) {
|
| 48 |
return process.env[TEAMMATE_COMMAND_ENV_VAR]
|
| 49 |
}
|
| 50 |
+
return isInBundledMode() ? resolveRealExecPath() : process.argv[1]!
|
| 51 |
}
|
| 52 |
|
| 53 |
/**
|
|
|
|
| 164 |
] as const
|
| 165 |
|
| 166 |
/**
|
| 167 |
+
* Builds a map of environment variables for teammate spawn commands.
|
| 168 |
+
* Skips CLAUDECODE and CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS if already set
|
| 169 |
+
* in the current environment (e.g., from settings.json env or tmux global env).
|
| 170 |
+
*
|
| 171 |
+
* Returns a Record<string, string> suitable for tmux -e flags or manual
|
| 172 |
+
* env KEY=VALUE construction.
|
| 173 |
*/
|
| 174 |
+
export function buildInheritedEnvMap(): Record<string, string> {
|
| 175 |
+
const envMap: Record<string, string> = {}
|
| 176 |
+
|
| 177 |
+
// Only hardcode these if NOT already set in process env — the user may
|
| 178 |
+
// have them in ~/.claude/settings.json env, tmux set-environment, etc.
|
| 179 |
+
if (!process.env.CLAUDECODE) {
|
| 180 |
+
envMap.CLAUDECODE = '1'
|
| 181 |
+
}
|
| 182 |
+
if (!process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) {
|
| 183 |
+
envMap.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'
|
| 184 |
+
}
|
| 185 |
|
| 186 |
for (const key of TEAMMATE_ENV_VARS) {
|
| 187 |
const value = process.env[key]
|
| 188 |
if (value !== undefined && value !== '') {
|
| 189 |
+
envMap[key] = value
|
| 190 |
}
|
| 191 |
}
|
| 192 |
|
| 193 |
+
return envMap
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/**
|
| 197 |
+
* Builds the `env KEY=VALUE ...` string for teammate spawn commands.
|
| 198 |
+
* Delegates to buildInheritedEnvMap() for the values.
|
| 199 |
+
*/
|
| 200 |
+
export function buildInheritedEnvVars(): string {
|
| 201 |
+
const envMap = buildInheritedEnvMap()
|
| 202 |
+
return Object.entries(envMap)
|
| 203 |
+
.map(([key, value]) => `${key}=${quote([value])}`)
|
| 204 |
+
.join(' ')
|
| 205 |
}
|
src/utils/swarm/teamHelpers.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
|
|
| 10 |
import { gitExe } from '../git.js'
|
| 11 |
import { lazySchema } from '../lazySchema.js'
|
| 12 |
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
|
|
|
| 13 |
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
| 14 |
import { getTasksDir, notifyTasksUpdated } from '../tasks.js'
|
| 15 |
import { getAgentName, getTeamName, isTeammate } from '../teammate.js'
|
|
@@ -181,6 +182,52 @@ export async function writeTeamFileAsync(
|
|
| 181 |
await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
|
| 182 |
}
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
/**
|
| 185 |
* Removes a teammate from the team file by agent ID or name.
|
| 186 |
* Used by the leader when processing shutdown approvals.
|
|
|
|
| 10 |
import { gitExe } from '../git.js'
|
| 11 |
import { lazySchema } from '../lazySchema.js'
|
| 12 |
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
| 13 |
+
import * as lockfile from '../lockfile.js'
|
| 14 |
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
| 15 |
import { getTasksDir, notifyTasksUpdated } from '../tasks.js'
|
| 16 |
import { getAgentName, getTeamName, isTeammate } from '../teammate.js'
|
|
|
|
| 182 |
await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
|
| 183 |
}
|
| 184 |
|
| 185 |
+
/**
|
| 186 |
+
* Atomically read-modify-write a team file under a file lock.
|
| 187 |
+
* Use this for concurrent member updates such as teammate spawning.
|
| 188 |
+
* Ensures parallel spawn operations don't clobber each other's member registrations.
|
| 189 |
+
*/
|
| 190 |
+
export async function mutateTeamFileAsync(
|
| 191 |
+
teamName: string,
|
| 192 |
+
mutator: (teamFile: TeamFile) => TeamFile | void,
|
| 193 |
+
): Promise<TeamFile> {
|
| 194 |
+
const teamDir = getTeamDir(teamName)
|
| 195 |
+
const teamFilePath = getTeamFilePath(teamName)
|
| 196 |
+
const lockFilePath = `${teamFilePath}.lock`
|
| 197 |
+
|
| 198 |
+
await mkdir(teamDir, { recursive: true })
|
| 199 |
+
// Ensure the file exists (create if not, ignore if already exists)
|
| 200 |
+
try {
|
| 201 |
+
await writeFile(teamFilePath, '{}', { encoding: 'utf-8', flag: 'wx' })
|
| 202 |
+
} catch (e) {
|
| 203 |
+
if (getErrnoCode(e) !== 'EEXIST') {
|
| 204 |
+
throw e
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const release = await lockfile.lock(teamFilePath, {
|
| 209 |
+
lockfilePath: lockFilePath,
|
| 210 |
+
retries: {
|
| 211 |
+
retries: 10,
|
| 212 |
+
minTimeout: 5,
|
| 213 |
+
maxTimeout: 100,
|
| 214 |
+
},
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
try {
|
| 218 |
+
const current = await readTeamFileAsync(teamName)
|
| 219 |
+
if (!current || !Array.isArray(current.members)) {
|
| 220 |
+
throw new Error(`Team "${teamName}" does not exist`)
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
const next = mutator(current) ?? current
|
| 224 |
+
await writeFile(teamFilePath, jsonStringify(next, null, 2))
|
| 225 |
+
return next
|
| 226 |
+
} finally {
|
| 227 |
+
await release()
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
/**
|
| 232 |
* Removes a teammate from the team file by agent ID or name.
|
| 233 |
* Used by the leader when processing shutdown approvals.
|
src/utils/swarm/teammateLayoutManager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import type { AgentColorName } from '../../tools/AgentTool/agentColorManager.js'
|
| 2 |
import { AGENT_COLORS } from '../../tools/AgentTool/agentColorManager.js'
|
| 3 |
import { detectAndGetBackend } from './backends/registry.js'
|
| 4 |
-
import type { PaneBackend } from './backends/types.js'
|
| 5 |
|
| 6 |
// Track color assignments for teammates (persisted per session)
|
| 7 |
const teammateColorAssignments = new Map<string, AgentColorName>()
|
|
@@ -76,9 +76,10 @@ export async function isInsideTmux(): Promise<boolean> {
|
|
| 76 |
export async function createTeammatePaneInSwarmView(
|
| 77 |
teammateName: string,
|
| 78 |
teammateColor: AgentColorName,
|
|
|
|
| 79 |
): Promise<{ paneId: string; isFirstTeammate: boolean }> {
|
| 80 |
const backend = await getBackend()
|
| 81 |
-
return backend.createTeammatePaneInSwarmView(teammateName, teammateColor)
|
| 82 |
}
|
| 83 |
|
| 84 |
/**
|
|
|
|
| 1 |
import type { AgentColorName } from '../../tools/AgentTool/agentColorManager.js'
|
| 2 |
import { AGENT_COLORS } from '../../tools/AgentTool/agentColorManager.js'
|
| 3 |
import { detectAndGetBackend } from './backends/registry.js'
|
| 4 |
+
import type { PaneBackend, PaneSpawnOptions } from './backends/types.js'
|
| 5 |
|
| 6 |
// Track color assignments for teammates (persisted per session)
|
| 7 |
const teammateColorAssignments = new Map<string, AgentColorName>()
|
|
|
|
| 76 |
export async function createTeammatePaneInSwarmView(
|
| 77 |
teammateName: string,
|
| 78 |
teammateColor: AgentColorName,
|
| 79 |
+
spawnOptions?: PaneSpawnOptions,
|
| 80 |
): Promise<{ paneId: string; isFirstTeammate: boolean }> {
|
| 81 |
const backend = await getBackend()
|
| 82 |
+
return backend.createTeammatePaneInSwarmView(teammateName, teammateColor, spawnOptions)
|
| 83 |
}
|
| 84 |
|
| 85 |
/**
|