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

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.3",
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
@@ -17,6 +17,8 @@ import { cmdTalk } from './commands/talk.js';
17
17
  import { cmdCheck } from './commands/check.js';
18
18
  import { cmdCompletion } from './commands/completion.js';
19
19
  import { cmdPm } from './pm/commands.js';
20
+ import { cmdConfig } from './commands/config.js';
21
+ import { cmdPreamble } from './commands/preamble.js';
20
22
 
21
23
  // ─────────────────────────────────────────────────────────────
22
24
  // Argument parsing
@@ -193,6 +195,14 @@ function main(): void {
193
195
  await cmdPm(ctx, args);
194
196
  break;
195
197
 
198
+ case 'config':
199
+ cmdConfig(ctx, args);
200
+ break;
201
+
202
+ case 'preamble':
203
+ cmdPreamble(ctx, args);
204
+ break;
205
+
196
206
  default:
197
207
  ctx.ui.error(`Unknown command: ${command}. Run 'tmux-team help' for usage.`);
198
208
  ctx.exit(ExitCodes.ERROR);
@@ -0,0 +1,187 @@
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
+ const subcommand = args[0];
159
+
160
+ // Parse --global flag
161
+ const globalFlag = args.includes('--global') || args.includes('-g');
162
+ const filteredArgs = args.filter((a) => a !== '--global' && a !== '-g');
163
+
164
+ switch (subcommand) {
165
+ case undefined:
166
+ case 'show':
167
+ showConfig(ctx);
168
+ break;
169
+
170
+ case 'set':
171
+ if (filteredArgs.length < 3) {
172
+ ctx.ui.error('Usage: tmux-team config set <key> <value> [--global]');
173
+ ctx.exit(ExitCodes.ERROR);
174
+ }
175
+ setConfig(ctx, filteredArgs[1], filteredArgs[2], globalFlag);
176
+ break;
177
+
178
+ case 'clear':
179
+ clearConfig(ctx, filteredArgs[1]);
180
+ break;
181
+
182
+ default:
183
+ ctx.ui.error(`Unknown config subcommand: ${subcommand}`);
184
+ ctx.ui.error('Usage: tmux-team config [show|set|clear]');
185
+ ctx.exit(ExitCodes.ERROR);
186
+ }
187
+ }
@@ -20,6 +20,8 @@ ${colors.yellow('COMMANDS')}
20
20
  ${colors.green('update')} <name> [options] Update an agent's config
21
21
  ${colors.green('remove')} <name> Remove an agent
22
22
  ${colors.green('init')} Create empty tmux-team.json
23
+ ${colors.green('config')} [show|set|clear] View/modify settings
24
+ ${colors.green('preamble')} [show|set|clear] Manage agent preambles
23
25
  ${colors.green('pm')} <subcommand> Project management (run 'pm help')
24
26
  ${colors.green('completion')} Output shell completion script
25
27
  ${colors.green('help')} Show this help message
@@ -45,7 +47,20 @@ ${colors.yellow('EXAMPLES')}
45
47
  tmux-team remove codex
46
48
 
47
49
  ${colors.yellow('CONFIG')}
48
- Local: ./tmux-team.json (pane registry)
50
+ Local: ./tmux-team.json (pane registry + $config override)
49
51
  Global: ~/.config/tmux-team/config.json (settings)
52
+
53
+ ${colors.yellow('CONFIG EXAMPLES')}
54
+ tmux-team config Show current settings
55
+ tmux-team config set mode wait Set mode in local config (repo override)
56
+ tmux-team config set mode polling --global Set mode in global config
57
+ tmux-team config clear mode Clear local override for mode
58
+ tmux-team config clear Clear all local overrides
59
+
60
+ ${colors.yellow('PREAMBLE EXAMPLES')}
61
+ tmux-team preamble Show all preambles
62
+ tmux-team preamble show codex Show preamble for codex
63
+ tmux-team preamble set codex "You are a code reviewer. Be concise."
64
+ tmux-team preamble clear codex Clear preamble for codex
50
65
  `);
51
66
  }
@@ -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) });
@@ -8,6 +8,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));
@@ -65,6 +66,9 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
65
66
  exit(ExitCodes.CONFIG_MISSING);
66
67
  }
67
68
 
69
+ // Determine current agent to skip self
70
+ const { actor: self } = resolveActor(config.paneRegistry);
71
+
68
72
  if (flags.delay && flags.delay > 0) {
69
73
  await sleepMs(flags.delay * 1000);
70
74
  }
@@ -72,6 +76,15 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
72
76
  const results: { agent: string; pane: string; status: string }[] = [];
73
77
 
74
78
  for (const [name, data] of agents) {
79
+ // Skip sending to self
80
+ if (name === self) {
81
+ results.push({ agent: name, pane: data.pane, status: 'skipped (self)' });
82
+ if (!flags.json) {
83
+ console.log(`${colors.dim('○')} Skipped ${colors.cyan(name)} (self)`);
84
+ }
85
+ continue;
86
+ }
87
+
75
88
  try {
76
89
  // Build message with preamble, then apply Gemini filter
77
90
  let msg = buildMessage(message, name, ctx);
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';
@@ -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
+ }