@xagent-ai/cli 1.3.6 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +9 -0
  2. package/README_CN.md +9 -0
  3. package/dist/cli.js +26 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/mcp.d.ts +8 -1
  6. package/dist/mcp.d.ts.map +1 -1
  7. package/dist/mcp.js +53 -20
  8. package/dist/mcp.js.map +1 -1
  9. package/dist/sdk-output-adapter.d.ts +79 -0
  10. package/dist/sdk-output-adapter.d.ts.map +1 -1
  11. package/dist/sdk-output-adapter.js +118 -0
  12. package/dist/sdk-output-adapter.js.map +1 -1
  13. package/dist/session.d.ts +88 -1
  14. package/dist/session.d.ts.map +1 -1
  15. package/dist/session.js +351 -5
  16. package/dist/session.js.map +1 -1
  17. package/dist/slash-commands.d.ts.map +1 -1
  18. package/dist/slash-commands.js +3 -5
  19. package/dist/slash-commands.js.map +1 -1
  20. package/dist/smart-approval.d.ts.map +1 -1
  21. package/dist/smart-approval.js +1 -0
  22. package/dist/smart-approval.js.map +1 -1
  23. package/dist/system-prompt-generator.d.ts +15 -1
  24. package/dist/system-prompt-generator.d.ts.map +1 -1
  25. package/dist/system-prompt-generator.js +36 -27
  26. package/dist/system-prompt-generator.js.map +1 -1
  27. package/dist/team-manager/index.d.ts +6 -0
  28. package/dist/team-manager/index.d.ts.map +1 -0
  29. package/dist/team-manager/index.js +6 -0
  30. package/dist/team-manager/index.js.map +1 -0
  31. package/dist/team-manager/message-broker.d.ts +128 -0
  32. package/dist/team-manager/message-broker.d.ts.map +1 -0
  33. package/dist/team-manager/message-broker.js +638 -0
  34. package/dist/team-manager/message-broker.js.map +1 -0
  35. package/dist/team-manager/team-coordinator.d.ts +45 -0
  36. package/dist/team-manager/team-coordinator.d.ts.map +1 -0
  37. package/dist/team-manager/team-coordinator.js +887 -0
  38. package/dist/team-manager/team-coordinator.js.map +1 -0
  39. package/dist/team-manager/team-store.d.ts +49 -0
  40. package/dist/team-manager/team-store.d.ts.map +1 -0
  41. package/dist/team-manager/team-store.js +436 -0
  42. package/dist/team-manager/team-store.js.map +1 -0
  43. package/dist/team-manager/teammate-spawner.d.ts +86 -0
  44. package/dist/team-manager/teammate-spawner.d.ts.map +1 -0
  45. package/dist/team-manager/teammate-spawner.js +605 -0
  46. package/dist/team-manager/teammate-spawner.js.map +1 -0
  47. package/dist/team-manager/types.d.ts +164 -0
  48. package/dist/team-manager/types.d.ts.map +1 -0
  49. package/dist/team-manager/types.js +27 -0
  50. package/dist/team-manager/types.js.map +1 -0
  51. package/dist/tools.d.ts +41 -1
  52. package/dist/tools.d.ts.map +1 -1
  53. package/dist/tools.js +288 -32
  54. package/dist/tools.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/cli.ts +20 -0
  57. package/src/mcp.ts +64 -25
  58. package/src/sdk-output-adapter.ts +177 -0
  59. package/src/session.ts +423 -15
  60. package/src/slash-commands.ts +3 -7
  61. package/src/smart-approval.ts +1 -0
  62. package/src/system-prompt-generator.ts +59 -26
  63. package/src/team-manager/index.ts +5 -0
  64. package/src/team-manager/message-broker.ts +751 -0
  65. package/src/team-manager/team-coordinator.ts +1117 -0
  66. package/src/team-manager/team-store.ts +558 -0
  67. package/src/team-manager/teammate-spawner.ts +800 -0
  68. package/src/team-manager/types.ts +206 -0
  69. package/src/tools.ts +316 -33
