tmux-team 2.0.0-alpha.3 → 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 +1 -1
- package/src/cli.ts +15 -3
- package/src/commands/config.ts +2 -3
- package/src/commands/help.ts +40 -23
- package/src/commands/talk.test.ts +133 -5
- package/src/commands/talk.ts +354 -30
- package/src/config.test.ts +1 -1
- package/src/config.ts +1 -1
- package/src/pm/commands.test.ts +4 -3
- package/src/pm/permissions.ts +5 -4
- package/src/pm/storage/github.ts +2 -2
package/package.json
CHANGED
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
|
|
107
|
+
// Help - load config to show current mode/timeout
|
|
107
108
|
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
108
|
-
|
|
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
|
|
package/src/commands/config.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/src/commands/help.ts
CHANGED
|
@@ -5,10 +5,30 @@
|
|
|
5
5
|
import { colors } from '../ui.js';
|
|
6
6
|
import { VERSION } from '../version.js';
|
|
7
7
|
|
|
8
|
-
export
|
|
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')}
|
|
35
|
-
${colors.green('--delay')} <seconds> Wait before sending
|
|
36
|
-
${colors.green('--wait')}
|
|
37
|
-
${colors.green('--timeout')} <seconds> Max wait time (
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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('
|
|
54
|
-
tmux-team config
|
|
55
|
-
tmux-team config set mode
|
|
56
|
-
tmux-team config set
|
|
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('
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
|
package/src/commands/talk.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
120
|
+
console.log(`${colors.dim('○')} Skipped ${colors.cyan(self)} (self)`);
|
|
84
121
|
}
|
|
85
|
-
continue;
|
|
86
122
|
}
|
|
87
123
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -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(
|
|
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
package/src/pm/commands.test.ts
CHANGED
|
@@ -770,9 +770,10 @@ describe('cmdPmList', () => {
|
|
|
770
770
|
await cmdPmList(ctx, []);
|
|
771
771
|
|
|
772
772
|
expect(ctx.ui.jsonData.length).toBe(1);
|
|
773
|
-
|
|
774
|
-
expect(
|
|
775
|
-
expect(
|
|
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
|
|
package/src/pm/permissions.ts
CHANGED
|
@@ -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 -
|
|
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:
|
|
181
|
-
source: '
|
|
182
|
-
warning: `⚠️ Unregistered pane:
|
|
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
|
|
package/src/pm/storage/github.ts
CHANGED
|
@@ -53,7 +53,7 @@ interface GHIssue {
|
|
|
53
53
|
interface GHMilestone {
|
|
54
54
|
number: number;
|
|
55
55
|
title: string;
|
|
56
|
-
state: '
|
|
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 === '
|
|
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
|
};
|