tmux-team 1.0.0 → 2.0.0-alpha.1

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.
@@ -0,0 +1,261 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // talk command - send message to agent(s)
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import type { Context } from '../types.js';
6
+ import type { WaitResult } from '../types.js';
7
+ import { ExitCodes } from '../exits.js';
8
+ import { colors } from '../ui.js';
9
+ import crypto from 'crypto';
10
+ import { cleanupState, clearActiveRequest, setActiveRequest } from '../state.js';
11
+
12
+ function sleepMs(ms: number): Promise<void> {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ function makeRequestId(): string {
17
+ return `req_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`;
18
+ }
19
+
20
+ function makeNonce(): string {
21
+ return crypto.randomBytes(2).toString('hex');
22
+ }
23
+
24
+ function renderWaitLine(agent: string, elapsedSeconds: number): string {
25
+ const s = Math.max(0, Math.floor(elapsedSeconds));
26
+ return `⏳ Waiting for ${agent}... (${s}s)`;
27
+ }
28
+
29
+ /**
30
+ * Build the final message with optional preamble.
31
+ * Format: [SYSTEM: <preamble>]\n\n<message>
32
+ */
33
+ function buildMessage(message: string, agentName: string, ctx: Context): string {
34
+ const { config, flags } = ctx;
35
+
36
+ // Skip preamble if disabled or --no-preamble flag
37
+ if (config.preambleMode === 'disabled' || flags.noPreamble) {
38
+ return message;
39
+ }
40
+
41
+ // Get agent-specific preamble
42
+ const agentConfig = config.agents[agentName];
43
+ const preamble = agentConfig?.preamble;
44
+
45
+ if (!preamble) {
46
+ return message;
47
+ }
48
+
49
+ return `[SYSTEM: ${preamble}]\n\n${message}`;
50
+ }
51
+
52
+ export async function cmdTalk(ctx: Context, target: string, message: string): Promise<void> {
53
+ const { ui, config, tmux, flags, exit } = ctx;
54
+ const waitEnabled = Boolean(flags.wait) || config.mode === 'wait';
55
+
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
+ if (target === 'all') {
62
+ const agents = Object.entries(config.paneRegistry);
63
+ if (agents.length === 0) {
64
+ ui.error("No agents configured. Use 'tmux-team add' first.");
65
+ exit(ExitCodes.CONFIG_MISSING);
66
+ }
67
+
68
+ if (flags.delay && flags.delay > 0) {
69
+ await sleepMs(flags.delay * 1000);
70
+ }
71
+
72
+ const results: { agent: string; pane: string; status: string }[] = [];
73
+
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' });
81
+ if (!flags.json) {
82
+ console.log(`${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
83
+ }
84
+ } catch {
85
+ results.push({ agent: name, pane: data.pane, status: 'failed' });
86
+ if (!flags.json) {
87
+ ui.warn(`Failed to send to ${name}`);
88
+ }
89
+ }
90
+ }
91
+
92
+ if (flags.json) {
93
+ ui.json({ target: 'all', results });
94
+ }
95
+ return;
96
+ }
97
+
98
+ // Single agent
99
+ if (!config.paneRegistry[target]) {
100
+ const available = Object.keys(config.paneRegistry).join(', ');
101
+ ui.error(`Agent '${target}' not found. Available: ${available || 'none'}`);
102
+ exit(ExitCodes.PANE_NOT_FOUND);
103
+ }
104
+
105
+ const pane = config.paneRegistry[target].pane;
106
+
107
+ if (flags.delay && flags.delay > 0) {
108
+ await sleepMs(flags.delay * 1000);
109
+ }
110
+
111
+ if (!waitEnabled) {
112
+ try {
113
+ // Build message with preamble, then apply Gemini filter
114
+ let msg = buildMessage(message, target, ctx);
115
+ if (target === 'gemini') msg = msg.replace(/!/g, '');
116
+ tmux.send(pane, msg);
117
+
118
+ if (flags.json) {
119
+ ui.json({ target, pane, status: 'sent' });
120
+ } else {
121
+ console.log(`${colors.green('→')} Sent to ${colors.cyan(target)} (${pane})`);
122
+ }
123
+ return;
124
+ } catch {
125
+ ui.error(`Failed to send to pane ${pane}. Is tmux running?`);
126
+ exit(ExitCodes.ERROR);
127
+ }
128
+ }
129
+
130
+ // Wait mode
131
+ const timeoutSeconds = flags.timeout ?? config.defaults.timeout;
132
+ const pollIntervalSeconds = Math.max(0.1, config.defaults.pollInterval);
133
+ const captureLines = config.defaults.captureLines;
134
+
135
+ const requestId = makeRequestId();
136
+ const nonce = makeNonce();
137
+ const marker = `{tmux-team-end:${nonce}}`;
138
+
139
+ // Build message with preamble, then append nonce instruction
140
+ const messageWithPreamble = buildMessage(message, target, ctx);
141
+ const fullMessage = `${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${marker}]`;
142
+
143
+ // Best-effort cleanup and soft-lock warning
144
+ const state = cleanupState(ctx.paths, 60 * 60); // 1 hour TTL
145
+ const existing = state.requests[target];
146
+ if (existing && !flags.json && !flags.force) {
147
+ ui.warn(
148
+ `Another recent wait request exists for '${target}' (id: ${existing.id}). Results may interleave.`
149
+ );
150
+ }
151
+
152
+ let baseline = '';
153
+ try {
154
+ baseline = tmux.capture(pane, captureLines);
155
+ } catch {
156
+ ui.error(`Failed to capture pane ${pane}. Is tmux running?`);
157
+ exit(ExitCodes.ERROR);
158
+ }
159
+
160
+ setActiveRequest(ctx.paths, target, { id: requestId, nonce, pane, startedAtMs: Date.now() });
161
+
162
+ const startedAt = Date.now();
163
+ let lastNonTtyLogAt = 0;
164
+ const isTTY = process.stdout.isTTY && !flags.json;
165
+
166
+ const onSigint = (): void => {
167
+ clearActiveRequest(ctx.paths, target, requestId);
168
+ if (!flags.json) process.stdout.write('\n');
169
+ ui.error('Interrupted.');
170
+ exit(ExitCodes.ERROR);
171
+ };
172
+
173
+ process.once('SIGINT', onSigint);
174
+
175
+ try {
176
+ const msg = target === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
177
+ tmux.send(pane, msg);
178
+
179
+ while (true) {
180
+ const elapsedSeconds = (Date.now() - startedAt) / 1000;
181
+ if (elapsedSeconds >= timeoutSeconds) {
182
+ clearActiveRequest(ctx.paths, target, requestId);
183
+ if (flags.json) {
184
+ // Single JSON output with error field (don't call ui.error separately)
185
+ ui.json({
186
+ target,
187
+ pane,
188
+ status: 'timeout',
189
+ error: `Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s`,
190
+ requestId,
191
+ nonce,
192
+ marker,
193
+ });
194
+ exit(ExitCodes.TIMEOUT);
195
+ }
196
+ if (isTTY) {
197
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
198
+ }
199
+ ui.error(`Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s.`);
200
+ exit(ExitCodes.TIMEOUT);
201
+ }
202
+
203
+ if (!flags.json) {
204
+ if (isTTY) {
205
+ process.stdout.write('\r' + renderWaitLine(target, elapsedSeconds));
206
+ } else {
207
+ const now = Date.now();
208
+ if (now - lastNonTtyLogAt >= 5000) {
209
+ lastNonTtyLogAt = now;
210
+ console.error(
211
+ `[tmux-team] Waiting for ${target} (${Math.floor(elapsedSeconds)}s elapsed)`
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ await sleepMs(pollIntervalSeconds * 1000);
218
+
219
+ let output = '';
220
+ try {
221
+ output = tmux.capture(pane, captureLines);
222
+ } catch {
223
+ clearActiveRequest(ctx.paths, target, requestId);
224
+ ui.error(`Failed to capture pane ${pane}. Is tmux running?`);
225
+ exit(ExitCodes.ERROR);
226
+ }
227
+
228
+ const markerIndex = output.indexOf(marker);
229
+ if (markerIndex === -1) continue;
230
+
231
+ let startIndex = 0;
232
+ const baselineIndex = baseline ? output.lastIndexOf(baseline) : -1;
233
+ if (baselineIndex !== -1) {
234
+ startIndex = baselineIndex + baseline.length;
235
+ }
236
+
237
+ const response = output.slice(startIndex, markerIndex).trim();
238
+
239
+ if (!flags.json && isTTY) {
240
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
241
+ } else if (!flags.json) {
242
+ // Ensure the next output starts on a new line
243
+ process.stdout.write('\n');
244
+ }
245
+
246
+ clearActiveRequest(ctx.paths, target, requestId);
247
+
248
+ const result: WaitResult = { requestId, nonce, marker, response };
249
+ if (flags.json) {
250
+ ui.json({ target, pane, status: 'completed', ...result });
251
+ } else {
252
+ console.log(colors.cyan(`─── Response from ${target} (${pane}) ───`));
253
+ console.log(response);
254
+ }
255
+ return;
256
+ }
257
+ } finally {
258
+ process.removeListener('SIGINT', onSigint);
259
+ clearActiveRequest(ctx.paths, target, requestId);
260
+ }
261
+ }
@@ -0,0 +1,47 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // update command - modify agent config
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import type { Context } from '../types.js';
6
+ import { ExitCodes } from '../exits.js';
7
+ import { saveLocalConfig } from '../config.js';
8
+
9
+ export function cmdUpdate(
10
+ ctx: Context,
11
+ name: string,
12
+ options: { pane?: string; remark?: string }
13
+ ): void {
14
+ const { ui, config, paths, flags, exit } = ctx;
15
+
16
+ if (!config.paneRegistry[name]) {
17
+ ui.error(`Agent '${name}' not found. Use 'tmux-team add' to create.`);
18
+ exit(ExitCodes.PANE_NOT_FOUND);
19
+ }
20
+
21
+ if (!options.pane && !options.remark) {
22
+ ui.error('No updates specified. Use --pane or --remark.');
23
+ exit(ExitCodes.ERROR);
24
+ }
25
+
26
+ const updates: string[] = [];
27
+
28
+ if (options.pane) {
29
+ config.paneRegistry[name].pane = options.pane;
30
+ updates.push(`pane → ${options.pane}`);
31
+ }
32
+
33
+ if (options.remark) {
34
+ config.paneRegistry[name].remark = options.remark;
35
+ updates.push(`remark updated`);
36
+ }
37
+
38
+ saveLocalConfig(paths, config.paneRegistry);
39
+
40
+ if (flags.json) {
41
+ ui.json({ updated: name, ...options });
42
+ } else {
43
+ for (const update of updates) {
44
+ ui.success(`Updated '${name}': ${update}`);
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,246 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Config Tests - XDG path resolution and config hierarchy
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import { resolveGlobalDir, resolvePaths, loadConfig, ConfigParseError } from './config.js';
10
+
11
+ // Mock fs and os modules
12
+ vi.mock('fs');
13
+ vi.mock('os');
14
+
15
+ describe('resolveGlobalDir', () => {
16
+ const mockHome = '/home/testuser';
17
+
18
+ beforeEach(() => {
19
+ vi.mocked(os.homedir).mockReturnValue(mockHome);
20
+ vi.mocked(fs.existsSync).mockReturnValue(false);
21
+ // Clear environment variables
22
+ delete process.env.TMUX_TEAM_HOME;
23
+ delete process.env.XDG_CONFIG_HOME;
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ it('uses TMUX_TEAM_HOME when set (highest priority)', () => {
31
+ process.env.TMUX_TEAM_HOME = '/custom/tmux-team';
32
+ expect(resolveGlobalDir()).toBe('/custom/tmux-team');
33
+ });
34
+
35
+ it('uses XDG_CONFIG_HOME when set', () => {
36
+ process.env.XDG_CONFIG_HOME = '/custom/config';
37
+ expect(resolveGlobalDir()).toBe('/custom/config/tmux-team');
38
+ });
39
+
40
+ it('uses ~/.config/tmux-team when directory exists', () => {
41
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
42
+ return p === path.join(mockHome, '.config', 'tmux-team');
43
+ });
44
+ expect(resolveGlobalDir()).toBe(path.join(mockHome, '.config', 'tmux-team'));
45
+ });
46
+
47
+ it('uses legacy ~/.tmux-team when it exists and XDG does not', () => {
48
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
49
+ return p === path.join(mockHome, '.tmux-team');
50
+ });
51
+ expect(resolveGlobalDir()).toBe(path.join(mockHome, '.tmux-team'));
52
+ });
53
+
54
+ it('defaults to XDG style (~/.config/tmux-team) for new installs', () => {
55
+ // Neither path exists
56
+ vi.mocked(fs.existsSync).mockReturnValue(false);
57
+ expect(resolveGlobalDir()).toBe(path.join(mockHome, '.config', 'tmux-team'));
58
+ });
59
+
60
+ it('prefers path with config.json when both XDG and legacy exist', () => {
61
+ const xdgPath = path.join(mockHome, '.config', 'tmux-team');
62
+ const legacyPath = path.join(mockHome, '.tmux-team');
63
+ const legacyConfig = path.join(legacyPath, 'config.json');
64
+
65
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
66
+ // Both dirs exist, but only legacy has config.json
67
+ if (p === xdgPath || p === legacyPath) return true;
68
+ if (p === legacyConfig) return true;
69
+ return false;
70
+ });
71
+
72
+ expect(resolveGlobalDir()).toBe(legacyPath);
73
+ });
74
+
75
+ it('prefers XDG when both exist and both have config.json', () => {
76
+ const xdgPath = path.join(mockHome, '.config', 'tmux-team');
77
+ const legacyPath = path.join(mockHome, '.tmux-team');
78
+ const xdgConfig = path.join(xdgPath, 'config.json');
79
+ const legacyConfig = path.join(legacyPath, 'config.json');
80
+
81
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
82
+ // Both dirs exist with config.json
83
+ if (p === xdgPath || p === legacyPath) return true;
84
+ if (p === xdgConfig || p === legacyConfig) return true;
85
+ return false;
86
+ });
87
+
88
+ expect(resolveGlobalDir()).toBe(xdgPath);
89
+ });
90
+
91
+ it('prefers XDG when only XDG has config.json', () => {
92
+ const xdgPath = path.join(mockHome, '.config', 'tmux-team');
93
+ const legacyPath = path.join(mockHome, '.tmux-team');
94
+ const xdgConfig = path.join(xdgPath, 'config.json');
95
+
96
+ vi.mocked(fs.existsSync).mockImplementation((p) => {
97
+ if (p === xdgPath || p === legacyPath) return true;
98
+ if (p === xdgConfig) return true;
99
+ return false;
100
+ });
101
+
102
+ expect(resolveGlobalDir()).toBe(xdgPath);
103
+ });
104
+ });
105
+
106
+ describe('resolvePaths', () => {
107
+ const mockHome = '/home/testuser';
108
+ const mockCwd = '/projects/myapp';
109
+
110
+ beforeEach(() => {
111
+ vi.mocked(os.homedir).mockReturnValue(mockHome);
112
+ vi.mocked(fs.existsSync).mockReturnValue(false);
113
+ delete process.env.TMUX_TEAM_HOME;
114
+ delete process.env.XDG_CONFIG_HOME;
115
+ });
116
+
117
+ afterEach(() => {
118
+ vi.clearAllMocks();
119
+ });
120
+
121
+ it('returns correct path structure', () => {
122
+ const paths = resolvePaths(mockCwd);
123
+
124
+ expect(paths.globalDir).toBe(path.join(mockHome, '.config', 'tmux-team'));
125
+ expect(paths.globalConfig).toBe(path.join(mockHome, '.config', 'tmux-team', 'config.json'));
126
+ expect(paths.localConfig).toBe(path.join(mockCwd, 'tmux-team.json'));
127
+ expect(paths.stateFile).toBe(path.join(mockHome, '.config', 'tmux-team', 'state.json'));
128
+ });
129
+
130
+ it('uses TMUX_TEAM_HOME for global paths', () => {
131
+ process.env.TMUX_TEAM_HOME = '/custom/path';
132
+ const paths = resolvePaths(mockCwd);
133
+
134
+ expect(paths.globalDir).toBe('/custom/path');
135
+ expect(paths.globalConfig).toBe('/custom/path/config.json');
136
+ expect(paths.stateFile).toBe('/custom/path/state.json');
137
+ });
138
+ });
139
+
140
+ describe('loadConfig', () => {
141
+ const mockPaths = {
142
+ globalDir: '/home/test/.config/tmux-team',
143
+ globalConfig: '/home/test/.config/tmux-team/config.json',
144
+ localConfig: '/projects/myapp/tmux-team.json',
145
+ stateFile: '/home/test/.config/tmux-team/state.json',
146
+ };
147
+
148
+ beforeEach(() => {
149
+ vi.mocked(fs.existsSync).mockReturnValue(false);
150
+ });
151
+
152
+ afterEach(() => {
153
+ vi.clearAllMocks();
154
+ });
155
+
156
+ it('returns default config when no config files exist', () => {
157
+ const config = loadConfig(mockPaths);
158
+
159
+ expect(config.mode).toBe('polling');
160
+ expect(config.preambleMode).toBe('always');
161
+ expect(config.defaults.timeout).toBe(60);
162
+ expect(config.defaults.pollInterval).toBe(1);
163
+ expect(config.defaults.captureLines).toBe(100);
164
+ expect(config.agents).toEqual({});
165
+ expect(config.paneRegistry).toEqual({});
166
+ });
167
+
168
+ it('loads and merges global config', () => {
169
+ const globalConfig = {
170
+ mode: 'wait',
171
+ preambleMode: 'disabled',
172
+ defaults: { timeout: 120 },
173
+ agents: { claude: { preamble: 'Be helpful' } },
174
+ };
175
+
176
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
177
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(globalConfig));
178
+
179
+ const config = loadConfig(mockPaths);
180
+
181
+ expect(config.mode).toBe('wait');
182
+ expect(config.preambleMode).toBe('disabled');
183
+ expect(config.defaults.timeout).toBe(120);
184
+ expect(config.defaults.pollInterval).toBe(1); // Default preserved
185
+ expect(config.agents.claude?.preamble).toBe('Be helpful');
186
+ });
187
+
188
+ it('loads local pane registry from tmux-team.json', () => {
189
+ const localConfig = {
190
+ claude: { pane: '1.0', remark: 'Main assistant' },
191
+ codex: { pane: '1.1' },
192
+ };
193
+
194
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
195
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
196
+
197
+ const config = loadConfig(mockPaths);
198
+
199
+ expect(config.paneRegistry.claude?.pane).toBe('1.0');
200
+ expect(config.paneRegistry.claude?.remark).toBe('Main assistant');
201
+ expect(config.paneRegistry.codex?.pane).toBe('1.1');
202
+ });
203
+
204
+ it('merges both global and local config', () => {
205
+ const globalConfig = {
206
+ agents: { claude: { preamble: 'Be brief' } },
207
+ };
208
+ const localConfig = {
209
+ claude: { pane: '1.0' },
210
+ };
211
+
212
+ vi.mocked(fs.existsSync).mockReturnValue(true);
213
+ vi.mocked(fs.readFileSync).mockImplementation((p) => {
214
+ if (p === mockPaths.globalConfig) return JSON.stringify(globalConfig);
215
+ if (p === mockPaths.localConfig) return JSON.stringify(localConfig);
216
+ return '';
217
+ });
218
+
219
+ const config = loadConfig(mockPaths);
220
+
221
+ expect(config.agents.claude?.preamble).toBe('Be brief');
222
+ expect(config.paneRegistry.claude?.pane).toBe('1.0');
223
+ });
224
+
225
+ it('throws ConfigParseError on invalid JSON in config file', () => {
226
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
227
+ vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }');
228
+
229
+ expect(() => loadConfig(mockPaths)).toThrow(ConfigParseError);
230
+ });
231
+
232
+ it('ConfigParseError includes file path and cause', () => {
233
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
234
+ vi.mocked(fs.readFileSync).mockReturnValue('not json');
235
+
236
+ try {
237
+ loadConfig(mockPaths);
238
+ expect.fail('Should have thrown');
239
+ } catch (err) {
240
+ expect(err).toBeInstanceOf(ConfigParseError);
241
+ const parseError = err as ConfigParseError;
242
+ expect(parseError.filePath).toBe(mockPaths.globalConfig);
243
+ expect(parseError.cause).toBeDefined();
244
+ }
245
+ });
246
+ });