@@ -0,0 +1,800 @@
1
+ import { spawn, ChildProcess, execSync } from 'child_process';
2
+ import crypto from 'crypto';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { TeamStore, getTeamStore } from './team-store.js';
7
+ import { TeamMember, DisplayMode, TeammateConfig } from './types.js';
8
+ import { colors, icons } from '../theme.js';
9
+
10
+ const generateId = () => crypto.randomUUID();
11
+
12
+ // Graceful shutdown timeout in milliseconds
13
+ const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 30000;
14
+
15
+ /**
16
+ * Process tracking information for external spawns (tmux/iTerm2)
17
+ */
18
+ interface ExternalProcessInfo {
19
+ type: 'tmux' | 'iterm2';
20
+ paneId?: string;
21
+ windowId?: string;
22
+ sessionId?: string;
23
+ startedAt: number;
24
+ }
25
+
26
+ /**
27
+ * Extended process tracking that includes both ChildProcess and external process info
28
+ */
29
+ interface TrackedProcess {
30
+ childProcess?: ChildProcess;
31
+ external?: ExternalProcessInfo;
32
+ memberId: string;
33
+ teamId: string;
34
+ config: TeammateConfig;
35
+ }
36
+
37
+ function getXagentCommand(): string {
38
+ const currentFile = fileURLToPath(import.meta.url);
39
+ const projectRoot = path.resolve(path.dirname(currentFile), '../..');
40
+ const cliPath = path.join(projectRoot, 'dist', 'cli.js');
41
+ return cliPath;
42
+ }
43
+
44
+ /**
45
+ * Check if CLI exists, throw descriptive error if not
46
+ */
47
+ function validateCliPath(): void {
48
+ const cliPath = getXagentCommand();
49
+
50
+ if (!fs.existsSync(cliPath)) {
51
+ throw new Error(
52
+ `xAgent CLI not found at ${cliPath}. ` +
53
+ `Please ensure the project is built (run 'npm run build' or 'tsc').`
54
+ );
55
+ }
56
+ }
57
+
58
+ export class TeammateSpawner {
59
+ private store: TeamStore;
60
+ private activeProcesses: Map<string, TrackedProcess> = new Map();
61
+ private tmuxSessionName: string | null = null;
62
+ private warnedAboutWindows = false;
63
+
64
+ // Output filtering configuration
65
+ private static readonly IGNORED_PATTERNS = [
66
+ /^╔[═]+╗$/, // Banner top border
67
+ /^║[^║]+║$/, // Banner content
68
+ /^╚[═]+╝$/, // Banner bottom border
69
+ /^─+$/, // Separator lines
70
+ /^✨ Welcome to XAGENT CLI!$/, // Welcome message
71
+ /^Type \/help to see available commands$/, // Help hint
72
+ /^ℹ Current Mode:$/, // Mode info
73
+ /^\s*✨\s*\w+/, // Mode indicator
74
+ /^\s*🧠 (Local|Remote) Models:$/, // Model info header
75
+ /^\s*→ (LLM|VLM):/, // Model info lines
76
+ /^📝 Registering MCP server/, // MCP registration
77
+ /^🧠 Connecting to \d+ MCP server/, // MCP connection
78
+ /^Connecting to MCP Server/, // MCP connection detail
79
+ /^Loaded \d+ tools from MCP Server/, // MCP tools loaded
80
+ /^MCP Server connected$/, // MCP connected
81
+ /^✓ \d+\/\d+ MCP server/, // MCP summary
82
+ /^\[MCP\] Registered \d+ tool/, // MCP tools registered
83
+ /^✔ Initialization complete$/, // Init complete
84
+ ];
85
+
86
+ // Patterns that indicate important output (should always show)
87
+ private static readonly IMPORTANT_PATTERNS = [
88
+ /✅|✓|✔/, // Success markers
89
+ /❌|✗|✖/, // Error markers
90
+ /⚠|⚠️/, // Warning markers
91
+ /🔍|🔎/, // Search/action markers
92
+ /📝|📄/, // Document markers
93
+ /Tool|tool/, // Tool execution
94
+ /Error|error|ERROR/, // Errors
95
+ /Task completed|completed/, // Task completion
96
+ /Found \d+/, // Search results
97
+ ];
98
+
99
+ constructor(store?: TeamStore) {
100
+ this.store = store || getTeamStore();
101
+ // Validate CLI on construction
102
+ validateCliPath();
103
+ }
104
+
105
+ /**
106
+ * Check if a line should be filtered out (not displayed)
107
+ */
108
+ private shouldFilterLine(line: string): boolean {
109
+ const trimmed = line.trim();
110
+ if (!trimmed) return true;
111
+
112
+ // Check if line matches any ignored pattern
113
+ for (const pattern of TeammateSpawner.IGNORED_PATTERNS) {
114
+ if (pattern.test(trimmed)) {
115
+ return true;
116
+ }
117
+ }
118
+
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * Format a teammate output line with proper styling
124
+ */
125
+ private formatOutputLine(memberName: string, line: string): string {
126
+ const trimmed = line.trim();
127
+
128
+ // Use compact prefix format: [name] content
129
+ const prefix = colors.primary(`[${memberName}]`);
130
+
131
+ return `${prefix} ${trimmed}`;
132
+ }
133
+
134
+ /**
135
+ * Format an error line with error styling
136
+ */
137
+ private formatErrorLine(memberName: string, line: string): string {
138
+ const trimmed = line.trim();
139
+ const prefix = colors.error(`[${memberName}]`);
140
+ return `${prefix} ${trimmed}`;
141
+ }
142
+
143
+ private isTmuxAvailable(): boolean {
144
+ if (process.platform === 'win32') {
145
+ // this.warnWindowsUser('tmux');
146
+ return false;
147
+ }
148
+ try {
149
+ execSync('which tmux', { stdio: 'ignore' });
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ private isInsideTmux(): boolean {
157
+ return !!process.env.TMUX;
158
+ }
159
+
160
+ private isIterm2Available(): boolean {
161
+ if (process.platform !== 'darwin') {
162
+ if (process.platform === 'win32') {
163
+ // this.warnWindowsUser('iTerm2');
164
+ }
165
+ return false;
166
+ }
167
+ try {
168
+ execSync('which it2', { stdio: 'ignore' });
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ // /**
176
+ // * Display Windows compatibility warning (only once per session)
177
+ // */
178
+ // private warnWindowsUser(feature: string): void {
179
+ // if (this.warnedAboutWindows) return;
180
+ // this.warnedAboutWindows = true;
181
+
182
+ // console.log(colors.warning(
183
+ // `\n[Windows] ${feature} is not available on Windows. ` +
184
+ // `Using 'in-process' mode for parallel agents.\n` +
185
+ // `Note: In-process mode runs teammates in the same terminal. ` +
186
+ // `For true terminal multiplexing on Windows, consider using Windows Terminal with multiple tabs.\n`
187
+ // ));
188
+ // }
189
+
190
+ async spawnTeammate(
191
+ teamId: string,
192
+ config: TeammateConfig,
193
+ workDir: string,
194
+ displayMode: DisplayMode = 'auto',
195
+ brokerPort?: number,
196
+ initialTaskId?: string,
197
+ leadId?: string
198
+ ): Promise<TeamMember> {
199
+ // Validate required fields with clear error messages
200
+ if (!config.name || config.name.trim() === '') {
201
+ throw new Error(
202
+ 'Teammate name is required. ' +
203
+ 'Please provide a valid "name" field in the teammates config. ' +
204
+ 'Example: { name: "developer", role: "coder", prompt: "..." }'
205
+ );
206
+ }
207
+
208
+ if (!config.role || config.role.trim() === '') {
209
+ throw new Error(
210
+ `Teammate role is required for "${config.name}". ` +
211
+ 'Please provide a valid "role" field in the teammates config.'
212
+ );
213
+ }
214
+
215
+ if (!config.prompt || config.prompt.trim() === '') {
216
+ throw new Error(
217
+ `Teammate prompt is required for "${config.name}". ` +
218
+ 'Please provide a valid "prompt" field in the teammates config.'
219
+ );
220
+ }
221
+
222
+ const memberId = generateId();
223
+ const memberName = config.name.trim();
224
+
225
+ const member: Omit<TeamMember, 'role' | 'permissions'> = {
226
+ memberId,
227
+ name: memberName,
228
+ memberRole: config.role,
229
+ model: config.model,
230
+ status: 'spawning',
231
+ displayMode: 'in-process'
232
+ };
233
+
234
+ const savedMember = await this.store.addMember(teamId, member);
235
+
236
+ const actualMode = this.resolveDisplayMode(displayMode);
237
+
238
+ let processInfo: { processId: number; external?: ExternalProcessInfo };
239
+
240
+ try {
241
+ switch (actualMode) {
242
+ case 'tmux':
243
+ processInfo = await this.spawnWithTmux(teamId, memberId, config, workDir, brokerPort, initialTaskId, leadId);
244
+ break;
245
+ case 'iterm2':
246
+ processInfo = await this.spawnWithIterm2(teamId, memberId, config, workDir, brokerPort, initialTaskId, leadId);
247
+ break;
248
+ case 'in-process':
249
+ default:
250
+ processInfo = await this.spawnWithNode(teamId, memberId, config, workDir, brokerPort, initialTaskId, leadId);
251
+ break;
252
+ }
253
+ } catch (error: any) {
254
+ // Update member status on failure
255
+ await this.store.updateMember(teamId, memberId, {
256
+ status: 'shutdown',
257
+ lastActivity: Date.now()
258
+ });
259
+
260
+ // Re-throw with more context
261
+ throw new Error(
262
+ `Failed to spawn teammate "${config.name}" in ${actualMode} mode: ${error.message}. ` +
263
+ `Consider using a different displayMode or check system requirements.`
264
+ );
265
+ }
266
+
267
+ savedMember.status = 'active';
268
+ savedMember.processId = processInfo.processId;
269
+ savedMember.displayMode = actualMode;
270
+ await this.store.updateMember(teamId, memberId, {
271
+ status: 'active',
272
+ processId: savedMember.processId,
273
+ displayMode: actualMode
274
+ });
275
+
276
+ return savedMember;
277
+ }
278
+
279
+ private resolveDisplayMode(mode: DisplayMode): 'tmux' | 'iterm2' | 'in-process' {
280
+ if (mode === 'in-process') return 'in-process';
281
+
282
+ if (mode === 'tmux' || mode === 'auto') {
283
+ if (this.isTmuxAvailable()) {
284
+ return 'tmux';
285
+ }
286
+ }
287
+
288
+ if (mode === 'iterm2' || mode === 'auto') {
289
+ if (this.isIterm2Available()) {
290
+ return 'iterm2';
291
+ }
292
+ }
293
+
294
+ return 'in-process';
295
+ }
296
+
297
+ private async spawnWithTmux(
298
+ teamId: string,
299
+ memberId: string,
300
+ config: TeammateConfig,
301
+ workDir: string,
302
+ brokerPort?: number,
303
+ initialTaskId?: string,
304
+ leadId?: string
305
+ ): Promise<{ processId: number; external?: ExternalProcessInfo }> {
306
+ const paneName = `xagent-${config.name.replace(/\s+/g, '-')}`;
307
+ const args = this.buildCommandArgs(teamId, memberId, config, brokerPort, false, undefined, initialTaskId, leadId);
308
+ const cliPath = getXagentCommand();
309
+ const cmd = `node "${cliPath}" ${args.join(' ')}`;
310
+
311
+ let paneId: string | undefined;
312
+ let windowId: string | undefined;
313
+ const sessionId = this.tmuxSessionName || teamId;
314
+
315
+ try {
316
+ if (this.isInsideTmux()) {
317
+ // Split current window and capture the new pane ID
318
+ execSync(`tmux split-window -v -p 50 -P -F '#{pane_id}'`, { cwd: workDir, stdio: ['ignore', 'pipe', 'ignore'] });
319
+
320
+ // Get the pane ID of the new pane
321
+ paneId = execSync(`tmux display-message -p '#{pane_id}'`, { cwd: workDir, encoding: 'utf-8' }).trim();
322
+
323
+ execSync(`tmux send-keys -t :.+ '${cmd}' Enter`, { cwd: workDir, stdio: 'ignore' });
324
+ execSync(`tmux select-pane -T "${paneName}"`, { cwd: workDir, stdio: 'ignore' });
325
+ } else {
326
+ // Create or attach to session
327
+ try {
328
+ execSync(`tmux new-session -d -s ${teamId} -x 200 -y 50 -P -F '#{session_id}'`, { cwd: workDir, stdio: 'ignore' });
329
+ this.tmuxSessionName = teamId;
330
+ } catch {
331
+ // Session might already exist, that's okay
332
+ }
333
+
334
+ // Create new window and get its ID
335
+ windowId = execSync(
336
+ `tmux new-window -t ${teamId} -n "${paneName}" -P -F '#{window_id}' "${cmd}"`,
337
+ { cwd: workDir, encoding: 'utf-8' }
338
+ ).trim();
339
+
340
+ paneId = execSync(`tmux display-message -t ${windowId} -p '#{pane_id}'`, { encoding: 'utf-8' }).trim();
341
+ }
342
+
343
+ // Track the external process
344
+ const external: ExternalProcessInfo = {
345
+ type: 'tmux',
346
+ paneId,
347
+ windowId,
348
+ sessionId,
349
+ startedAt: Date.now()
350
+ };
351
+
352
+ // Store tracking info
353
+ this.activeProcesses.set(memberId, {
354
+ memberId,
355
+ teamId,
356
+ config,
357
+ external
358
+ });
359
+
360
+ // Generate a unique process ID for tracking (using hash of pane info)
361
+ const processId = this.generateProcessId('tmux', paneId || windowId || sessionId);
362
+
363
+ return { processId, external };
364
+ } catch (error: any) {
365
+ console.log(colors.warning(`tmux spawn failed: ${error.message}, falling back to in-process`));
366
+ return this.spawnWithNode(teamId, memberId, config, workDir, brokerPort, initialTaskId, leadId);
367
+ }
368
+ }
369
+
370
+ private async spawnWithIterm2(
371
+ teamId: string,
372
+ memberId: string,
373
+ config: TeammateConfig,
374
+ workDir: string,
375
+ brokerPort?: number,
376
+ initialTaskId?: string,
377
+ leadId?: string
378
+ ): Promise<{ processId: number; external?: ExternalProcessInfo }> {
379
+ const args = this.buildCommandArgs(teamId, memberId, config, brokerPort, false, undefined, initialTaskId, leadId);
380
+ const cliPath = getXagentCommand();
381
+ const cmd = `node "${cliPath}" ${args.join(' ')}`;
382
+
383
+ try {
384
+ // Split pane and get session ID
385
+ const sessionId = execSync(`it2 splitpane -v --session-id`, {
386
+ cwd: workDir,
387
+ encoding: 'utf-8'
388
+ }).trim();
389
+
390
+ execSync(`it2 send "${cmd}"`, { cwd: workDir, stdio: 'ignore' });
391
+
392
+ const external: ExternalProcessInfo = {
393
+ type: 'iterm2',
394
+ sessionId,
395
+ startedAt: Date.now()
396
+ };
397
+
398
+ this.activeProcesses.set(memberId, {
399
+ memberId,
400
+ teamId,
401
+ config,
402
+ external
403
+ });
404
+
405
+ const processId = this.generateProcessId('iterm2', sessionId);
406
+
407
+ return { processId, external };
408
+ } catch (error: any) {
409
+ console.log(colors.warning(`iTerm2 spawn failed: ${error.message}, falling back to in-process`));
410
+ return this.spawnWithNode(teamId, memberId, config, workDir, brokerPort, initialTaskId, leadId);
411
+ }
412
+ }
413
+
414
+ private async spawnWithNode(
415
+ teamId: string,
416
+ memberId: string,
417
+ config: TeammateConfig,
418
+ workDir: string,
419
+ brokerPort?: number,
420
+ initialTaskId?: string,
421
+ leadId?: string
422
+ ): Promise<{ processId: number; external?: ExternalProcessInfo }> {
423
+ const args = this.buildCommandArgs(teamId, memberId, config, brokerPort, false, undefined, initialTaskId, leadId);
424
+ const cliPath = getXagentCommand();
425
+
426
+ const env: Record<string, string> = {
427
+ ...process.env as Record<string, string>,
428
+ XAGENT_TEAM_MODE: 'true',
429
+ XAGENT_TEAM_ID: teamId,
430
+ XAGENT_MEMBER_ID: memberId,
431
+ XAGENT_MEMBER_NAME: config.name,
432
+ XAGENT_SPAWN_PROMPT: config.prompt,
433
+ };
434
+
435
+ if (brokerPort) {
436
+ env.XAGENT_BROKER_PORT = String(brokerPort);
437
+ }
438
+
439
+ if (initialTaskId) {
440
+ env.XAGENT_INITIAL_TASK_ID = initialTaskId;
441
+ }
442
+
443
+ if (leadId) {
444
+ env.XAGENT_LEAD_ID = leadId;
445
+ }
446
+
447
+ let childProcess: ChildProcess;
448
+ try {
449
+ childProcess = spawn('node', [cliPath, ...args], {
450
+ cwd: workDir,
451
+ stdio: ['pipe', 'pipe', 'pipe'],
452
+ env
453
+ });
454
+ } catch (error: any) {
455
+ throw new Error(
456
+ `Failed to spawn Node.js process: ${error.message}. ` +
457
+ `Ensure Node.js is installed and the CLI is built.`
458
+ );
459
+ }
460
+
461
+ // Track the process
462
+ this.activeProcesses.set(memberId, {
463
+ childProcess,
464
+ memberId,
465
+ teamId,
466
+ config
467
+ });
468
+
469
+ childProcess.stdout?.on('data', (data: Buffer) => {
470
+ const lines = data.toString().split('\n');
471
+ for (const line of lines) {
472
+ if (!this.shouldFilterLine(line)) {
473
+ console.log(this.formatOutputLine(config.name, line));
474
+ }
475
+ }
476
+ });
477
+
478
+ childProcess.stderr?.on('data', (data: Buffer) => {
479
+ const lines = data.toString().split('\n');
480
+ for (const line of lines) {
481
+ if (line.trim()) {
482
+ console.error(this.formatErrorLine(config.name, line));
483
+ }
484
+ }
485
+ });
486
+
487
+ childProcess.on('exit', async (code) => {
488
+ this.activeProcesses.delete(memberId);
489
+ await this.store.updateMember(teamId, memberId, {
490
+ status: 'shutdown',
491
+ lastActivity: Date.now()
492
+ });
493
+ console.log(colors.textMuted(`[${config.name}] ${icons.arrow} exited with code ${code}`));
494
+ });
495
+
496
+ childProcess.on('error', (error) => {
497
+ console.error(colors.error(`[${config.name}] SPAWN ERROR: ${error.message}`));
498
+ this.activeProcesses.delete(memberId);
499
+ });
500
+
501
+ return { processId: childProcess.pid || Date.now() };
502
+ }
503
+
504
+ /**
505
+ * Generate a deterministic process ID for external processes
506
+ */
507
+ private generateProcessId(type: string, identifier: string): number {
508
+ // Create a hash from type and identifier to get a consistent number
509
+ const hash = crypto.createHash('md5').update(`${type}:${identifier}`).digest();
510
+ return hash.readUInt32BE(0);
511
+ }
512
+
513
+ private buildCommandArgs(
514
+ teamId: string,
515
+ memberId: string,
516
+ config: TeammateConfig,
517
+ brokerPort?: number,
518
+ isLead: boolean = false,
519
+ isSdk?: boolean,
520
+ initialTaskId?: string,
521
+ leadId?: string
522
+ ): string[] {
523
+ const args = [
524
+ 'start',
525
+ '--team-mode',
526
+ '--team-id', teamId,
527
+ '--member-id', memberId,
528
+ '--member-name', config.name,
529
+ ];
530
+
531
+ const useSdk = isSdk ?? (process.env.XAGENT_SDK === 'true');
532
+ if (useSdk) {
533
+ args.push('--sdk');
534
+ }
535
+
536
+ if (isLead) {
537
+ args.push('--is-team-lead');
538
+ }
539
+
540
+ if (config.model) {
541
+ args.push('--model', config.model);
542
+ }
543
+
544
+ if (brokerPort) {
545
+ args.push('--broker-port', String(brokerPort));
546
+ }
547
+
548
+ if (config.prompt) {
549
+ args.push('--initial-prompt', config.prompt);
550
+ }
551
+
552
+ if (initialTaskId) {
553
+ args.push('--initial-task-id', initialTaskId);
554
+ }
555
+
556
+ if (leadId) {
557
+ args.push('--lead-id', leadId);
558
+ }
559
+
560
+ return args;
561
+ }
562
+
563
+ /**
564
+ * Gracefully shutdown a teammate with a timeout for task completion.
565
+ */
566
+ async shutdownTeammate(
567
+ teamId: string,
568
+ memberId: string,
569
+ options?: { force?: boolean; timeout?: number }
570
+ ): Promise<{ success: boolean; reason?: string }> {
571
+ const { force = false, timeout = GRACEFUL_SHUTDOWN_TIMEOUT_MS } = options || {};
572
+
573
+ const team = await this.store.getTeam(teamId);
574
+ if (!team) {
575
+ return { success: false, reason: `Team ${teamId} not found` };
576
+ }
577
+
578
+ const member = team.members.find(m => m.memberId === memberId);
579
+ if (!member) {
580
+ return { success: false, reason: `Member ${memberId} not found` };
581
+ }
582
+
583
+ if (member.status !== 'active') {
584
+ return { success: true, reason: `Member already ${member.status}` };
585
+ }
586
+
587
+ const tracked = this.activeProcesses.get(memberId);
588
+
589
+ // Handle tmux processes
590
+ if (member.displayMode === 'tmux' && tracked?.external) {
591
+ return this.shutdownTmuxProcess(teamId, memberId, tracked.external, force, timeout);
592
+ }
593
+
594
+ // Handle iTerm2 processes
595
+ if (member.displayMode === 'iterm2' && tracked?.external) {
596
+ return this.shutdownIterm2Process(teamId, memberId, tracked.external, force, timeout);
597
+ }
598
+
599
+ // Handle in-process (ChildProcess)
600
+ if (tracked?.childProcess) {
601
+ return this.shutdownChildProcess(teamId, memberId, tracked.childProcess, force, timeout);
602
+ }
603
+
604
+ // No tracked process, just update status
605
+ await this.store.updateMember(teamId, memberId, { status: 'shutdown' });
606
+ return { success: true, reason: 'No active process found' };
607
+ }
608
+
609
+ private async shutdownTmuxProcess(
610
+ teamId: string,
611
+ memberId: string,
612
+ external: ExternalProcessInfo,
613
+ force: boolean,
614
+ timeout: number
615
+ ): Promise<{ success: boolean; reason?: string }> {
616
+ try {
617
+ if (!force) {
618
+ // Send graceful shutdown signal via message
619
+ // The teammate should handle this and exit cleanly
620
+ console.log(colors.info(`[Shutdown] Sending graceful shutdown signal to tmux pane...`));
621
+
622
+ // Try to send Ctrl+C to the pane for graceful shutdown
623
+ if (external.paneId) {
624
+ execSync(`tmux send-keys -t ${external.paneId} C-c`, { stdio: 'ignore' });
625
+
626
+ // Wait for process to exit or timeout
627
+ const startTime = Date.now();
628
+ while (Date.now() - startTime < timeout) {
629
+ try {
630
+ // Check if pane still exists
631
+ execSync(`tmux list-panes -t ${external.paneId}`, { stdio: 'ignore' });
632
+ await new Promise(resolve => setTimeout(resolve, 500));
633
+ } catch {
634
+ // Pane no longer exists
635
+ this.activeProcesses.delete(memberId);
636
+ await this.store.updateMember(teamId, memberId, { status: 'shutdown' });
637
+ return { success: true, reason: 'Graceful shutdown completed' };
638
+ }
639
+ }
640
+ }
641
+ }
642
+
643
+ // Force kill the pane
644
+ if (external.paneId) {
645
+ execSync(`tmux kill-pane -t ${external.paneId}`, { stdio: 'ignore' });
646
+ } else if (external.windowId && this.tmuxSessionName) {
647
+ execSync(`tmux kill-window -t ${this.tmuxSessionName}:${external.windowId}`, { stdio: 'ignore' });
648
+ }
649
+
650
+ this.activeProcesses.delete(memberId);
651
+ await this.store.updateMember(teamId, memberId, { status: 'shutdown' });
652
+ return { success: true, reason: force ? 'Force killed' : 'Timeout, force killed' };
653
+ } catch (error: any) {
654
+ // Pane might already be closed
655
+ this.activeProcesses.delete(memberId);
656
+ await this.store.updateMember(teamId, memberId, { status: 'shutdown' });
657
+ return { success: true, reason: `Process cleanup: ${error.message}` };
658
+ }
659
+ }
660
+
661
+ private async shutdownIterm2Process(
662
+ teamId: string,
663
+ memberId: string,
664
+ external: ExternalProcessInfo,
665
+ force: boolean,
666
+ timeout: number
667
+ ): Promise<{ success: boolean; reason?: string }> {
668
+ try {
669
+ if (!force && external.sessionId) {
670
+ // iTerm2 doesn't have great graceful shutdown support
671
+ // We'll try sending Ctrl+C via the session
672
+ console.log(colors.info(`[Shutdown] Sending graceful shutdown signal to iTerm2 session...`));
673
+ execSync(`it2 send --session ${external.sessionId} \\x03`, { stdio: 'ignore' });
674
+
675
+ // Wait briefly for graceful shutdown
676
+ await new Promise(resolve => setTimeout(resolve, Math.min(timeout, 5000)));
677
+ }
678
+
679
+ // iTerm2 panes can't be killed programmatically easily
680
+ // The user will need to close the pane manually
681
+ console.log(colors.warning(
682
+ `[Shutdown] iTerm2 pane for member ${memberId} should be closed manually. ` +
683
+ `The process has been marked as shutdown.`
684
+ ));
685
+
686
+ this.activeProcesses.delete(memberId);
687
+ await this.store.updateMember(teamId, memberId, { status: 'shutdown' });
688
+ return { success: true, reason: 'iTerm2 session marked as shutdown (close manually)' };
689
+ } catch (error: any) {
690
+ this.activeProcesses.delete(memberId);
691
+ await this.store.updateMember(teamId, memberId, { status: 'shutdown' });
692
+ return { success: true, reason: `Process cleanup: ${error.message}` };
693
+ }
694
+ }
695
+
696
+ private async shutdownChildProcess(
697
+ teamId: string,
698
+ memberId: string,
699
+ childProcess: ChildProcess,
700
+ force: boolean,
701
+ timeout: number
702
+ ): Promise<{ success: boolean; reason?: string }> {
703
+ return new Promise((resolve) => {
704
+ const cleanup = () => {
705
+ this.activeProcesses.delete(memberId);
706
+ this.store.updateMember(teamId, memberId, { status: 'shutdown' }).catch(() => {});
707
+ };
708
+
709
+ // Set up timeout for force kill
710
+ const timeoutId = setTimeout(() => {
711
+ console.log(colors.warning(`[Shutdown] Timeout reached, force killing process ${childProcess.pid}`));
712
+ childProcess.kill('SIGKILL');
713
+ cleanup();
714
+ resolve({ success: true, reason: 'Timeout, force killed with SIGKILL' });
715
+ }, timeout);
716
+
717
+ // Handle process exit
718
+ childProcess.once('exit', (code) => {
719
+ clearTimeout(timeoutId);
720
+ cleanup();
721
+ resolve({ success: true, reason: `Process exited with code ${code}` });
722
+ });
723
+
724
+ if (force) {
725
+ // Force kill immediately
726
+ childProcess.kill('SIGKILL');
727
+ } else {
728
+ // Send SIGTERM for graceful shutdown
729
+ console.log(colors.info(`[Shutdown] Sending SIGTERM to process ${childProcess.pid}`));
730
+ childProcess.kill('SIGTERM');
731
+ }
732
+ });
733
+ }
734
+
735
+ async shutdownAllTeammates(teamId: string): Promise<{ success: boolean; results: Map<string, { success: boolean; reason?: string }> }> {
736
+ const team = await this.store.getTeam(teamId);
737
+ if (!team) {
738
+ return { success: false, results: new Map() };
739
+ }
740
+
741
+ const results = new Map<string, { success: boolean; reason?: string }>();
742
+
743
+ for (const member of team.members) {
744
+ if (member.status === 'active' && member.role !== 'lead') {
745
+ const result = await this.shutdownTeammate(teamId, member.memberId);
746
+ results.set(member.memberId, result);
747
+ }
748
+ }
749
+
750
+ // Clean up tmux session
751
+ if (this.tmuxSessionName) {
752
+ try {
753
+ execSync(`tmux kill-session -t ${this.tmuxSessionName}`, { stdio: 'ignore' });
754
+ } catch {
755
+ // Session might already be closed
756
+ }
757
+ this.tmuxSessionName = null;
758
+ }
759
+
760
+ return {
761
+ success: Array.from(results.values()).every(r => r.success),
762
+ results
763
+ };
764
+ }
765
+
766
+ getActiveProcesses(): string[] {
767
+ return Array.from(this.activeProcesses.keys());
768
+ }
769
+
770
+ /**
771
+ * Get detailed info about a tracked process
772
+ */
773
+ getProcessInfo(memberId: string): TrackedProcess | undefined {
774
+ return this.activeProcesses.get(memberId);
775
+ }
776
+
777
+ isDisplayModeAvailable(mode: DisplayMode): boolean {
778
+ switch (mode) {
779
+ case 'tmux':
780
+ return this.isTmuxAvailable();
781
+ case 'iterm2':
782
+ return this.isIterm2Available();
783
+ case 'in-process':
784
+ return true;
785
+ case 'auto':
786
+ return true;
787
+ default:
788
+ return false;
789
+ }
790
+ }
791
+ }
792
+
793
+ let teammateSpawnerInstance: TeammateSpawner | null = null;
794
+
795
+ export function getTeammateSpawner(store?: TeamStore): TeammateSpawner {
796
+ if (!teammateSpawnerInstance) {
797
+ teammateSpawnerInstance = new TeammateSpawner(store);
798
+ }
799
+ return teammateSpawnerInstance;
800
+ }