tmux-team 2.0.0-alpha.3 → 2.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-team",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.1.0",
4
4
  "description": "CLI tool for AI agent collaboration in tmux - manage cross-pane communication",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -7,7 +7,8 @@ import { createContext, ExitCodes } from './context.js';
7
7
  import type { Flags } from './types.js';
8
8
 
9
9
  // Commands
10
- import { cmdHelp } from './commands/help.js';
10
+ import { cmdHelp, HelpConfig } from './commands/help.js';
11
+ import { loadConfig, resolvePaths } from './config.js';
11
12
  import { cmdInit } from './commands/init.js';
12
13
  import { cmdList } from './commands/list.js';
13
14
  import { cmdAdd } from './commands/add.js';
@@ -103,9 +104,20 @@ function main(): void {
103
104
  const argv = process.argv.slice(2);
104
105
  const { command, args, flags } = parseArgs(argv);
105
106
 
106
- // Help doesn't need context
107
+ // Help - load config to show current mode/timeout
107
108
  if (!command || command === 'help' || command === '--help' || command === '-h') {
108
- cmdHelp();
109
+ try {
110
+ const paths = resolvePaths();
111
+ const config = loadConfig(paths);
112
+ const helpConfig: HelpConfig = {
113
+ mode: config.mode,
114
+ timeout: config.defaults.timeout,
115
+ };
116
+ cmdHelp(helpConfig);
117
+ } catch {
118
+ // Fallback if config can't be loaded
119
+ cmdHelp();
120
+ }
109
121
  process.exit(ExitCodes.SUCCESS);
110
122
  }
111
123
 
@@ -155,11 +155,10 @@ function clearConfig(ctx: Context, key?: string): void {
155
155
  * Config command entry point.
156
156
  */
157
157
  export function cmdConfig(ctx: Context, args: string[]): void {
158
- const subcommand = args[0];
159
-
160
- // Parse --global flag
158
+ // Parse --global flag first, then determine subcommand
161
159
  const globalFlag = args.includes('--global') || args.includes('-g');
162
160
  const filteredArgs = args.filter((a) => a !== '--global' && a !== '-g');
161
+ const subcommand = filteredArgs[0];
163
162
 
164
163
  switch (subcommand) {
165
164
  case undefined:
@@ -5,10 +5,30 @@
5
5
  import { colors } from '../ui.js';
6
6
  import { VERSION } from '../version.js';
7
7
 
8
- export function cmdHelp(): void {
8
+ export interface HelpConfig {
9
+ mode?: 'polling' | 'wait';
10
+ timeout?: number;
11
+ }
12
+
13
+ export function cmdHelp(config?: HelpConfig): void {
14
+ const mode = config?.mode ?? 'polling';
15
+ const timeout = config?.timeout ?? 180;
16
+ const isWaitMode = mode === 'wait';
17
+
18
+ // Mode indicator with clear explanation
19
+ const modeInfo = isWaitMode
20
+ ? `${colors.yellow('CURRENT MODE')}: ${colors.green('wait')} (timeout: ${timeout}s)
21
+ ${colors.dim('→ talk commands will BLOCK until agent responds or timeout')}
22
+ ${colors.dim('→ Response is returned directly, no need to use check command')}`
23
+ : `${colors.yellow('CURRENT MODE')}: ${colors.cyan('polling')}
24
+ ${colors.dim('→ talk commands send and return immediately')}
25
+ ${colors.dim('→ Use check command to read agent response')}`;
26
+
9
27
  console.log(`
10
28
  ${colors.cyan('tmux-team')} v${VERSION} - AI agent collaboration in tmux
11
29
 
30
+ ${modeInfo}
31
+
12
32
  ${colors.yellow('USAGE')}
13
33
  tmux-team <command> [arguments]
14
34
 
@@ -31,36 +51,33 @@ ${colors.yellow('OPTIONS')}
31
51
  ${colors.green('--verbose')} Show detailed output
32
52
  ${colors.green('--force')} Skip warnings
33
53
 
34
- ${colors.yellow('TALK OPTIONS')} ${colors.dim('(v2)')}
35
- ${colors.green('--delay')} <seconds> Wait before sending (default: seconds)
36
- ${colors.green('--wait')} Wait for agent response (nonce-based)
37
- ${colors.green('--timeout')} <seconds> Max wait time (default: 60)
54
+ ${colors.yellow('TALK OPTIONS')}
55
+ ${colors.green('--delay')} <seconds> Wait before sending
56
+ ${colors.green('--wait')} Force wait mode (block until response)
57
+ ${colors.green('--timeout')} <seconds> Max wait time (current: ${timeout}s)
38
58
  ${colors.green('--no-preamble')} Skip agent preamble for this message
39
59
 
40
- ${colors.yellow('EXAMPLES')}
41
- tmux-team talk codex "Please review the PR"
42
- tmux-team talk all "Sync meeting in 5 minutes"
43
- tmux-team check gemini 50
60
+ ${colors.yellow('EXAMPLES')}${
61
+ isWaitMode
62
+ ? `
63
+ ${colors.dim('# Wait mode: commands block until response')}
64
+ tmux-team talk codex "Review this PR" ${colors.dim('← blocks, returns response')}
65
+ tmux-team talk all "Status update" ${colors.dim('← waits for all agents')}`
66
+ : `
67
+ ${colors.dim('# Polling mode: send then check')}
68
+ tmux-team talk codex "Review this PR" ${colors.dim('← sends immediately')}
69
+ tmux-team check codex ${colors.dim('← read response later')}`
70
+ }
44
71
  tmux-team list --json
45
72
  tmux-team add codex 10.1 "Code review specialist"
46
- tmux-team update codex --pane 10.2
47
- tmux-team remove codex
48
73
 
49
74
  ${colors.yellow('CONFIG')}
50
75
  Local: ./tmux-team.json (pane registry + $config override)
51
76
  Global: ~/.config/tmux-team/config.json (settings)
52
77
 
53
- ${colors.yellow('CONFIG EXAMPLES')}
54
- tmux-team config Show current settings
55
- tmux-team config set mode wait Set mode in local config (repo override)
56
- tmux-team config set mode polling --global Set mode in global config
57
- tmux-team config clear mode Clear local override for mode
58
- tmux-team config clear Clear all local overrides
59
-
60
- ${colors.yellow('PREAMBLE EXAMPLES')}
61
- tmux-team preamble Show all preambles
62
- tmux-team preamble show codex Show preamble for codex
63
- tmux-team preamble set codex "You are a code reviewer. Be concise."
64
- tmux-team preamble clear codex Clear preamble for codex
78
+ ${colors.yellow('CHANGE MODE')}
79
+ tmux-team config set mode wait ${colors.dim('Enable wait mode (local)')}
80
+ tmux-team config set mode polling ${colors.dim('Enable polling mode (local)')}
81
+ tmux-team config set timeout 120 ${colors.dim('Set timeout to 120s')}
65
82
  `);
66
83
  }
@@ -533,16 +533,144 @@ describe('cmdTalk - --wait mode', () => {
533
533
  }
534
534
  });
535
535
 
536
- it('errors when using --wait with all target', async () => {
536
+ it('supports wait mode with all target (parallel polling)', async () => {
537
+ // Create mock tmux that returns markers for each agent after a delay
538
+ const tmux = createMockTmux();
539
+ let captureCount = 0;
540
+ const markersByPane: Record<string, string> = {};
541
+
542
+ // Mock send to capture the marker for each pane
543
+ tmux.send = (pane: string, msg: string) => {
544
+ const match = msg.match(/\{tmux-team-end:([a-f0-9]+)\}/);
545
+ if (match) {
546
+ markersByPane[pane] = match[0];
547
+ }
548
+ };
549
+
550
+ // Mock capture to return the marker after first poll
551
+ tmux.capture = (pane: string) => {
552
+ captureCount++;
553
+ // Return marker on second capture for each pane
554
+ if (captureCount > 3 && markersByPane[pane]) {
555
+ return `Response from agent\n${markersByPane[pane]}`;
556
+ }
557
+ return 'working...';
558
+ };
559
+
537
560
  const ui = createMockUI();
561
+ const paths = createTestPaths(testDir);
538
562
  const ctx = createContext({
539
563
  ui,
540
- paths: createTestPaths(testDir),
541
- flags: { wait: true },
564
+ tmux,
565
+ paths,
566
+ flags: { wait: true, timeout: 5 },
567
+ config: {
568
+ defaults: { timeout: 5, pollInterval: 0.05, captureLines: 100 },
569
+ paneRegistry: {
570
+ codex: { pane: '10.1' },
571
+ gemini: { pane: '10.2' },
572
+ },
573
+ },
574
+ });
575
+
576
+ await cmdTalk(ctx, 'all', 'Hello');
577
+
578
+ // Should have captured both panes
579
+ expect(captureCount).toBeGreaterThan(2);
580
+ });
581
+
582
+ it('handles partial timeout in wait mode with all target', async () => {
583
+ const tmux = createMockTmux();
584
+ const markersByPane: Record<string, string> = {};
585
+
586
+ tmux.send = (pane: string, msg: string) => {
587
+ const match = msg.match(/\{tmux-team-end:([a-f0-9]+)\}/);
588
+ if (match) {
589
+ markersByPane[pane] = match[0];
590
+ }
591
+ };
592
+
593
+ // Only pane 10.1 responds, 10.2 times out
594
+ tmux.capture = (pane: string) => {
595
+ if (pane === '10.1' && markersByPane[pane]) {
596
+ return `Response from codex\n${markersByPane[pane]}`;
597
+ }
598
+ return 'still working...';
599
+ };
600
+
601
+ const ui = createMockUI();
602
+ const paths = createTestPaths(testDir);
603
+ const ctx = createContext({
604
+ ui,
605
+ tmux,
606
+ paths,
607
+ flags: { wait: true, timeout: 0.1, json: true },
608
+ config: {
609
+ defaults: { timeout: 0.1, pollInterval: 0.02, captureLines: 100 },
610
+ paneRegistry: {
611
+ codex: { pane: '10.1' },
612
+ gemini: { pane: '10.2' },
613
+ },
614
+ },
542
615
  });
543
616
 
544
- await expect(cmdTalk(ctx, 'all', 'Hello')).rejects.toThrow('exit(1)');
545
- expect(ui.errors[0]).toContain('Wait mode is not supported');
617
+ try {
618
+ await cmdTalk(ctx, 'all', 'Hello');
619
+ } catch {
620
+ // Expected timeout exit
621
+ }
622
+
623
+ // Should have JSON output with both results
624
+ expect(ui.jsonOutput.length).toBe(1);
625
+ const result = ui.jsonOutput[0] as {
626
+ summary: { completed: number; timeout: number };
627
+ results: Array<{ agent: string; status: string }>;
628
+ };
629
+ expect(result.summary.completed).toBe(1);
630
+ expect(result.summary.timeout).toBe(1);
631
+ expect(result.results.find((r) => r.agent === 'codex')?.status).toBe('completed');
632
+ expect(result.results.find((r) => r.agent === 'gemini')?.status).toBe('timeout');
633
+ });
634
+
635
+ it('uses unique nonces per agent in broadcast', async () => {
636
+ const tmux = createMockTmux();
637
+ const nonces: string[] = [];
638
+
639
+ tmux.send = (_pane: string, msg: string) => {
640
+ const match = msg.match(/\{tmux-team-end:([a-f0-9]+)\}/);
641
+ if (match) {
642
+ nonces.push(match[1]);
643
+ }
644
+ };
645
+
646
+ // Return markers immediately
647
+ tmux.capture = (pane: string) => {
648
+ const idx = pane === '10.1' ? 0 : 1;
649
+ if (nonces[idx]) {
650
+ return `Response\n{tmux-team-end:${nonces[idx]}}`;
651
+ }
652
+ return '';
653
+ };
654
+
655
+ const paths = createTestPaths(testDir);
656
+ const ctx = createContext({
657
+ tmux,
658
+ paths,
659
+ flags: { wait: true, timeout: 5 },
660
+ config: {
661
+ defaults: { timeout: 5, pollInterval: 0.02, captureLines: 100 },
662
+ paneRegistry: {
663
+ codex: { pane: '10.1' },
664
+ gemini: { pane: '10.2' },
665
+ },
666
+ },
667
+ });
668
+
669
+ await cmdTalk(ctx, 'all', 'Hello');
670
+
671
+ // Each agent should have a unique nonce
672
+ expect(nonces.length).toBe(2);
673
+ expect(nonces[0]).not.toBe(nonces[1]);
546
674
  });
547
675
  });
548
676
 
@@ -2,7 +2,7 @@
2
2
  // talk command - send message to agent(s)
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
- import type { Context } from '../types.js';
5
+ import type { Context, PaneEntry } from '../types.js';
6
6
  import type { WaitResult } from '../types.js';
7
7
  import { ExitCodes } from '../exits.js';
8
8
  import { colors } from '../ui.js';
@@ -27,6 +27,38 @@ function renderWaitLine(agent: string, elapsedSeconds: number): string {
27
27
  return `⏳ Waiting for ${agent}... (${s}s)`;
28
28
  }
29
29
 
30
+ // ─────────────────────────────────────────────────────────────
31
+ // Types for broadcast wait mode
32
+ // ─────────────────────────────────────────────────────────────
33
+
34
+ interface AgentWaitState {
35
+ agent: string;
36
+ pane: string;
37
+ requestId: string;
38
+ nonce: string;
39
+ marker: string;
40
+ baseline: string;
41
+ status: 'pending' | 'completed' | 'timeout' | 'error';
42
+ response?: string;
43
+ error?: string;
44
+ elapsedMs?: number;
45
+ }
46
+
47
+ interface BroadcastWaitResult {
48
+ target: 'all';
49
+ mode: 'wait';
50
+ self?: string;
51
+ identityWarning?: string;
52
+ summary: {
53
+ total: number;
54
+ completed: number;
55
+ timeout: number;
56
+ error: number;
57
+ skipped: number;
58
+ };
59
+ results: AgentWaitState[];
60
+ }
61
+
30
62
  /**
31
63
  * Build the final message with optional preamble.
32
64
  * Format: [SYSTEM: <preamble>]\n\n<message>
@@ -54,11 +86,6 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
54
86
  const { ui, config, tmux, flags, exit } = ctx;
55
87
  const waitEnabled = Boolean(flags.wait) || config.mode === 'wait';
56
88
 
57
- if (waitEnabled && target === 'all') {
58
- ui.error("Wait mode is not supported with 'all' yet. Send to one agent at a time.");
59
- exit(ExitCodes.ERROR);
60
- }
61
-
62
89
  if (target === 'all') {
63
90
  const agents = Object.entries(config.paneRegistry);
64
91
  if (agents.length === 0) {
@@ -67,44 +94,58 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
67
94
  }
68
95
 
69
96
  // Determine current agent to skip self
70
- const { actor: self } = resolveActor(config.paneRegistry);
97
+ const { actor: self, warning: identityWarning } = resolveActor(config.paneRegistry);
98
+
99
+ // Surface identity warnings (mismatch, unregistered pane, etc.)
100
+ if (identityWarning && !flags.json) {
101
+ ui.warn(identityWarning);
102
+ }
71
103
 
72
104
  if (flags.delay && flags.delay > 0) {
73
105
  await sleepMs(flags.delay * 1000);
74
106
  }
75
107
 
76
- const results: { agent: string; pane: string; status: string }[] = [];
108
+ // Filter out self
109
+ const targetAgents = agents.filter(([name]) => name !== self);
110
+ const skippedSelf = agents.length !== targetAgents.length;
111
+
112
+ if (!waitEnabled) {
113
+ // Non-wait mode: fire and forget
114
+ const results: { agent: string; pane: string; status: string }[] = [];
77
115
 
78
- for (const [name, data] of agents) {
79
- // Skip sending to self
80
- if (name === self) {
81
- results.push({ agent: name, pane: data.pane, status: 'skipped (self)' });
116
+ if (skippedSelf) {
117
+ const selfData = config.paneRegistry[self];
118
+ results.push({ agent: self, pane: selfData?.pane || '', status: 'skipped (self)' });
82
119
  if (!flags.json) {
83
- console.log(`${colors.dim('○')} Skipped ${colors.cyan(name)} (self)`);
120
+ console.log(`${colors.dim('○')} Skipped ${colors.cyan(self)} (self)`);
84
121
  }
85
- continue;
86
122
  }
87
123
 
88
- try {
89
- // Build message with preamble, then apply Gemini filter
90
- let msg = buildMessage(message, name, ctx);
91
- if (name === 'gemini') msg = msg.replace(/!/g, '');
92
- tmux.send(data.pane, msg);
93
- results.push({ agent: name, pane: data.pane, status: 'sent' });
94
- if (!flags.json) {
95
- console.log(`${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
96
- }
97
- } catch {
98
- results.push({ agent: name, pane: data.pane, status: 'failed' });
99
- if (!flags.json) {
100
- ui.warn(`Failed to send to ${name}`);
124
+ for (const [name, data] of targetAgents) {
125
+ try {
126
+ let msg = buildMessage(message, name, ctx);
127
+ if (name === 'gemini') msg = msg.replace(/!/g, '');
128
+ tmux.send(data.pane, msg);
129
+ results.push({ agent: name, pane: data.pane, status: 'sent' });
130
+ if (!flags.json) {
131
+ console.log(`${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
132
+ }
133
+ } catch {
134
+ results.push({ agent: name, pane: data.pane, status: 'failed' });
135
+ if (!flags.json) {
136
+ ui.warn(`Failed to send to ${name}`);
137
+ }
101
138
  }
102
139
  }
103
- }
104
140
 
105
- if (flags.json) {
106
- ui.json({ target: 'all', results });
141
+ if (flags.json) {
142
+ ui.json({ target: 'all', self, identityWarning, results });
143
+ }
144
+ return;
107
145
  }
146
+
147
+ // Wait mode: parallel polling
148
+ await cmdTalkAllWait(ctx, targetAgents, message, self, identityWarning, skippedSelf);
108
149
  return;
109
150
  }
110
151
 
@@ -272,3 +313,286 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
272
313
  clearActiveRequest(ctx.paths, target, requestId);
273
314
  }
274
315
  }
316
+
317
+ // ─────────────────────────────────────────────────────────────
318
+ // Broadcast wait mode: parallel polling for all agents
319
+ // ─────────────────────────────────────────────────────────────
320
+
321
+ async function cmdTalkAllWait(
322
+ ctx: Context,
323
+ targetAgents: [string, PaneEntry][],
324
+ message: string,
325
+ self: string,
326
+ identityWarning: string | undefined,
327
+ skippedSelf: boolean
328
+ ): Promise<void> {
329
+ const { ui, config, tmux, flags, exit, paths } = ctx;
330
+ const timeoutSeconds = flags.timeout ?? config.defaults.timeout;
331
+ const pollIntervalSeconds = Math.max(0.1, config.defaults.pollInterval);
332
+ const captureLines = config.defaults.captureLines;
333
+
334
+ // Best-effort state cleanup
335
+ cleanupState(paths, 60 * 60);
336
+
337
+ // Initialize wait state for each agent with unique nonces
338
+ const agentStates: AgentWaitState[] = [];
339
+
340
+ if (!flags.json) {
341
+ console.log(
342
+ `${colors.cyan('→')} Broadcasting to ${targetAgents.length} agent(s) (wait mode)...`
343
+ );
344
+ }
345
+
346
+ // Phase 1: Send messages to all agents and capture baselines
347
+ for (const [name, data] of targetAgents) {
348
+ const requestId = makeRequestId();
349
+ const nonce = makeNonce(); // Unique nonce per agent (#19)
350
+ const marker = `{tmux-team-end:${nonce}}`;
351
+
352
+ let baseline = '';
353
+ try {
354
+ baseline = tmux.capture(data.pane, captureLines);
355
+ } catch {
356
+ agentStates.push({
357
+ agent: name,
358
+ pane: data.pane,
359
+ requestId,
360
+ nonce,
361
+ marker,
362
+ baseline: '',
363
+ status: 'error',
364
+ error: `Failed to capture pane ${data.pane}`,
365
+ });
366
+ if (!flags.json) {
367
+ ui.warn(`Failed to capture ${name} (${data.pane})`);
368
+ }
369
+ continue;
370
+ }
371
+
372
+ // Build and send message
373
+ const messageWithPreamble = buildMessage(message, name, ctx);
374
+ const fullMessage = `${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${marker}]`;
375
+ const msg = name === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
376
+
377
+ try {
378
+ tmux.send(data.pane, msg);
379
+ setActiveRequest(paths, name, {
380
+ id: requestId,
381
+ nonce,
382
+ pane: data.pane,
383
+ startedAtMs: Date.now(),
384
+ });
385
+ agentStates.push({
386
+ agent: name,
387
+ pane: data.pane,
388
+ requestId,
389
+ nonce,
390
+ marker,
391
+ baseline,
392
+ status: 'pending',
393
+ });
394
+ if (!flags.json) {
395
+ console.log(` ${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
396
+ }
397
+ } catch {
398
+ agentStates.push({
399
+ agent: name,
400
+ pane: data.pane,
401
+ requestId,
402
+ nonce,
403
+ marker,
404
+ baseline,
405
+ status: 'error',
406
+ error: `Failed to send to pane ${data.pane}`,
407
+ });
408
+ if (!flags.json) {
409
+ ui.warn(`Failed to send to ${name}`);
410
+ }
411
+ }
412
+ }
413
+
414
+ // Track pending agents
415
+ const pendingAgents = () => agentStates.filter((s) => s.status === 'pending');
416
+
417
+ if (pendingAgents().length === 0) {
418
+ // All failed to send, output results and exit with error
419
+ outputBroadcastResults(ctx, agentStates, self, identityWarning, skippedSelf);
420
+ exit(ExitCodes.ERROR);
421
+ return;
422
+ }
423
+
424
+ const startedAt = Date.now();
425
+ let lastLogAt = 0;
426
+ const isTTY = process.stdout.isTTY && !flags.json;
427
+
428
+ // SIGINT handler: cleanup ALL active requests (#18)
429
+ const onSigint = (): void => {
430
+ for (const state of agentStates) {
431
+ clearActiveRequest(paths, state.agent, state.requestId);
432
+ }
433
+ if (!flags.json) {
434
+ process.stdout.write('\n');
435
+ ui.error('Interrupted.');
436
+ }
437
+ // Output partial results
438
+ outputBroadcastResults(ctx, agentStates, self, identityWarning, skippedSelf);
439
+ exit(ExitCodes.ERROR);
440
+ };
441
+
442
+ process.once('SIGINT', onSigint);
443
+
444
+ try {
445
+ // Phase 2: Poll all agents in parallel until all complete or timeout
446
+ while (pendingAgents().length > 0) {
447
+ const elapsedSeconds = (Date.now() - startedAt) / 1000;
448
+
449
+ // Check timeout for each pending agent (#17)
450
+ for (const state of pendingAgents()) {
451
+ if (elapsedSeconds >= timeoutSeconds) {
452
+ state.status = 'timeout';
453
+ state.error = `Timed out after ${Math.floor(timeoutSeconds)}s`;
454
+ state.elapsedMs = Math.floor(elapsedSeconds * 1000);
455
+ clearActiveRequest(paths, state.agent, state.requestId);
456
+ if (!flags.json) {
457
+ console.log(
458
+ ` ${colors.red('✗')} ${colors.cyan(state.agent)} timed out (${Math.floor(elapsedSeconds)}s)`
459
+ );
460
+ }
461
+ }
462
+ }
463
+
464
+ // All done?
465
+ if (pendingAgents().length === 0) break;
466
+
467
+ // Progress logging (non-TTY)
468
+ if (!flags.json && !isTTY) {
469
+ const now = Date.now();
470
+ if (now - lastLogAt >= 5000) {
471
+ lastLogAt = now;
472
+ const pending = pendingAgents()
473
+ .map((s) => s.agent)
474
+ .join(', ');
475
+ console.error(
476
+ `[tmux-team] Waiting for: ${pending} (${Math.floor(elapsedSeconds)}s elapsed)`
477
+ );
478
+ }
479
+ }
480
+
481
+ await sleepMs(pollIntervalSeconds * 1000);
482
+
483
+ // Poll each pending agent
484
+ for (const state of pendingAgents()) {
485
+ let output = '';
486
+ try {
487
+ output = tmux.capture(state.pane, captureLines);
488
+ } catch {
489
+ state.status = 'error';
490
+ state.error = `Failed to capture pane ${state.pane}`;
491
+ state.elapsedMs = Date.now() - startedAt;
492
+ clearActiveRequest(paths, state.agent, state.requestId);
493
+ if (!flags.json) {
494
+ ui.warn(`Failed to capture ${state.agent}`);
495
+ }
496
+ continue;
497
+ }
498
+
499
+ const markerIndex = output.indexOf(state.marker);
500
+ if (markerIndex === -1) continue;
501
+
502
+ // Found marker - extract response
503
+ let startIndex = 0;
504
+ const baselineIndex = state.baseline ? output.lastIndexOf(state.baseline) : -1;
505
+ if (baselineIndex !== -1) {
506
+ startIndex = baselineIndex + state.baseline.length;
507
+ }
508
+
509
+ state.response = output.slice(startIndex, markerIndex).trim();
510
+ state.status = 'completed';
511
+ state.elapsedMs = Date.now() - startedAt;
512
+ clearActiveRequest(paths, state.agent, state.requestId);
513
+
514
+ if (!flags.json) {
515
+ console.log(
516
+ ` ${colors.green('✓')} ${colors.cyan(state.agent)} completed (${Math.floor(state.elapsedMs / 1000)}s)`
517
+ );
518
+ }
519
+ }
520
+ }
521
+ } finally {
522
+ process.removeListener('SIGINT', onSigint);
523
+ // Cleanup any remaining active requests
524
+ for (const state of agentStates) {
525
+ clearActiveRequest(paths, state.agent, state.requestId);
526
+ }
527
+ }
528
+
529
+ // Output results
530
+ outputBroadcastResults(ctx, agentStates, self, identityWarning, skippedSelf);
531
+
532
+ // Exit with appropriate code
533
+ const hasTimeout = agentStates.some((s) => s.status === 'timeout');
534
+ const hasError = agentStates.some((s) => s.status === 'error');
535
+ if (hasTimeout) {
536
+ exit(ExitCodes.TIMEOUT);
537
+ } else if (hasError) {
538
+ exit(ExitCodes.ERROR);
539
+ }
540
+ }
541
+
542
+ function outputBroadcastResults(
543
+ ctx: Context,
544
+ agentStates: AgentWaitState[],
545
+ self: string,
546
+ identityWarning: string | undefined,
547
+ skippedSelf: boolean
548
+ ): void {
549
+ const { ui, flags } = ctx;
550
+
551
+ const summary = {
552
+ total: agentStates.length + (skippedSelf ? 1 : 0),
553
+ completed: agentStates.filter((s) => s.status === 'completed').length,
554
+ timeout: agentStates.filter((s) => s.status === 'timeout').length,
555
+ error: agentStates.filter((s) => s.status === 'error').length,
556
+ skipped: skippedSelf ? 1 : 0,
557
+ };
558
+
559
+ if (flags.json) {
560
+ const result: BroadcastWaitResult = {
561
+ target: 'all',
562
+ mode: 'wait',
563
+ self,
564
+ identityWarning,
565
+ summary,
566
+ results: agentStates.map((s) => ({
567
+ agent: s.agent,
568
+ pane: s.pane,
569
+ requestId: s.requestId,
570
+ nonce: s.nonce,
571
+ marker: s.marker,
572
+ baseline: '', // Don't include baseline in output
573
+ status: s.status,
574
+ response: s.response,
575
+ error: s.error,
576
+ elapsedMs: s.elapsedMs,
577
+ })),
578
+ };
579
+ ui.json(result);
580
+ return;
581
+ }
582
+
583
+ // Human-readable output
584
+ console.log();
585
+ console.log(
586
+ `${colors.cyan('Summary:')} ${summary.completed} completed, ${summary.timeout} timeout, ${summary.error} error, ${summary.skipped} skipped`
587
+ );
588
+ console.log();
589
+
590
+ // Print responses
591
+ for (const state of agentStates) {
592
+ if (state.status === 'completed' && state.response) {
593
+ console.log(colors.cyan(`─── Response from ${state.agent} (${state.pane}) ───`));
594
+ console.log(state.response);
595
+ console.log();
596
+ }
597
+ }
598
+ }
@@ -158,7 +158,7 @@ describe('loadConfig', () => {
158
158
 
159
159
  expect(config.mode).toBe('polling');
160
160
  expect(config.preambleMode).toBe('always');
161
- expect(config.defaults.timeout).toBe(60);
161
+ expect(config.defaults.timeout).toBe(180);
162
162
  expect(config.defaults.pollInterval).toBe(1);
163
163
  expect(config.defaults.captureLines).toBe(100);
164
164
  expect(config.agents).toEqual({});
package/src/config.ts CHANGED
@@ -23,7 +23,7 @@ const DEFAULT_CONFIG: Omit<GlobalConfig, 'agents'> & { agents: Record<string, ne
23
23
  mode: 'polling',
24
24
  preambleMode: 'always',
25
25
  defaults: {
26
- timeout: 60,
26
+ timeout: 180,
27
27
  pollInterval: 1,
28
28
  captureLines: 100,
29
29
  },
@@ -770,9 +770,10 @@ describe('cmdPmList', () => {
770
770
  await cmdPmList(ctx, []);
771
771
 
772
772
  expect(ctx.ui.jsonData.length).toBe(1);
773
- expect(ctx.ui.jsonData[0]).toHaveProperty('teams');
774
- expect(ctx.ui.jsonData[0]).toHaveProperty('currentTeamId');
775
- expect(Array.isArray(ctx.ui.jsonData[0].teams)).toBe(true);
773
+ const data = ctx.ui.jsonData[0] as { teams: unknown[]; currentTeamId: string | null };
774
+ expect(data).toHaveProperty('teams');
775
+ expect(data).toHaveProperty('currentTeamId');
776
+ expect(Array.isArray(data.teams)).toBe(true);
776
777
  });
777
778
  });
778
779
 
@@ -175,11 +175,12 @@ export function resolveActor(paneRegistry: Record<string, PaneEntry>): ActorReso
175
175
 
176
176
  // Pane not in registry
177
177
  if (envActor) {
178
- // Agent claims identity but pane not registered - warn
178
+ // Agent claims identity but pane not registered - use env identity with warning
179
+ // Security: Still apply agent's deny patterns to prevent bypass via unregistered pane
179
180
  return {
180
- actor: 'human',
181
- source: 'default',
182
- warning: `⚠️ Unregistered pane: TMT_AGENT_NAME="${envActor}" but pane ${currentPane} is not in registry. Treating as human (full access).`,
181
+ actor: envActor,
182
+ source: 'env',
183
+ warning: `⚠️ Unregistered pane: pane ${currentPane} is not in registry. Using TMT_AGENT_NAME="${envActor}".`,
183
184
  };
184
185
  }
185
186
 
@@ -53,7 +53,7 @@ interface GHIssue {
53
53
  interface GHMilestone {
54
54
  number: number;
55
55
  title: string;
56
- state: 'OPEN' | 'CLOSED';
56
+ state: 'open' | 'closed'; // REST API uses lowercase (unlike GraphQL)
57
57
  createdAt: string;
58
58
  }
59
59
 
@@ -253,7 +253,7 @@ export class GitHubAdapter implements StorageAdapter {
253
253
  return {
254
254
  id,
255
255
  name: ghMilestone.title,
256
- status: ghMilestone.state === 'CLOSED' ? 'done' : 'pending',
256
+ status: ghMilestone.state === 'closed' ? 'done' : 'pending',
257
257
  createdAt: ghMilestone.createdAt,
258
258
  updatedAt: ghMilestone.createdAt, // GH milestones don't have updatedAt
259
259
  };