tmux-team 2.0.0-alpha.1 → 2.0.0-alpha.4

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,13 +1,15 @@
1
1
  {
2
2
  "name": "tmux-team",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.4",
4
4
  "description": "CLI tool for AI agent collaboration in tmux - manage cross-pane communication",
5
5
  "type": "module",
6
6
  "bin": {
7
- "tmux-team": "./bin/tmux-team"
7
+ "tmux-team": "./bin/tmux-team",
8
+ "tmt": "./bin/tmux-team"
8
9
  },
9
10
  "scripts": {
10
11
  "dev": "tsx src/cli.ts",
12
+ "tmt": "./bin/tmux-team",
11
13
  "test": "vitest",
12
14
  "test:run": "vitest run",
13
15
  "lint": "oxlint src/",
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';
@@ -17,6 +18,8 @@ import { cmdTalk } from './commands/talk.js';
17
18
  import { cmdCheck } from './commands/check.js';
18
19
  import { cmdCompletion } from './commands/completion.js';
19
20
  import { cmdPm } from './pm/commands.js';
21
+ import { cmdConfig } from './commands/config.js';
22
+ import { cmdPreamble } from './commands/preamble.js';
20
23
 
21
24
  // ─────────────────────────────────────────────────────────────
22
25
  // Argument parsing
@@ -101,9 +104,20 @@ function main(): void {
101
104
  const argv = process.argv.slice(2);
102
105
  const { command, args, flags } = parseArgs(argv);
103
106
 
104
- // Help doesn't need context
107
+ // Help - load config to show current mode/timeout
105
108
  if (!command || command === 'help' || command === '--help' || command === '-h') {
106
- 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
+ }
107
121
  process.exit(ExitCodes.SUCCESS);
108
122
  }
109
123
 
@@ -193,6 +207,14 @@ function main(): void {
193
207
  await cmdPm(ctx, args);
194
208
  break;
195
209
 
210
+ case 'config':
211
+ cmdConfig(ctx, args);
212
+ break;
213
+
214
+ case 'preamble':
215
+ cmdPreamble(ctx, args);
216
+ break;
217
+
196
218
  default:
197
219
  ctx.ui.error(`Unknown command: ${command}. Run 'tmux-team help' for usage.`);
198
220
  ctx.exit(ExitCodes.ERROR);
@@ -0,0 +1,186 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Config command - view and modify settings
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import type { Context } from '../types.js';
6
+ import { ExitCodes } from '../context.js';
7
+ import {
8
+ loadGlobalConfig,
9
+ saveGlobalConfig,
10
+ loadLocalConfigFile,
11
+ saveLocalConfigFile,
12
+ updateLocalSettings,
13
+ clearLocalSettings,
14
+ } from '../config.js';
15
+
16
+ type ConfigKey = 'mode' | 'preambleMode';
17
+
18
+ const VALID_KEYS: ConfigKey[] = ['mode', 'preambleMode'];
19
+
20
+ const VALID_VALUES: Record<ConfigKey, string[]> = {
21
+ mode: ['polling', 'wait'],
22
+ preambleMode: ['always', 'disabled'],
23
+ };
24
+
25
+ function isValidKey(key: string): key is ConfigKey {
26
+ return VALID_KEYS.includes(key as ConfigKey);
27
+ }
28
+
29
+ function isValidValue(key: ConfigKey, value: string): boolean {
30
+ return VALID_VALUES[key].includes(value);
31
+ }
32
+
33
+ /**
34
+ * Show resolved config with source indicators.
35
+ */
36
+ function showConfig(ctx: Context): void {
37
+ const globalConfig = loadGlobalConfig(ctx.paths);
38
+ const localConfigFile = loadLocalConfigFile(ctx.paths);
39
+ const localSettings = localConfigFile.$config;
40
+
41
+ if (ctx.flags.json) {
42
+ ctx.ui.json({
43
+ resolved: {
44
+ mode: ctx.config.mode,
45
+ preambleMode: ctx.config.preambleMode,
46
+ defaults: ctx.config.defaults,
47
+ },
48
+ sources: {
49
+ mode: localSettings?.mode ? 'local' : globalConfig.mode ? 'global' : 'default',
50
+ preambleMode: localSettings?.preambleMode
51
+ ? 'local'
52
+ : globalConfig.preambleMode
53
+ ? 'global'
54
+ : 'default',
55
+ },
56
+ paths: {
57
+ global: ctx.paths.globalConfig,
58
+ local: ctx.paths.localConfig,
59
+ },
60
+ });
61
+ return;
62
+ }
63
+
64
+ // Determine sources
65
+ const modeSource = localSettings?.mode ? '(local)' : globalConfig.mode ? '(global)' : '(default)';
66
+ const preambleSource = localSettings?.preambleMode
67
+ ? '(local)'
68
+ : globalConfig.preambleMode
69
+ ? '(global)'
70
+ : '(default)';
71
+
72
+ ctx.ui.info('Current configuration:\n');
73
+ ctx.ui.table(
74
+ ['Key', 'Value', 'Source'],
75
+ [
76
+ ['mode', ctx.config.mode, modeSource],
77
+ ['preambleMode', ctx.config.preambleMode, preambleSource],
78
+ ['defaults.timeout', String(ctx.config.defaults.timeout), '(global)'],
79
+ ['defaults.pollInterval', String(ctx.config.defaults.pollInterval), '(global)'],
80
+ ['defaults.captureLines', String(ctx.config.defaults.captureLines), '(global)'],
81
+ ]
82
+ );
83
+
84
+ ctx.ui.info(`\nPaths:`);
85
+ ctx.ui.info(` Global: ${ctx.paths.globalConfig}`);
86
+ ctx.ui.info(` Local: ${ctx.paths.localConfig}`);
87
+ }
88
+
89
+ /**
90
+ * Set a config value.
91
+ */
92
+ function setConfig(ctx: Context, key: string, value: string, global: boolean): void {
93
+ if (!isValidKey(key)) {
94
+ ctx.ui.error(`Invalid key: ${key}. Valid keys: ${VALID_KEYS.join(', ')}`);
95
+ ctx.exit(ExitCodes.ERROR);
96
+ }
97
+
98
+ if (!isValidValue(key, value)) {
99
+ ctx.ui.error(
100
+ `Invalid value for ${key}: ${value}. Valid values: ${VALID_VALUES[key].join(', ')}`
101
+ );
102
+ ctx.exit(ExitCodes.ERROR);
103
+ }
104
+
105
+ if (global) {
106
+ // Set in global config
107
+ const globalConfig = loadGlobalConfig(ctx.paths);
108
+ if (key === 'mode') {
109
+ globalConfig.mode = value as 'polling' | 'wait';
110
+ } else if (key === 'preambleMode') {
111
+ globalConfig.preambleMode = value as 'always' | 'disabled';
112
+ }
113
+ saveGlobalConfig(ctx.paths, globalConfig);
114
+ ctx.ui.success(`Set ${key}=${value} in global config`);
115
+ } else {
116
+ // Set in local config
117
+ if (key === 'mode') {
118
+ updateLocalSettings(ctx.paths, { mode: value as 'polling' | 'wait' });
119
+ } else if (key === 'preambleMode') {
120
+ updateLocalSettings(ctx.paths, { preambleMode: value as 'always' | 'disabled' });
121
+ }
122
+ ctx.ui.success(`Set ${key}=${value} in local config (repo override)`);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Clear local config override.
128
+ */
129
+ function clearConfig(ctx: Context, key?: string): void {
130
+ if (key) {
131
+ if (!isValidKey(key)) {
132
+ ctx.ui.error(`Invalid key: ${key}. Valid keys: ${VALID_KEYS.join(', ')}`);
133
+ ctx.exit(ExitCodes.ERROR);
134
+ }
135
+
136
+ // Clear specific key from local settings
137
+ const localConfigFile = loadLocalConfigFile(ctx.paths);
138
+ if (localConfigFile.$config) {
139
+ delete localConfigFile.$config[key];
140
+ // Remove $config if empty
141
+ if (Object.keys(localConfigFile.$config).length === 0) {
142
+ delete localConfigFile.$config;
143
+ }
144
+ saveLocalConfigFile(ctx.paths, localConfigFile);
145
+ }
146
+ ctx.ui.success(`Cleared local override for ${key}`);
147
+ } else {
148
+ // Clear all local settings
149
+ clearLocalSettings(ctx.paths);
150
+ ctx.ui.success('Cleared all local config overrides');
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Config command entry point.
156
+ */
157
+ export function cmdConfig(ctx: Context, args: string[]): void {
158
+ // Parse --global flag first, then determine subcommand
159
+ const globalFlag = args.includes('--global') || args.includes('-g');
160
+ const filteredArgs = args.filter((a) => a !== '--global' && a !== '-g');
161
+ const subcommand = filteredArgs[0];
162
+
163
+ switch (subcommand) {
164
+ case undefined:
165
+ case 'show':
166
+ showConfig(ctx);
167
+ break;
168
+
169
+ case 'set':
170
+ if (filteredArgs.length < 3) {
171
+ ctx.ui.error('Usage: tmux-team config set <key> <value> [--global]');
172
+ ctx.exit(ExitCodes.ERROR);
173
+ }
174
+ setConfig(ctx, filteredArgs[1], filteredArgs[2], globalFlag);
175
+ break;
176
+
177
+ case 'clear':
178
+ clearConfig(ctx, filteredArgs[1]);
179
+ break;
180
+
181
+ default:
182
+ ctx.ui.error(`Unknown config subcommand: ${subcommand}`);
183
+ ctx.ui.error('Usage: tmux-team config [show|set|clear]');
184
+ ctx.exit(ExitCodes.ERROR);
185
+ }
186
+ }
@@ -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
 
@@ -20,6 +40,8 @@ ${colors.yellow('COMMANDS')}
20
40
  ${colors.green('update')} <name> [options] Update an agent's config
21
41
  ${colors.green('remove')} <name> Remove an agent
22
42
  ${colors.green('init')} Create empty tmux-team.json
43
+ ${colors.green('config')} [show|set|clear] View/modify settings
44
+ ${colors.green('preamble')} [show|set|clear] Manage agent preambles
23
45
  ${colors.green('pm')} <subcommand> Project management (run 'pm help')
24
46
  ${colors.green('completion')} Output shell completion script
25
47
  ${colors.green('help')} Show this help message
@@ -29,23 +51,33 @@ ${colors.yellow('OPTIONS')}
29
51
  ${colors.green('--verbose')} Show detailed output
30
52
  ${colors.green('--force')} Skip warnings
31
53
 
32
- ${colors.yellow('TALK OPTIONS')} ${colors.dim('(v2)')}
33
- ${colors.green('--delay')} <seconds> Wait before sending (default: seconds)
34
- ${colors.green('--wait')} Wait for agent response (nonce-based)
35
- ${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)
36
58
  ${colors.green('--no-preamble')} Skip agent preamble for this message
37
59
 
38
- ${colors.yellow('EXAMPLES')}
39
- tmux-team talk codex "Please review the PR"
40
- tmux-team talk all "Sync meeting in 5 minutes"
41
- 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
+ }
42
71
  tmux-team list --json
43
72
  tmux-team add codex 10.1 "Code review specialist"
44
- tmux-team update codex --pane 10.2
45
- tmux-team remove codex
46
73
 
47
74
  ${colors.yellow('CONFIG')}
48
- Local: ./tmux-team.json (pane registry)
75
+ Local: ./tmux-team.json (pane registry + $config override)
49
76
  Global: ~/.config/tmux-team/config.json (settings)
77
+
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')}
50
82
  `);
51
83
  }
@@ -0,0 +1,153 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Preamble command - manage agent preambles
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import type { Context } from '../types.js';
6
+ import { ExitCodes } from '../context.js';
7
+ import { loadGlobalConfig, saveGlobalConfig } from '../config.js';
8
+
9
+ /**
10
+ * Show preamble(s) for agent(s).
11
+ */
12
+ function showPreamble(ctx: Context, agentName?: string): void {
13
+ const { ui, config, flags } = ctx;
14
+
15
+ if (agentName) {
16
+ // Show specific agent's preamble
17
+ const agentConfig = config.agents[agentName];
18
+ const preamble = agentConfig?.preamble;
19
+
20
+ if (flags.json) {
21
+ ui.json({ agent: agentName, preamble: preamble ?? null });
22
+ return;
23
+ }
24
+
25
+ if (preamble) {
26
+ ui.info(`Preamble for ${agentName}:`);
27
+ console.log(preamble);
28
+ } else {
29
+ ui.info(`No preamble set for ${agentName}`);
30
+ }
31
+ } else {
32
+ // Show all preambles
33
+ const preambles: { agent: string; preamble: string }[] = [];
34
+
35
+ for (const [name, agentConfig] of Object.entries(config.agents)) {
36
+ if (agentConfig.preamble) {
37
+ preambles.push({ agent: name, preamble: agentConfig.preamble });
38
+ }
39
+ }
40
+
41
+ if (flags.json) {
42
+ ui.json({ preambles });
43
+ return;
44
+ }
45
+
46
+ if (preambles.length === 0) {
47
+ ui.info('No preambles configured');
48
+ return;
49
+ }
50
+
51
+ for (const { agent, preamble } of preambles) {
52
+ console.log(`─── ${agent} ───`);
53
+ console.log(preamble);
54
+ console.log();
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Set preamble for an agent.
61
+ */
62
+ function setPreamble(ctx: Context, agentName: string, preamble: string): void {
63
+ const { ui, paths, flags } = ctx;
64
+
65
+ const globalConfig = loadGlobalConfig(paths);
66
+
67
+ // Ensure agents object exists
68
+ if (!globalConfig.agents) {
69
+ globalConfig.agents = {};
70
+ }
71
+
72
+ // Ensure agent config exists
73
+ if (!globalConfig.agents[agentName]) {
74
+ globalConfig.agents[agentName] = {};
75
+ }
76
+
77
+ globalConfig.agents[agentName].preamble = preamble;
78
+ saveGlobalConfig(paths, globalConfig);
79
+
80
+ if (flags.json) {
81
+ ui.json({ agent: agentName, preamble, status: 'set' });
82
+ } else {
83
+ ui.success(`Set preamble for ${agentName}`);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Clear preamble for an agent.
89
+ */
90
+ function clearPreamble(ctx: Context, agentName: string): void {
91
+ const { ui, paths, flags } = ctx;
92
+
93
+ const globalConfig = loadGlobalConfig(paths);
94
+
95
+ if (globalConfig.agents?.[agentName]?.preamble) {
96
+ delete globalConfig.agents[agentName].preamble;
97
+
98
+ // Clean up empty agent config
99
+ if (Object.keys(globalConfig.agents[agentName]).length === 0) {
100
+ delete globalConfig.agents[agentName];
101
+ }
102
+
103
+ saveGlobalConfig(paths, globalConfig);
104
+
105
+ if (flags.json) {
106
+ ui.json({ agent: agentName, status: 'cleared' });
107
+ } else {
108
+ ui.success(`Cleared preamble for ${agentName}`);
109
+ }
110
+ } else {
111
+ if (flags.json) {
112
+ ui.json({ agent: agentName, status: 'not_set' });
113
+ } else {
114
+ ui.info(`No preamble was set for ${agentName}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Preamble command entry point.
121
+ */
122
+ export function cmdPreamble(ctx: Context, args: string[]): void {
123
+ const subcommand = args[0];
124
+
125
+ switch (subcommand) {
126
+ case undefined:
127
+ case 'show':
128
+ showPreamble(ctx, args[1]);
129
+ break;
130
+
131
+ case 'set':
132
+ if (args.length < 3) {
133
+ ctx.ui.error('Usage: tmux-team preamble set <agent> <preamble>');
134
+ ctx.exit(ExitCodes.ERROR);
135
+ }
136
+ // Join remaining args as preamble (allows spaces without quotes)
137
+ setPreamble(ctx, args[1], args.slice(2).join(' '));
138
+ break;
139
+
140
+ case 'clear':
141
+ if (args.length < 2) {
142
+ ctx.ui.error('Usage: tmux-team preamble clear <agent>');
143
+ ctx.exit(ExitCodes.ERROR);
144
+ }
145
+ clearPreamble(ctx, args[1]);
146
+ break;
147
+
148
+ default:
149
+ ctx.ui.error(`Unknown preamble subcommand: ${subcommand}`);
150
+ ctx.ui.error('Usage: tmux-team preamble [show|set|clear]');
151
+ ctx.exit(ExitCodes.ERROR);
152
+ }
153
+ }
@@ -203,12 +203,18 @@ describe('buildMessage (via cmdTalk)', () => {
203
203
 
204
204
  describe('cmdTalk - basic send', () => {
205
205
  let testDir: string;
206
+ const originalEnv = { ...process.env };
206
207
 
207
208
  beforeEach(() => {
209
+ // Disable pane detection in tests
210
+ delete process.env.TMUX;
211
+ delete process.env.TMT_AGENT_NAME;
212
+ delete process.env.TMUX_TEAM_ACTOR;
208
213
  testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'talk-test-'));
209
214
  });
210
215
 
211
216
  afterEach(() => {
217
+ process.env = { ...originalEnv };
212
218
  if (fs.existsSync(testDir)) {
213
219
  fs.rmSync(testDir, { recursive: true, force: true });
214
220
  }
@@ -235,6 +241,27 @@ describe('cmdTalk - basic send', () => {
235
241
  expect(tmux.sends.map((s) => s.pane).sort()).toEqual(['1.0', '1.1', '1.2']);
236
242
  });
237
243
 
244
+ it('skips self when sending to all (via env var)', async () => {
245
+ // Simulate being an agent via env var (when not in tmux)
246
+ const originalEnv = { ...process.env };
247
+ delete process.env.TMUX; // Ensure pane detection is disabled
248
+ process.env.TMT_AGENT_NAME = 'claude';
249
+
250
+ try {
251
+ const tmux = createMockTmux();
252
+ const ui = createMockUI();
253
+ const ctx = createContext({ tmux, ui, paths: createTestPaths(testDir) });
254
+
255
+ await cmdTalk(ctx, 'all', 'Hello team');
256
+
257
+ // Should skip claude (self) and only send to codex and gemini
258
+ expect(tmux.sends).toHaveLength(2);
259
+ expect(tmux.sends.map((s) => s.pane).sort()).toEqual(['1.1', '1.2']);
260
+ } finally {
261
+ process.env = originalEnv;
262
+ }
263
+ });
264
+
238
265
  it('removes exclamation marks for gemini agent', async () => {
239
266
  const tmux = createMockTmux();
240
267
  const ctx = createContext({ tmux, paths: createTestPaths(testDir) });
@@ -506,16 +533,144 @@ describe('cmdTalk - --wait mode', () => {
506
533
  }
507
534
  });
508
535
 
509
- 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
+
510
560
  const ui = createMockUI();
561
+ const paths = createTestPaths(testDir);
511
562
  const ctx = createContext({
512
563
  ui,
513
- paths: createTestPaths(testDir),
514
- 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
+ },
515
574
  });
516
575
 
517
- await expect(cmdTalk(ctx, 'all', 'Hello')).rejects.toThrow('exit(1)');
518
- expect(ui.errors[0]).toContain('Wait mode is not supported');
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
+ },
615
+ });
616
+
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]);
519
674
  });
520
675
  });
521
676