chenbhao commited on
Commit
9086e0a
·
1 Parent(s): e6f7e9a

feat: Agent Team

Browse files
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
- "teammateMode": "tmux"
 
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

  • SHA256: 79c884b53f14606249f9b0bea9b91ca0cbc0a59a77be417143eab95e534be436
  • Pointer size: 132 Bytes
  • Size of remote file: 5.85 MB
assets/agentmate2.png ADDED

Git LFS Details

  • SHA256: 30aece739014afb791e57fdc153ff4466b530579af954458c49cff97bd702c4d
  • Pointer size: 132 Bytes
  • Size of remote file: 6.03 MB
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() ? process.execPath : process.argv[1]!
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
- // Note: We spawn without a prompt - initial instructions are sent via mailbox
 
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
- // Propagate env vars that teammates need but may not inherit from tmux split-window shells.
438
- // Includes CLAUDECODE, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, and API provider vars.
439
- const envStr = buildInheritedEnvVars()
440
- const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}`
 
 
 
 
 
 
 
 
 
 
 
 
 
441
 
442
- // Send the command to the new pane
443
- // Use swarm socket when running outside tmux (external swarm session)
444
- await sendCommandToPane(paneId, spawnCommand, !insideTmux)
 
 
 
 
 
 
 
 
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
- const teamFile = await readTeamFileAsync(teamName)
490
- if (!teamFile) {
491
- throw new Error(
492
- `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
493
- )
494
- }
495
- teamFile.members.push({
496
- agentId: teammateId,
497
- name: sanitizedName,
498
- agentType: agent_type,
499
- model,
500
- prompt,
501
- color: teammateColor,
502
- planModeRequired: plan_mode_required,
503
- joinedAt: Date.now(),
504
- tmuxPaneId: paneId,
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
- const teamFile = await readTeamFileAsync(teamName)
704
- if (!teamFile) {
705
- throw new Error(
706
- `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
707
- )
708
- }
709
- teamFile.members.push({
710
- agentId: teammateId,
711
- name: sanitizedName,
712
- agentType: agent_type,
713
- model,
714
- prompt,
715
- color: teammateColor,
716
- planModeRequired: plan_mode_required,
717
- joinedAt: Date.now(),
718
- tmuxPaneId: paneId,
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
- const teamFile = await readTeamFileAsync(teamName)
990
- if (!teamFile) {
991
- throw new Error(
992
- `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
993
- )
994
- }
995
- teamFile.members.push({
996
- agentId: teammateId,
997
- name: sanitizedName,
998
- agentType: agent_type,
999
- model,
1000
- prompt,
1001
- color: teammateColor,
1002
- planModeRequired: plan_mode_required,
1003
- joinedAt: Date.now(),
1004
- tmuxPaneId: 'in-process',
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
- * Ant builds: always enabled.
20
- * External builds require both:
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
- // Ant: always on
26
- if (process.env.USER_TYPE === 'ant') {
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
- // Wait for shell to initialize before returning, so commands can be sent immediately
627
- await waitForPaneShellReady()
 
 
 
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
- // Wait for shell to initialize before returning, so commands can be sent immediately
699
- await waitForPaneShellReady()
 
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() ? process.execPath : process.argv[1]!
28
  }
29
 
30
  /**
@@ -141,19 +164,42 @@ const TEAMMATE_ENV_VARS = [
141
  ] as const
142
 
143
  /**
144
- * Builds the `env KEY=VALUE ...` string for teammate spawn commands.
145
- * Always includes CLAUDECODE=1 and CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1,
146
- * plus any provider/config env vars that are set in the current process.
 
 
 
147
  */
148
- export function buildInheritedEnvVars(): string {
149
- const envVars = ['CLAUDECODE=1', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1']
 
 
 
 
 
 
 
 
 
150
 
151
  for (const key of TEAMMATE_ENV_VARS) {
152
  const value = process.env[key]
153
  if (value !== undefined && value !== '') {
154
- envVars.push(`${key}=${quote([value])}`)
155
  }
156
  }
157
 
158
- return envVars.join(' ')
 
 
 
 
 
 
 
 
 
 
 
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
  /**