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.
@@ -2,12 +2,13 @@
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';
9
9
  import crypto from 'crypto';
10
10
  import { cleanupState, clearActiveRequest, setActiveRequest } from '../state.js';
11
+ import { resolveActor } from '../pm/permissions.js';
11
12
 
12
13
  function sleepMs(ms: number): Promise<void> {
13
14
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -26,6 +27,38 @@ function renderWaitLine(agent: string, elapsedSeconds: number): string {
26
27
  return `⏳ Waiting for ${agent}... (${s}s)`;
27
28
  }
28
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
+
29
62
  /**
30
63
  * Build the final message with optional preamble.
31
64
  * Format: [SYSTEM: <preamble>]\n\n<message>
@@ -53,11 +86,6 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
53
86
  const { ui, config, tmux, flags, exit } = ctx;
54
87
  const waitEnabled = Boolean(flags.wait) || config.mode === 'wait';
55
88
 
56
- if (waitEnabled && target === 'all') {
57
- ui.error("Wait mode is not supported with 'all' yet. Send to one agent at a time.");
58
- exit(ExitCodes.ERROR);
59
- }
60
-
61
89
  if (target === 'all') {
62
90
  const agents = Object.entries(config.paneRegistry);
63
91
  if (agents.length === 0) {
@@ -65,33 +93,59 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
65
93
  exit(ExitCodes.CONFIG_MISSING);
66
94
  }
67
95
 
96
+ // Determine current agent to skip self
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
+ }
103
+
68
104
  if (flags.delay && flags.delay > 0) {
69
105
  await sleepMs(flags.delay * 1000);
70
106
  }
71
107
 
72
- 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;
73
111
 
74
- for (const [name, data] of agents) {
75
- try {
76
- // Build message with preamble, then apply Gemini filter
77
- let msg = buildMessage(message, name, ctx);
78
- if (name === 'gemini') msg = msg.replace(/!/g, '');
79
- tmux.send(data.pane, msg);
80
- results.push({ agent: name, pane: data.pane, status: 'sent' });
112
+ if (!waitEnabled) {
113
+ // Non-wait mode: fire and forget
114
+ const results: { agent: string; pane: string; status: string }[] = [];
115
+
116
+ if (skippedSelf) {
117
+ const selfData = config.paneRegistry[self];
118
+ results.push({ agent: self, pane: selfData?.pane || '', status: 'skipped (self)' });
81
119
  if (!flags.json) {
82
- console.log(`${colors.green('')} Sent to ${colors.cyan(name)} (${data.pane})`);
120
+ console.log(`${colors.dim('')} Skipped ${colors.cyan(self)} (self)`);
83
121
  }
84
- } catch {
85
- results.push({ agent: name, pane: data.pane, status: 'failed' });
86
- if (!flags.json) {
87
- ui.warn(`Failed to send to ${name}`);
122
+ }
123
+
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
+ }
88
138
  }
89
139
  }
90
- }
91
140
 
92
- if (flags.json) {
93
- ui.json({ target: 'all', results });
141
+ if (flags.json) {
142
+ ui.json({ target: 'all', self, identityWarning, results });
143
+ }
144
+ return;
94
145
  }
146
+
147
+ // Wait mode: parallel polling
148
+ await cmdTalkAllWait(ctx, targetAgents, message, self, identityWarning, skippedSelf);
95
149
  return;
96
150
  }
97
151
 
@@ -259,3 +313,286 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
259
313
  clearActiveRequest(ctx.paths, target, requestId);
260
314
  }
261
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
@@ -5,7 +5,14 @@
5
5
  import fs from 'fs';
6
6
  import path from 'path';
7
7
  import os from 'os';
8
- import type { GlobalConfig, LocalConfig, ResolvedConfig, Paths } from './types.js';
8
+ import type {
9
+ GlobalConfig,
10
+ LocalConfig,
11
+ LocalConfigFile,
12
+ LocalSettings,
13
+ ResolvedConfig,
14
+ Paths,
15
+ } from './types.js';
9
16
 
10
17
  const CONFIG_FILENAME = 'config.json';
11
18
  const LOCAL_CONFIG_FILENAME = 'tmux-team.json';
@@ -16,7 +23,7 @@ const DEFAULT_CONFIG: Omit<GlobalConfig, 'agents'> & { agents: Record<string, ne
16
23
  mode: 'polling',
17
24
  preambleMode: 'always',
18
25
  defaults: {
19
- timeout: 60,
26
+ timeout: 180,
20
27
  pollInterval: 1,
21
28
  captureLines: 100,
22
29
  },
@@ -136,10 +143,20 @@ export function loadConfig(paths: Paths): ResolvedConfig {
136
143
  }
137
144
  }
138
145
 
139
- // Load local config (pane registry)
140
- const localConfig = loadJsonFile<LocalConfig>(paths.localConfig);
141
- if (localConfig) {
142
- config.paneRegistry = localConfig;
146
+ // Load local config (pane registry + optional settings)
147
+ const localConfigFile = loadJsonFile<LocalConfigFile>(paths.localConfig);
148
+ if (localConfigFile) {
149
+ // Extract local settings if present
150
+ const { $config: localSettings, ...paneEntries } = localConfigFile;
151
+
152
+ // Merge local settings (override global)
153
+ if (localSettings) {
154
+ if (localSettings.mode) config.mode = localSettings.mode;
155
+ if (localSettings.preambleMode) config.preambleMode = localSettings.preambleMode;
156
+ }
157
+
158
+ // Set pane registry (filter out $config)
159
+ config.paneRegistry = paneEntries as LocalConfig;
143
160
  }
144
161
 
145
162
  return config;
@@ -157,3 +174,50 @@ export function ensureGlobalDir(paths: Paths): void {
157
174
  fs.mkdirSync(paths.globalDir, { recursive: true });
158
175
  }
159
176
  }
177
+
178
+ /**
179
+ * Load raw global config file (for editing).
180
+ */
181
+ export function loadGlobalConfig(paths: Paths): Partial<GlobalConfig> {
182
+ return loadJsonFile<Partial<GlobalConfig>>(paths.globalConfig) ?? {};
183
+ }
184
+
185
+ /**
186
+ * Save global config file.
187
+ */
188
+ export function saveGlobalConfig(paths: Paths, config: Partial<GlobalConfig>): void {
189
+ ensureGlobalDir(paths);
190
+ fs.writeFileSync(paths.globalConfig, JSON.stringify(config, null, 2) + '\n');
191
+ }
192
+
193
+ /**
194
+ * Load raw local config file (for editing).
195
+ */
196
+ export function loadLocalConfigFile(paths: Paths): LocalConfigFile {
197
+ return loadJsonFile<LocalConfigFile>(paths.localConfig) ?? {};
198
+ }
199
+
200
+ /**
201
+ * Save local config file (preserves both $config and pane entries).
202
+ */
203
+ export function saveLocalConfigFile(paths: Paths, configFile: LocalConfigFile): void {
204
+ fs.writeFileSync(paths.localConfig, JSON.stringify(configFile, null, 2) + '\n');
205
+ }
206
+
207
+ /**
208
+ * Update local settings (creates $config if needed).
209
+ */
210
+ export function updateLocalSettings(paths: Paths, settings: LocalSettings): void {
211
+ const configFile = loadLocalConfigFile(paths);
212
+ configFile.$config = { ...configFile.$config, ...settings };
213
+ saveLocalConfigFile(paths, configFile);
214
+ }
215
+
216
+ /**
217
+ * Clear local settings.
218
+ */
219
+ export function clearLocalSettings(paths: Paths): void {
220
+ const configFile = loadLocalConfigFile(paths);
221
+ delete configFile.$config;
222
+ saveLocalConfigFile(paths, configFile);
223
+ }