tmux-team 3.3.0 → 4.0.0-beta.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.
package/README.md CHANGED
@@ -64,6 +64,16 @@ Find pane IDs with: `tmux display-message -p "#{pane_id}"`
64
64
 
65
65
  Run `tmt help` for all commands and options.
66
66
 
67
+ ## Message Delivery
68
+
69
+ tmux-team uses tmux buffers + paste, then waits briefly before sending Enter. This avoids shell history expansion and handles paste-safety windows in CLIs like Gemini.
70
+
71
+ **Config:** `pasteEnterDelayMs` (default: 500)
72
+
73
+ ```bash
74
+ tmt config set pasteEnterDelayMs 500
75
+ ```
76
+
67
77
  ## Managing Your Team
68
78
 
69
79
  Configuration lives in `tmux-team.json` in your project root.
@@ -76,6 +86,7 @@ tmt ls
76
86
  **Edit** - Modify `tmux-team.json` directly:
77
87
  ```json
78
88
  {
89
+ "$config": { "pasteEnterDelayMs": 500 },
79
90
  "codex": { "pane": "1.1", "remark": "Code reviewer" },
80
91
  "gemini": { "pane": "1.2", "remark": "Documentation" }
81
92
  }
@@ -86,28 +97,125 @@ tmt ls
86
97
  tmt rm codex
87
98
  ```
88
99
 
89
- ## Claude Code Plugin
100
+ ---
101
+
102
+ ## Shared Teams
103
+
104
+ > *Work on different folders but talk to the same team of agents.*
105
+
106
+ By default, `tmux-team.json` is local to each folder. The `--team` flag lets agents across different folders share a team:
107
+
108
+ ```bash
109
+ # Initialize a shared team
110
+ tmt init --team myproject
111
+
112
+ # Register agents from ANY folder
113
+ cd ~/code/frontend && tmt this claude --team myproject
114
+ cd ~/code/backend && tmt this codex --team myproject
115
+ cd ~/code/infra && tmt this gemini --team myproject
116
+
117
+ # Now talk to them from anywhere
118
+ tmt talk codex "What's the user API schema?" --team myproject
119
+ tmt talk all "Starting deploy - heads up" --team myproject
120
+ ```
121
+
122
+ > **Tip:** Most AI coding agents (Claude Code, Codex, Gemini CLI) support `!` to run shell commands. Agents can register themselves without leaving the session:
123
+ > ```
124
+ > !tmt this claude --team myproject
125
+ > ```
126
+
127
+ ### When to use shared teams
128
+
129
+ **Single project** (default) — agents work in the same folder:
130
+ ```bash
131
+ tmt init
132
+ tmt this claude
133
+ tmt add codex 1.1
134
+ ```
135
+
136
+ **Shared team** — agents work across folders but collaborate:
137
+ ```bash
138
+ tmt init --team acme-app
139
+ tmt this frontend-claude --team acme-app # from ~/acme/frontend
140
+ tmt this backend-codex --team acme-app # from ~/acme/backend
141
+ ```
142
+
143
+ ### Multi-team coordination
144
+
145
+ For large systems, create team hierarchies where leaders coordinate sub-teams:
146
+
147
+ ```mermaid
148
+ flowchart
149
+
150
+ A["you (claude)"]
151
+ A2["codex"]
152
+ A3["gemini"]
153
+ B["backend-lead"]
154
+ B2["codex"]
155
+ C["infra-lead"]
156
+ C2["codex"]
157
+
158
+ subgraph your-team
159
+ A <--> A2
160
+ A <--> A3
161
+ end
162
+
163
+ A e1@<--> B
164
+ A e2@<--> C
165
+
166
+ e1@{ animate: true }
167
+ e2@{ animate: true }
168
+
169
+ subgraph backend-team
170
+ B <--> B2
171
+ end
172
+
173
+ subgraph infra-team
174
+ C <--> C2
175
+ end
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Using /team in Claude Code
181
+
182
+ The `/team` command lets Claude talk to other AI agents directly. Install the plugin:
90
183
 
91
184
  ```
92
185
  /plugin marketplace add wkh237/tmux-team
93
186
  /plugin install tmux-team
94
187
  ```
95
188
 
96
- Gives you two slash commands:
189
+ ### /team Commands
190
+
191
+ | Command | What it does |
192
+ |---------|--------------|
193
+ | `/team list` | Show all registered agents |
194
+ | `/team talk <agent> "msg"` | Send a message and wait for response |
195
+ | `/team talk all "msg"` | Broadcast to all agents |
196
+
197
+ ### Real-World Examples
97
198
 
98
- **`/learn`** - Teach Claude how to use tmux-team
199
+ **Code review delegation:**
99
200
  ```
100
- /learn
201
+ /team talk codex "Review my changes in src/auth/ for security issues"
101
202
  ```
102
- Run this once when starting a session. Claude will understand how to coordinate with other agents.
103
203
 
104
- **`/team`** - Talk to other agents
204
+ **Cross-agent coordination:**
105
205
  ```
106
- /team talk codex "Review my authentication changes"
107
- /team talk all "I'm starting the database migration"
108
- /team list
206
+ /team talk all "Starting database migration - hold off on API changes"
109
207
  ```
110
- Use this to delegate tasks, ask for reviews, or broadcast updates.
208
+
209
+ **Ask a specialist:**
210
+ ```
211
+ /team talk gemini "What's the best practice for rate limiting in GCP?"
212
+ ```
213
+
214
+ ### Tips
215
+
216
+ - `/team talk` waits for the agent to respond before continuing
217
+ - Use `/team list` to see who's available
218
+ - Run `/learn` once per session to teach Claude the full tmux-team workflow
111
219
 
112
220
  ## Learn More
113
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-team",
3
- "version": "3.3.0",
3
+ "version": "4.0.0-beta.1",
4
4
  "description": "CLI tool for AI agent collaboration in tmux - manage cross-pane communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "tmt": "./bin/tmux-team",
13
13
  "test": "pnpm test:run",
14
14
  "test:watch": "vitest",
15
- "test:run": "vitest run --coverage && node scripts/check-coverage.mjs --threshold 95",
15
+ "test:run": "vitest run --coverage && node scripts/check-coverage.mjs --threshold 90 --branches 85",
16
16
  "lint": "oxlint src/",
17
17
  "lint:fix": "oxlint src/ --fix",
18
18
  "format": "prettier --write src/",
@@ -39,8 +39,8 @@ tmux-team list
39
39
 
40
40
  ## Notes
41
41
 
42
- - `talk` automatically sends Enter key after the message
43
- - `talk` automatically filters exclamation marks for Gemini (TTY issue)
42
+ - `talk` sends via tmux buffer paste, then waits briefly before Enter
43
+ - Control the delay with `pasteEnterDelayMs` in config (default: 500)
44
44
  - Use `--delay` instead of sleep (safer for tool whitelists)
45
45
  - Use `--wait` for synchronous request-response patterns
46
46
  - Run `tmux-team help` for full CLI documentation
@@ -39,8 +39,8 @@ tmux-team list
39
39
 
40
40
  ## Notes
41
41
 
42
- - `talk` automatically sends Enter key after the message
43
- - `talk` automatically filters exclamation marks for Gemini (TTY issue)
42
+ - `talk` sends via tmux buffer paste, then waits briefly before Enter
43
+ - Control the delay with `pasteEnterDelayMs` in config (default: 500)
44
44
  - Use `--delay` instead of sleep (safer for tool whitelists)
45
45
  - Use `--wait` for synchronous request-response patterns
46
46
  - Run `tmux-team help` for full CLI documentation
package/src/cli.test.ts CHANGED
@@ -16,7 +16,7 @@ function makeStubContext(): Context {
16
16
  config: {
17
17
  mode: 'polling',
18
18
  preambleMode: 'always',
19
- defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 },
19
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 },
20
20
  agents: {},
21
21
  paneRegistry: {},
22
22
  },
package/src/cli.ts CHANGED
@@ -59,6 +59,10 @@ function parseArgs(argv: string[]): { command: string; args: string[]; flags: Fl
59
59
  flags.lines = parseInt(argv[++i], 10) || 100;
60
60
  } else if (arg === '--no-preamble') {
61
61
  flags.noPreamble = true;
62
+ } else if (arg === '--team') {
63
+ flags.team = argv[++i];
64
+ } else if (arg.startsWith('--team=')) {
65
+ flags.team = arg.slice(7);
62
66
  } else if (arg.startsWith('--pane=')) {
63
67
  // Handled in update command
64
68
  positional.push(arg);
@@ -5,16 +5,25 @@
5
5
  import fs from 'fs';
6
6
  import type { Context, PaneEntry } from '../types.js';
7
7
  import { ExitCodes } from '../exits.js';
8
- import { loadLocalConfigFile, saveLocalConfigFile } from '../config.js';
8
+ import { loadLocalConfigFile, saveLocalConfigFile, ensureTeamsDir } from '../config.js';
9
9
 
10
10
  export function cmdAdd(ctx: Context, name: string, pane: string, remark?: string): void {
11
11
  const { ui, config, paths, flags, exit } = ctx;
12
12
 
13
+ // Ensure teams directory exists if using --team
14
+ if (flags.team) {
15
+ ensureTeamsDir(paths.globalDir);
16
+ }
17
+
13
18
  // Create config file if it doesn't exist
14
19
  if (!fs.existsSync(paths.localConfig)) {
15
20
  fs.writeFileSync(paths.localConfig, '{}\n');
16
21
  if (!flags.json) {
17
- ui.info(`Created ${paths.localConfig}`);
22
+ if (flags.team) {
23
+ ui.info(`Created shared team "${flags.team}"`);
24
+ } else {
25
+ ui.info(`Created ${paths.localConfig}`);
26
+ }
18
27
  }
19
28
  }
20
29
 
@@ -35,8 +44,12 @@ export function cmdAdd(ctx: Context, name: string, pane: string, remark?: string
35
44
  saveLocalConfigFile(paths, localConfig);
36
45
 
37
46
  if (flags.json) {
38
- ui.json({ added: name, pane, remark });
47
+ ui.json({ added: name, pane, remark, team: flags.team });
39
48
  } else {
40
- ui.success(`Added agent '${name}' at pane ${pane}`);
49
+ if (flags.team) {
50
+ ui.success(`Added agent '${name}' to team "${flags.team}" at pane ${pane}`);
51
+ } else {
52
+ ui.success(`Added agent '${name}' at pane ${pane}`);
53
+ }
41
54
  }
42
55
  }
@@ -54,7 +54,7 @@ function createCtx(
54
54
  const baseConfig: ResolvedConfig = {
55
55
  mode: 'polling',
56
56
  preambleMode: 'always',
57
- defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 },
57
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 },
58
58
  agents: {},
59
59
  paneRegistry: {},
60
60
  ...overrides?.config,
@@ -355,7 +355,9 @@ describe('basic commands', () => {
355
355
  it('cmdConfig clear errors on invalid key', () => {
356
356
  const ctx = createCtx(testDir);
357
357
  expect(() => cmdConfig(ctx, ['clear', 'invalidkey'])).toThrow(`exit(${ExitCodes.ERROR})`);
358
- expect(ctx.ui.error).toHaveBeenCalledWith('Invalid key: invalidkey. Valid keys: mode, preambleMode, preambleEvery');
358
+ expect(ctx.ui.error).toHaveBeenCalledWith(
359
+ 'Invalid key: invalidkey. Valid keys: mode, preambleMode, preambleEvery, pasteEnterDelayMs'
360
+ );
359
361
  });
360
362
 
361
363
  it('cmdCompletion prints scripts', () => {
@@ -34,7 +34,14 @@ function createCtx(
34
34
  const config: ResolvedConfig = {
35
35
  mode: 'polling',
36
36
  preambleMode: 'always',
37
- defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 },
37
+ defaults: {
38
+ timeout: 180,
39
+ pollInterval: 1,
40
+ captureLines: 100,
41
+ maxCaptureLines: 2000,
42
+ preambleEvery: 3,
43
+ pasteEnterDelayMs: 500,
44
+ },
38
45
  agents: {},
39
46
  paneRegistry: {},
40
47
  ...configOverrides,
@@ -14,11 +14,11 @@ import {
14
14
  } from '../config.js';
15
15
 
16
16
  type EnumConfigKey = 'mode' | 'preambleMode';
17
- type NumericConfigKey = 'preambleEvery';
17
+ type NumericConfigKey = 'preambleEvery' | 'pasteEnterDelayMs';
18
18
  type ConfigKey = EnumConfigKey | NumericConfigKey;
19
19
 
20
20
  const ENUM_KEYS: EnumConfigKey[] = ['mode', 'preambleMode'];
21
- const NUMERIC_KEYS: NumericConfigKey[] = ['preambleEvery'];
21
+ const NUMERIC_KEYS: NumericConfigKey[] = ['preambleEvery', 'pasteEnterDelayMs'];
22
22
  const VALID_KEYS: ConfigKey[] = [...ENUM_KEYS, ...NUMERIC_KEYS];
23
23
 
24
24
  const VALID_VALUES: Record<EnumConfigKey, string[]> = {
@@ -56,6 +56,7 @@ function showConfig(ctx: Context): void {
56
56
  mode: ctx.config.mode,
57
57
  preambleMode: ctx.config.preambleMode,
58
58
  preambleEvery: ctx.config.defaults.preambleEvery,
59
+ pasteEnterDelayMs: ctx.config.defaults.pasteEnterDelayMs,
59
60
  defaults: ctx.config.defaults,
60
61
  },
61
62
  sources: {
@@ -71,6 +72,12 @@ function showConfig(ctx: Context): void {
71
72
  : globalConfig.defaults?.preambleEvery !== undefined
72
73
  ? 'global'
73
74
  : 'default',
75
+ pasteEnterDelayMs:
76
+ localSettings?.pasteEnterDelayMs !== undefined
77
+ ? 'local'
78
+ : globalConfig.defaults?.pasteEnterDelayMs !== undefined
79
+ ? 'global'
80
+ : 'default',
74
81
  },
75
82
  paths: {
76
83
  global: ctx.paths.globalConfig,
@@ -93,6 +100,12 @@ function showConfig(ctx: Context): void {
93
100
  : globalConfig.defaults?.preambleEvery !== undefined
94
101
  ? '(global)'
95
102
  : '(default)';
103
+ const pasteEnterDelaySource =
104
+ localSettings?.pasteEnterDelayMs !== undefined
105
+ ? '(local)'
106
+ : globalConfig.defaults?.pasteEnterDelayMs !== undefined
107
+ ? '(global)'
108
+ : '(default)';
96
109
 
97
110
  ctx.ui.info('Current configuration:\n');
98
111
  ctx.ui.table(
@@ -101,6 +114,11 @@ function showConfig(ctx: Context): void {
101
114
  ['mode', ctx.config.mode, modeSource],
102
115
  ['preambleMode', ctx.config.preambleMode, preambleSource],
103
116
  ['preambleEvery', String(ctx.config.defaults.preambleEvery), preambleEverySource],
117
+ [
118
+ 'pasteEnterDelayMs',
119
+ String(ctx.config.defaults.pasteEnterDelayMs),
120
+ pasteEnterDelaySource,
121
+ ],
104
122
  ['defaults.timeout', String(ctx.config.defaults.timeout), '(global)'],
105
123
  ['defaults.pollInterval', String(ctx.config.defaults.pollInterval), '(global)'],
106
124
  ['defaults.captureLines', String(ctx.config.defaults.captureLines), '(global)'],
@@ -157,10 +175,24 @@ function setConfig(ctx: Context, key: string, value: string, global: boolean): v
157
175
  captureLines: 100,
158
176
  maxCaptureLines: 2000,
159
177
  preambleEvery: parseInt(value, 10),
178
+ pasteEnterDelayMs: 500,
160
179
  };
161
180
  } else {
162
181
  globalConfig.defaults.preambleEvery = parseInt(value, 10);
163
182
  }
183
+ } else if (key === 'pasteEnterDelayMs') {
184
+ if (!globalConfig.defaults) {
185
+ globalConfig.defaults = {
186
+ timeout: 180,
187
+ pollInterval: 1,
188
+ captureLines: 100,
189
+ maxCaptureLines: 2000,
190
+ preambleEvery: 3,
191
+ pasteEnterDelayMs: parseInt(value, 10),
192
+ };
193
+ } else {
194
+ globalConfig.defaults.pasteEnterDelayMs = parseInt(value, 10);
195
+ }
164
196
  }
165
197
  saveGlobalConfig(ctx.paths, globalConfig);
166
198
  ctx.ui.success(`Set ${key}=${value} in global config`);
@@ -172,6 +204,8 @@ function setConfig(ctx: Context, key: string, value: string, global: boolean): v
172
204
  updateLocalSettings(ctx.paths, { preambleMode: value as 'always' | 'disabled' });
173
205
  } else if (key === 'preambleEvery') {
174
206
  updateLocalSettings(ctx.paths, { preambleEvery: parseInt(value, 10) });
207
+ } else if (key === 'pasteEnterDelayMs') {
208
+ updateLocalSettings(ctx.paths, { pasteEnterDelayMs: parseInt(value, 10) });
175
209
  }
176
210
  ctx.ui.success(`Set ${key}=${value} in local config (repo override)`);
177
211
  }
@@ -64,6 +64,7 @@ ${colors.yellow('OPTIONS')}
64
64
  ${colors.green('--json')} Output in JSON format
65
65
  ${colors.green('--verbose')} Show detailed output
66
66
  ${colors.green('--force')} Skip warnings
67
+ ${colors.green('--team')} <name> Use shared team (cross-folder)
67
68
 
68
69
  ${colors.yellow('TALK OPTIONS')}
69
70
  ${colors.green('--delay')} <seconds> Wait before sending
@@ -90,6 +91,7 @@ ${colors.yellow('EXAMPLES')}${
90
91
  ${colors.yellow('CONFIG')}
91
92
  Local: ./tmux-team.json (pane registry + $config override)
92
93
  Global: ~/.config/tmux-team/config.json (settings)
94
+ Teams: ~/.config/tmux-team/teams/<name>.json (shared teams)
93
95
 
94
96
  ${colors.yellow('CHANGE MODE')}
95
97
  tmux-team config set mode wait ${colors.dim('Enable wait mode (local)')}
@@ -1,14 +1,20 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // init command - create tmux-team.json
2
+ // init command - create tmux-team.json or shared team config
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
5
  import fs from 'fs';
6
6
  import type { Context } from '../types.js';
7
7
  import { ExitCodes } from '../exits.js';
8
+ import { ensureTeamsDir } from '../config.js';
8
9
 
9
10
  export function cmdInit(ctx: Context): void {
10
11
  const { ui, paths, flags, exit } = ctx;
11
12
 
13
+ // Ensure teams directory exists if using --team
14
+ if (flags.team) {
15
+ ensureTeamsDir(paths.globalDir);
16
+ }
17
+
12
18
  if (fs.existsSync(paths.localConfig)) {
13
19
  ui.error(`${paths.localConfig} already exists. Remove it first if you want to reinitialize.`);
14
20
  exit(ExitCodes.ERROR);
@@ -17,8 +23,12 @@ export function cmdInit(ctx: Context): void {
17
23
  fs.writeFileSync(paths.localConfig, '{}\n');
18
24
 
19
25
  if (flags.json) {
20
- ui.json({ created: paths.localConfig });
26
+ ui.json({ created: paths.localConfig, team: flags.team });
21
27
  } else {
22
- ui.success(`Created ${paths.localConfig}`);
28
+ if (flags.team) {
29
+ ui.success(`Created shared team "${flags.team}" at ${paths.localConfig}`);
30
+ } else {
31
+ ui.success(`Created ${paths.localConfig}`);
32
+ }
23
33
  }
24
34
  }
@@ -26,7 +26,7 @@ function createCtx(testDir: string, overrides?: Partial<{ flags: Partial<Flags>
26
26
  const config: ResolvedConfig = {
27
27
  mode: 'polling',
28
28
  preambleMode: 'always',
29
- defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 },
29
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 },
30
30
  agents: {},
31
31
  paneRegistry: {},
32
32
  };
@@ -17,8 +17,8 @@ ${colors.yellow('WHAT IS TMUX-TEAM?')}
17
17
  ${colors.yellow('CORE CONCEPT')}
18
18
 
19
19
  Each agent runs in its own tmux pane. When you talk to another agent:
20
- 1. Your message is sent to their pane via tmux send-keys
21
- 2. They see it as if a human typed it
20
+ 1. Your message is pasted via a tmux buffer
21
+ 2. tmux-team waits briefly, then sends Enter to submit
22
22
  3. You read their response by capturing their pane output
23
23
 
24
24
  ${colors.yellow('ESSENTIAL COMMANDS')}
@@ -59,7 +59,7 @@ ${colors.yellow('CONFIGURATION')}
59
59
  Config file: ${colors.cyan('./tmux-team.json')}
60
60
 
61
61
  {
62
- "$config": { "mode": "wait" },
62
+ "$config": { "mode": "wait", "pasteEnterDelayMs": 500 },
63
63
  "codex": { "pane": "%1", "remark": "Code reviewer" },
64
64
  "gemini": { "pane": "%2", "remark": "Documentation" }
65
65
  }
@@ -9,16 +9,24 @@ export function cmdList(ctx: Context): void {
9
9
  const agents = Object.entries(config.paneRegistry);
10
10
 
11
11
  if (flags.json) {
12
- ui.json(config.paneRegistry);
12
+ ui.json({ team: flags.team, agents: config.paneRegistry });
13
13
  return;
14
14
  }
15
15
 
16
16
  if (agents.length === 0) {
17
- ui.info("No agents configured. Use 'tmux-team add <name> <pane>' to add one.");
17
+ if (flags.team) {
18
+ ui.info(`No agents in team "${flags.team}". Use 'tmt this <name> --team ${flags.team}' to add one.`);
19
+ } else {
20
+ ui.info("No agents configured. Use 'tmux-team add <name> <pane>' to add one.");
21
+ }
18
22
  return;
19
23
  }
20
24
 
21
25
  console.log();
26
+ if (flags.team) {
27
+ console.log(`Team: ${flags.team}`);
28
+ console.log();
29
+ }
22
30
  ui.table(
23
31
  ['NAME', 'PANE', 'REMARK'],
24
32
  agents.map(([name, data]) => [name, data.pane, data.remark || '-'])
@@ -48,7 +48,7 @@ function createDefaultConfig(): ResolvedConfig {
48
48
  pollInterval: 1,
49
49
  captureLines: 100,
50
50
  maxCaptureLines: 2000,
51
- preambleEvery: 3,
51
+ preambleEvery: 3, pasteEnterDelayMs: 500,
52
52
  },
53
53
  agents: {},
54
54
  paneRegistry: {
@@ -81,6 +81,7 @@ function createDefaultConfig(): ResolvedConfig {
81
81
  captureLines: 100,
82
82
  maxCaptureLines: 2000,
83
83
  preambleEvery: 3,
84
+ pasteEnterDelayMs: 500,
84
85
  },
85
86
  agents: {},
86
87
  paneRegistry: {
@@ -238,7 +239,7 @@ describe('buildMessage (via cmdTalk)', () => {
238
239
  pollInterval: 0.1,
239
240
  captureLines: 100,
240
241
  maxCaptureLines: 2000,
241
- preambleEvery: 3,
242
+ preambleEvery: 3, pasteEnterDelayMs: 500,
242
243
  },
243
244
  };
244
245
 
@@ -276,6 +277,7 @@ describe('buildMessage (via cmdTalk)', () => {
276
277
  captureLines: 100,
277
278
  maxCaptureLines: 2000,
278
279
  preambleEvery: 1,
280
+ pasteEnterDelayMs: 500,
279
281
  },
280
282
  };
281
283
 
@@ -300,6 +302,7 @@ describe('buildMessage (via cmdTalk)', () => {
300
302
  captureLines: 100,
301
303
  maxCaptureLines: 2000,
302
304
  preambleEvery: 0,
305
+ pasteEnterDelayMs: 500,
303
306
  },
304
307
  };
305
308
 
@@ -425,14 +428,14 @@ describe('cmdTalk - basic send', () => {
425
428
  }
426
429
  });
427
430
 
428
- it('removes exclamation marks for gemini agent', async () => {
431
+ it('preserves exclamation marks for gemini agent', async () => {
429
432
  const tmux = createMockTmux();
430
433
  const ctx = createContext({ tmux, paths: createTestPaths(testDir) });
431
434
 
432
435
  await cmdTalk(ctx, 'gemini', 'Hello! This is exciting!');
433
436
 
434
437
  expect(tmux.sends).toHaveLength(1);
435
- expect(tmux.sends[0].message).toBe('Hello This is exciting');
438
+ expect(tmux.sends[0].message).toBe('Hello! This is exciting!');
436
439
  });
437
440
 
438
441
  it('exits with error for unknown agent', async () => {
@@ -548,7 +551,7 @@ describe('cmdTalk - --wait mode', () => {
548
551
  pollInterval: 0.01,
549
552
  captureLines: 100,
550
553
  maxCaptureLines: 2000,
551
- preambleEvery: 3,
554
+ preambleEvery: 3, pasteEnterDelayMs: 500,
552
555
  },
553
556
  },
554
557
  });
@@ -592,7 +595,7 @@ describe('cmdTalk - --wait mode', () => {
592
595
  pollInterval: 0.01,
593
596
  captureLines: 100,
594
597
  maxCaptureLines: 2000,
595
- preambleEvery: 3,
598
+ preambleEvery: 3, pasteEnterDelayMs: 500,
596
599
  },
597
600
  },
598
601
  });
@@ -623,7 +626,7 @@ describe('cmdTalk - --wait mode', () => {
623
626
  pollInterval: 0.02,
624
627
  captureLines: 100,
625
628
  maxCaptureLines: 2000,
626
- preambleEvery: 3,
629
+ preambleEvery: 3, pasteEnterDelayMs: 500,
627
630
  },
628
631
  },
629
632
  });
@@ -671,7 +674,7 @@ describe('cmdTalk - --wait mode', () => {
671
674
  pollInterval: 0.01,
672
675
  captureLines: 100,
673
676
  maxCaptureLines: 2000,
674
- preambleEvery: 3,
677
+ preambleEvery: 3, pasteEnterDelayMs: 500,
675
678
  },
676
679
  },
677
680
  });
@@ -707,7 +710,7 @@ describe('cmdTalk - --wait mode', () => {
707
710
  pollInterval: 0.01,
708
711
  captureLines: 100,
709
712
  maxCaptureLines: 2000,
710
- preambleEvery: 3,
713
+ preambleEvery: 3, pasteEnterDelayMs: 500,
711
714
  },
712
715
  },
713
716
  });
@@ -736,7 +739,7 @@ describe('cmdTalk - --wait mode', () => {
736
739
  pollInterval: 0.01,
737
740
  captureLines: 100,
738
741
  maxCaptureLines: 2000,
739
- preambleEvery: 3,
742
+ preambleEvery: 3, pasteEnterDelayMs: 500,
740
743
  },
741
744
  },
742
745
  });
@@ -758,22 +761,19 @@ describe('cmdTalk - --wait mode', () => {
758
761
  // Create mock tmux that returns markers for each agent after a delay
759
762
  const tmux = createMockTmux();
760
763
  let captureCount = 0;
761
- const noncesByPane: Record<string, string> = {};
762
-
763
- // Mock send to capture the nonce for each pane
764
- tmux.send = (pane: string, msg: string) => {
765
- const match = msg.match(INSTRUCTION_NONCE_REGEX);
766
- if (match) {
767
- noncesByPane[pane] = match[1];
768
- }
764
+ const getNonceForPane = (pane: string): string | undefined => {
765
+ const sent = tmux.sends.find((s) => s.pane === pane)?.message ?? '';
766
+ const match = String(sent).match(INSTRUCTION_NONCE_REGEX);
767
+ return match?.[1];
769
768
  };
770
769
 
771
770
  // Mock capture to return complete response after first poll
772
771
  tmux.capture = (pane: string) => {
773
772
  captureCount++;
774
773
  // Return complete response on second capture for each pane
775
- if (captureCount > 3 && noncesByPane[pane]) {
776
- return mockCompleteResponse(noncesByPane[pane], 'Response from agent');
774
+ const nonce = getNonceForPane(pane);
775
+ if (captureCount > 3 && nonce) {
776
+ return mockCompleteResponse(nonce, 'Response from agent');
777
777
  }
778
778
  return 'working...';
779
779
  };
@@ -791,7 +791,7 @@ describe('cmdTalk - --wait mode', () => {
791
791
  pollInterval: 0.05,
792
792
  captureLines: 100,
793
793
  maxCaptureLines: 2000,
794
- preambleEvery: 3,
794
+ preambleEvery: 3, pasteEnterDelayMs: 500,
795
795
  },
796
796
  paneRegistry: {
797
797
  codex: { pane: '10.1' },
@@ -808,19 +808,17 @@ describe('cmdTalk - --wait mode', () => {
808
808
 
809
809
  it('handles partial timeout in wait mode with all target', async () => {
810
810
  const tmux = createMockTmux();
811
- const noncesByPane: Record<string, string> = {};
812
-
813
- tmux.send = (pane: string, msg: string) => {
814
- const match = msg.match(INSTRUCTION_NONCE_REGEX);
815
- if (match) {
816
- noncesByPane[pane] = match[1];
817
- }
811
+ const getNonceForPane = (pane: string): string | undefined => {
812
+ const sent = tmux.sends.find((s) => s.pane === pane)?.message ?? '';
813
+ const match = String(sent).match(INSTRUCTION_NONCE_REGEX);
814
+ return match?.[1];
818
815
  };
819
816
 
820
817
  // Only pane 10.1 responds with end marker, 10.2 never has end marker
821
818
  tmux.capture = (pane: string) => {
822
- if (pane === '10.1' && noncesByPane[pane]) {
823
- return mockCompleteResponse(noncesByPane[pane], 'Response from codex');
819
+ const nonce = getNonceForPane(pane);
820
+ if (pane === '10.1' && nonce) {
821
+ return mockCompleteResponse(nonce, 'Response from codex');
824
822
  }
825
823
  // gemini has no end marker - still typing
826
824
  return 'still working...';
@@ -839,7 +837,7 @@ describe('cmdTalk - --wait mode', () => {
839
837
  pollInterval: 0.02,
840
838
  captureLines: 100,
841
839
  maxCaptureLines: 2000,
842
- preambleEvery: 3,
840
+ preambleEvery: 3, pasteEnterDelayMs: 500,
843
841
  },
844
842
  paneRegistry: {
845
843
  codex: { pane: '10.1' },
@@ -868,18 +866,15 @@ describe('cmdTalk - --wait mode', () => {
868
866
 
869
867
  it('uses unique nonces per agent in broadcast', async () => {
870
868
  const tmux = createMockTmux();
871
- const nonces: string[] = [];
872
-
873
- tmux.send = (_pane: string, msg: string) => {
874
- const match = msg.match(INSTRUCTION_NONCE_REGEX);
875
- if (match) {
876
- nonces.push(match[1]);
877
- }
878
- };
869
+ const getNonces = (): string[] =>
870
+ tmux.sends
871
+ .map((s) => String(s.message).match(INSTRUCTION_NONCE_REGEX)?.[1])
872
+ .filter((nonce): nonce is string => Boolean(nonce));
879
873
 
880
874
  // Return complete response immediately
881
875
  tmux.capture = (pane: string) => {
882
876
  const idx = pane === '10.1' ? 0 : 1;
877
+ const nonces = getNonces();
883
878
  if (nonces[idx]) {
884
879
  return mockCompleteResponse(nonces[idx], 'Response');
885
880
  }
@@ -897,7 +892,7 @@ describe('cmdTalk - --wait mode', () => {
897
892
  pollInterval: 0.02,
898
893
  captureLines: 100,
899
894
  maxCaptureLines: 2000,
900
- preambleEvery: 3,
895
+ preambleEvery: 3, pasteEnterDelayMs: 500,
901
896
  },
902
897
  paneRegistry: {
903
898
  codex: { pane: '10.1' },
@@ -909,6 +904,7 @@ describe('cmdTalk - --wait mode', () => {
909
904
  await cmdTalk(ctx, 'all', 'Hello');
910
905
 
911
906
  // Each agent should have a unique nonce
907
+ const nonces = getNonces();
912
908
  expect(nonces.length).toBe(2);
913
909
  expect(nonces[0]).not.toBe(nonces[1]);
914
910
  });
@@ -1007,7 +1003,7 @@ describe('cmdTalk - nonce collision handling', () => {
1007
1003
  pollInterval: 0.01,
1008
1004
  captureLines: 100,
1009
1005
  maxCaptureLines: 2000,
1010
- preambleEvery: 3,
1006
+ preambleEvery: 3, pasteEnterDelayMs: 500,
1011
1007
  },
1012
1008
  },
1013
1009
  });
@@ -1062,7 +1058,7 @@ describe('cmdTalk - JSON output contract', () => {
1062
1058
  pollInterval: 0.01,
1063
1059
  captureLines: 100,
1064
1060
  maxCaptureLines: 2000,
1065
- preambleEvery: 3,
1061
+ preambleEvery: 3, pasteEnterDelayMs: 500,
1066
1062
  },
1067
1063
  },
1068
1064
  });
@@ -1103,7 +1099,7 @@ describe('cmdTalk - JSON output contract', () => {
1103
1099
  pollInterval: 0.01,
1104
1100
  captureLines: 100,
1105
1101
  maxCaptureLines: 2000,
1106
- preambleEvery: 3,
1102
+ preambleEvery: 3, pasteEnterDelayMs: 500,
1107
1103
  },
1108
1104
  },
1109
1105
  });
@@ -1145,7 +1141,7 @@ describe('cmdTalk - JSON output contract', () => {
1145
1141
  pollInterval: 0.01,
1146
1142
  captureLines: 100,
1147
1143
  maxCaptureLines: 2000,
1148
- preambleEvery: 3,
1144
+ preambleEvery: 3, pasteEnterDelayMs: 500,
1149
1145
  },
1150
1146
  },
1151
1147
  });
@@ -1182,7 +1178,7 @@ describe('cmdTalk - JSON output contract', () => {
1182
1178
  pollInterval: 0.01,
1183
1179
  captureLines: 100,
1184
1180
  maxCaptureLines: 2000,
1185
- preambleEvery: 3,
1181
+ preambleEvery: 3, pasteEnterDelayMs: 500,
1186
1182
  },
1187
1183
  },
1188
1184
  });
@@ -1202,17 +1198,16 @@ describe('cmdTalk - JSON output contract', () => {
1202
1198
  it('handles broadcast with mixed completion and timeout', async () => {
1203
1199
  const tmux = createMockTmux();
1204
1200
  const ui = createMockUI();
1205
- const markersByPane: Record<string, string> = {};
1206
-
1207
- tmux.send = (pane: string, msg: string) => {
1208
- const match = msg.match(INSTRUCTION_NONCE_REGEX);
1209
- if (match) markersByPane[pane] = match[1];
1201
+ const getNonceForPane = (pane: string): string | undefined => {
1202
+ const sent = tmux.sends.find((s) => s.pane === pane)?.message ?? '';
1203
+ const match = String(sent).match(INSTRUCTION_NONCE_REGEX);
1204
+ return match?.[1];
1210
1205
  };
1211
1206
 
1212
1207
  // codex completes with end marker, gemini has no end marker (still typing)
1213
1208
  tmux.capture = (pane: string) => {
1214
1209
  if (pane === '10.1') {
1215
- const nonce = markersByPane['10.1'];
1210
+ const nonce = getNonceForPane('10.1');
1216
1211
  const endMarker = `RESPONSE-END-${nonce}`;
1217
1212
  // Complete response with end marker
1218
1213
  return `Response\n${endMarker}`;
@@ -1233,7 +1228,7 @@ describe('cmdTalk - JSON output contract', () => {
1233
1228
  pollInterval: 0.02,
1234
1229
  captureLines: 100,
1235
1230
  maxCaptureLines: 2000,
1236
- preambleEvery: 3,
1231
+ preambleEvery: 3, pasteEnterDelayMs: 500,
1237
1232
  },
1238
1233
  paneRegistry: {
1239
1234
  codex: { pane: '10.1' },
@@ -1315,7 +1310,7 @@ describe('cmdTalk - end marker detection', () => {
1315
1310
  ui,
1316
1311
  paths: createTestPaths(testDir),
1317
1312
  flags: { wait: true, json: true, timeout: 0.5 },
1318
- config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 } },
1313
+ config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 } },
1319
1314
  });
1320
1315
 
1321
1316
  await cmdTalk(ctx, 'claude', 'Test message');
@@ -1349,7 +1344,7 @@ describe('cmdTalk - end marker detection', () => {
1349
1344
  ui,
1350
1345
  paths: createTestPaths(testDir),
1351
1346
  flags: { wait: true, json: true, timeout: 0.5 },
1352
- config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 } },
1347
+ config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 } },
1353
1348
  });
1354
1349
 
1355
1350
  await cmdTalk(ctx, 'claude', 'Test');
@@ -1383,7 +1378,7 @@ Line 4 final`;
1383
1378
  ui,
1384
1379
  paths: createTestPaths(testDir),
1385
1380
  flags: { wait: true, json: true, timeout: 0.5 },
1386
- config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 } },
1381
+ config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 } },
1387
1382
  });
1388
1383
 
1389
1384
  await cmdTalk(ctx, 'claude', 'Test');
@@ -1414,7 +1409,7 @@ Line 4 final`;
1414
1409
  ui,
1415
1410
  paths: createTestPaths(testDir),
1416
1411
  flags: { wait: true, json: true, timeout: 0.5 },
1417
- config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 } },
1412
+ config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 } },
1418
1413
  });
1419
1414
 
1420
1415
  await cmdTalk(ctx, 'claude', 'Test');
@@ -1451,7 +1446,7 @@ Line 4 final`;
1451
1446
  ui,
1452
1447
  paths: createTestPaths(testDir),
1453
1448
  flags: { wait: true, json: true, timeout: 0.5 },
1454
- config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 } },
1449
+ config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 } },
1455
1450
  });
1456
1451
 
1457
1452
  await cmdTalk(ctx, 'claude', 'Test');
@@ -1487,7 +1482,7 @@ Line 4 final`;
1487
1482
  ui,
1488
1483
  paths: createTestPaths(testDir),
1489
1484
  flags: { wait: true, json: true, timeout: 0.5 },
1490
- config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 200, maxCaptureLines: 2000, preambleEvery: 3 } },
1485
+ config: { defaults: { timeout: 0.5, pollInterval: 0.01, captureLines: 200, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 } },
1491
1486
  });
1492
1487
 
1493
1488
  await cmdTalk(ctx, 'claude', 'Test');
@@ -319,6 +319,7 @@ function buildMessage(message: string, agentName: string, ctx: Context): string
319
319
  export async function cmdTalk(ctx: Context, target: string, message: string): Promise<void> {
320
320
  const { ui, config, tmux, flags, exit } = ctx;
321
321
  const waitEnabled = Boolean(flags.wait) || config.mode === 'wait';
322
+ const enterDelayMs = config.defaults.pasteEnterDelayMs;
322
323
 
323
324
  if (target === 'all') {
324
325
  const agents = Object.entries(config.paneRegistry);
@@ -357,9 +358,8 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
357
358
 
358
359
  for (const [name, data] of targetAgents) {
359
360
  try {
360
- let msg = buildMessage(message, name, ctx);
361
- if (name === 'gemini') msg = msg.replace(/!/g, '');
362
- tmux.send(data.pane, msg);
361
+ const msg = buildMessage(message, name, ctx);
362
+ tmux.send(data.pane, msg, { enterDelayMs });
363
363
  results.push({ agent: name, pane: data.pane, status: 'sent' });
364
364
  if (!flags.json) {
365
365
  console.log(`${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
@@ -399,9 +399,8 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
399
399
  if (!waitEnabled) {
400
400
  try {
401
401
  // Build message with preamble, then apply Gemini filter
402
- let msg = buildMessage(message, target, ctx);
403
- if (target === 'gemini') msg = msg.replace(/!/g, '');
404
- tmux.send(pane, msg);
402
+ const msg = buildMessage(message, target, ctx);
403
+ tmux.send(pane, msg, { enterDelayMs });
405
404
 
406
405
  if (flags.json) {
407
406
  ui.json({ target, pane, status: 'sent' });
@@ -462,7 +461,7 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
462
461
  process.once('SIGINT', onSigint);
463
462
 
464
463
  try {
465
- const msg = target === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
464
+ const msg = fullMessage;
466
465
 
467
466
  if (flags.debug) {
468
467
  console.error(`[DEBUG] Starting wait mode for ${target}`);
@@ -470,7 +469,7 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
470
469
  console.error(`[DEBUG] Message sent:\n${msg}`);
471
470
  }
472
471
 
473
- tmux.send(pane, msg);
472
+ tmux.send(pane, msg, { enterDelayMs });
474
473
 
475
474
  while (true) {
476
475
  const elapsedSeconds = (Date.now() - startedAt) / 1000;
@@ -662,6 +661,7 @@ async function cmdTalkAllWait(
662
661
  skippedSelf: boolean
663
662
  ): Promise<void> {
664
663
  const { ui, config, tmux, flags, exit, paths } = ctx;
664
+ const enterDelayMs = config.defaults.pasteEnterDelayMs;
665
665
  const timeoutSeconds = flags.timeout ?? config.defaults.timeout;
666
666
  const pollIntervalSeconds = Math.max(0.1, config.defaults.pollInterval);
667
667
  const captureLines = config.defaults.captureLines;
@@ -694,11 +694,11 @@ async function cmdTalkAllWait(
694
694
  // Note: instruction doesn't contain literal marker to prevent false-positive detection
695
695
  const messageWithPreamble = buildMessage(message, name, ctx);
696
696
  const fullMessage = `${messageWithPreamble}\n\n${makeEndMarkerInstruction(nonce)}`;
697
- const msg = name === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
697
+ const msg = fullMessage;
698
698
 
699
699
  try {
700
700
  const now = Date.now();
701
- tmux.send(data.pane, msg);
701
+ tmux.send(data.pane, msg, { enterDelayMs });
702
702
  setActiveRequest(paths, name, {
703
703
  id: requestId,
704
704
  nonce,
@@ -12,7 +12,7 @@ export function cmdThis(ctx: Context, name: string, remark?: string): void {
12
12
  const currentPaneId = tmux.getCurrentPaneId();
13
13
  if (!currentPaneId) {
14
14
  ui.error('Not running inside tmux.');
15
- exit(ExitCodes.ERROR);
15
+ return exit(ExitCodes.ERROR);
16
16
  }
17
17
 
18
18
  // Reuse existing add logic
@@ -206,6 +206,7 @@ describe('loadConfig', () => {
206
206
  expect(config.defaults.timeout).toBe(180);
207
207
  expect(config.defaults.pollInterval).toBe(1);
208
208
  expect(config.defaults.captureLines).toBe(100);
209
+ expect(config.defaults.pasteEnterDelayMs).toBe(500);
209
210
  expect(config.agents).toEqual({});
210
211
  expect(config.paneRegistry).toEqual({});
211
212
  });
package/src/config.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  const CONFIG_FILENAME = 'config.json';
18
18
  const LOCAL_CONFIG_FILENAME = 'tmux-team.json';
19
19
  const STATE_FILENAME = 'state.json';
20
+ const TEAMS_DIR = 'teams';
20
21
 
21
22
  // Default configuration values
22
23
  const DEFAULT_CONFIG: GlobalConfig = {
@@ -28,6 +29,7 @@ const DEFAULT_CONFIG: GlobalConfig = {
28
29
  captureLines: 100,
29
30
  maxCaptureLines: 2000, // max lines for final extraction (expandable capture)
30
31
  preambleEvery: 3, // inject preamble every N messages
32
+ pasteEnterDelayMs: 500, // delay after paste before Enter
31
33
  },
32
34
  };
33
35
 
@@ -101,9 +103,37 @@ function findUpward(filename: string, startDir: string): string | null {
101
103
  }
102
104
  }
103
105
 
104
- export function resolvePaths(cwd: string = process.cwd()): Paths {
106
+ /**
107
+ * Get the path to a shared team config file.
108
+ */
109
+ export function getTeamConfigPath(globalDir: string, teamName: string): string {
110
+ return path.join(globalDir, TEAMS_DIR, `${teamName}.json`);
111
+ }
112
+
113
+ /**
114
+ * Ensure the teams directory exists.
115
+ */
116
+ export function ensureTeamsDir(globalDir: string): void {
117
+ const teamsDir = path.join(globalDir, TEAMS_DIR);
118
+ if (!fs.existsSync(teamsDir)) {
119
+ fs.mkdirSync(teamsDir, { recursive: true });
120
+ }
121
+ }
122
+
123
+ export function resolvePaths(cwd: string = process.cwd(), teamName?: string): Paths {
105
124
  const globalDir = resolveGlobalDir();
106
125
 
126
+ // If team name is specified, use the shared team config
127
+ if (teamName) {
128
+ const teamConfig = getTeamConfigPath(globalDir, teamName);
129
+ return {
130
+ globalDir,
131
+ globalConfig: path.join(globalDir, CONFIG_FILENAME),
132
+ localConfig: teamConfig,
133
+ stateFile: path.join(globalDir, STATE_FILENAME),
134
+ };
135
+ }
136
+
107
137
  // Search up for local config (like .git discovery)
108
138
  const localConfigPath =
109
139
  findUpward(LOCAL_CONFIG_FILENAME, cwd) ?? path.join(cwd, LOCAL_CONFIG_FILENAME);
@@ -180,6 +210,9 @@ export function loadConfig(paths: Paths): ResolvedConfig {
180
210
  if (localSettings.preambleEvery !== undefined) {
181
211
  config.defaults.preambleEvery = localSettings.preambleEvery;
182
212
  }
213
+ if (localSettings.pasteEnterDelayMs !== undefined) {
214
+ config.defaults.pasteEnterDelayMs = localSettings.pasteEnterDelayMs;
215
+ }
183
216
  }
184
217
 
185
218
  // Build pane registry and agents config from local entries
@@ -18,7 +18,7 @@ describe('createContext', () => {
18
18
  const config: ResolvedConfig = {
19
19
  mode: 'polling',
20
20
  preambleMode: 'always',
21
- defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3 },
21
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 },
22
22
  agents: {},
23
23
  paneRegistry: {},
24
24
  };
package/src/context.ts CHANGED
@@ -17,7 +17,7 @@ export interface CreateContextOptions {
17
17
  export function createContext(options: CreateContextOptions): Context {
18
18
  const { argv, flags, cwd = process.cwd() } = options;
19
19
 
20
- const paths = resolvePaths(cwd);
20
+ const paths = resolvePaths(cwd, flags.team);
21
21
  const config = loadConfig(paths);
22
22
  const ui = createUI(flags.json);
23
23
  const tmux = createTmux();
package/src/tmux.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // Tmux Wrapper Tests - send-keys, capture-pane
2
+ // Tmux Wrapper Tests - buffer paste, capture-pane
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
5
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -23,68 +23,70 @@ describe('createTmux', () => {
23
23
  });
24
24
 
25
25
  describe('send', () => {
26
- it('calls tmux send-keys with pane ID and message', () => {
26
+ it('uses buffer paste and then sends Enter', () => {
27
27
  const tmux = createTmux();
28
28
 
29
- tmux.send('1.0', 'Hello world');
29
+ tmux.send('1.0', 'Hello world', { enterDelayMs: 0 });
30
30
 
31
31
  expect(mockedExecSync).toHaveBeenCalledWith(
32
- 'tmux send-keys -t "1.0" "Hello world"',
32
+ expect.stringContaining('tmux set-buffer -b "tmt-'),
33
33
  expect.any(Object)
34
34
  );
35
- });
36
-
37
- it('sends Enter key after message', () => {
38
- const tmux = createTmux();
39
-
40
- tmux.send('1.0', 'Hello');
41
-
42
- expect(mockedExecSync).toHaveBeenCalledTimes(2);
43
- expect(mockedExecSync).toHaveBeenNthCalledWith(
44
- 2,
35
+ expect(mockedExecSync).toHaveBeenCalledWith(
36
+ expect.stringContaining('tmux paste-buffer -b "tmt-'),
37
+ expect.any(Object)
38
+ );
39
+ expect(mockedExecSync).toHaveBeenCalledWith(
45
40
  'tmux send-keys -t "1.0" Enter',
46
41
  expect.any(Object)
47
42
  );
48
43
  });
49
44
 
50
- it('escapes special characters in message', () => {
45
+ it('adds a trailing newline to the buffer payload', () => {
51
46
  const tmux = createTmux();
52
47
 
53
- tmux.send('1.0', 'Hello "world" with \'quotes\'');
48
+ tmux.send('1.0', 'Line 1\nLine 2', { enterDelayMs: 0 });
54
49
 
55
50
  expect(mockedExecSync).toHaveBeenCalledWith(
56
- expect.stringContaining('"Hello \\"world\\" with \'quotes\'"'),
51
+ expect.stringContaining('"Line 1\\nLine 2\\n"'),
57
52
  expect.any(Object)
58
53
  );
59
54
  });
60
55
 
61
- it('handles newlines in message', () => {
56
+ it('escapes special characters in message', () => {
62
57
  const tmux = createTmux();
63
58
 
64
- tmux.send('1.0', 'Line 1\nLine 2');
59
+ tmux.send('1.0', 'Hello "world" with \'quotes\'', { enterDelayMs: 0 });
65
60
 
66
- // JSON.stringify escapes newlines as \n
67
61
  expect(mockedExecSync).toHaveBeenCalledWith(
68
- expect.stringContaining('"Line 1\\nLine 2"'),
62
+ expect.stringContaining('"Hello \\"world\\" with \'quotes\'\\n"'),
69
63
  expect.any(Object)
70
64
  );
71
65
  });
72
66
 
73
- it('throws when pane does not exist', () => {
74
- const error = new Error("can't find pane: 99.99");
67
+ it('falls back to send-keys when buffer paste fails', () => {
68
+ const error = new Error('set-buffer failed');
75
69
  mockedExecSync.mockImplementationOnce(() => {
76
70
  throw error;
77
71
  });
78
-
79
72
  const tmux = createTmux();
80
73
 
81
- expect(() => tmux.send('99.99', 'Hello')).toThrow("can't find pane: 99.99");
74
+ tmux.send('1.0', 'Hello', { enterDelayMs: 0 });
75
+
76
+ expect(mockedExecSync).toHaveBeenCalledWith(
77
+ 'tmux send-keys -t "1.0" "Hello"',
78
+ expect.any(Object)
79
+ );
80
+ expect(mockedExecSync).toHaveBeenCalledWith(
81
+ 'tmux send-keys -t "1.0" Enter',
82
+ expect.any(Object)
83
+ );
82
84
  });
83
85
 
84
86
  it('uses pipe stdio to suppress output', () => {
85
87
  const tmux = createTmux();
86
88
 
87
- tmux.send('1.0', 'Hello');
89
+ tmux.send('1.0', 'Hello', { enterDelayMs: 0 });
88
90
 
89
91
  expect(mockedExecSync).toHaveBeenCalledWith(expect.any(String), { stdio: 'pipe' });
90
92
  });
@@ -236,9 +238,13 @@ describe('createTmux', () => {
236
238
  mockedExecSync.mockReturnValue('');
237
239
  const tmux = createTmux();
238
240
 
239
- tmux.send('1.2', 'Hello');
241
+ tmux.send('1.2', 'Hello', { enterDelayMs: 0 });
240
242
  tmux.capture('1.2', 100);
241
243
 
244
+ expect(mockedExecSync).toHaveBeenCalledWith(
245
+ expect.stringContaining('tmux paste-buffer -b "tmt-'),
246
+ expect.any(Object)
247
+ );
242
248
  expect(mockedExecSync).toHaveBeenCalledWith(
243
249
  expect.stringContaining('-t "1.2"'),
244
250
  expect.any(Object)
@@ -249,9 +255,13 @@ describe('createTmux', () => {
249
255
  mockedExecSync.mockReturnValue('');
250
256
  const tmux = createTmux();
251
257
 
252
- tmux.send('main:1.2', 'Hello');
258
+ tmux.send('main:1.2', 'Hello', { enterDelayMs: 0 });
253
259
  tmux.capture('main:1.2', 100);
254
260
 
261
+ expect(mockedExecSync).toHaveBeenCalledWith(
262
+ expect.stringContaining('tmux paste-buffer -b "tmt-'),
263
+ expect.any(Object)
264
+ );
255
265
  expect(mockedExecSync).toHaveBeenCalledWith(
256
266
  expect.stringContaining('-t "main:1.2"'),
257
267
  expect.any(Object)
@@ -263,11 +273,11 @@ describe('createTmux', () => {
263
273
  const tmux = createTmux();
264
274
 
265
275
  // Malicious pane ID attempt
266
- tmux.send('1.0; rm -rf /', 'Hello');
276
+ tmux.send('1.0; rm -rf /', 'Hello', { enterDelayMs: 0 });
267
277
 
268
278
  // Should be quoted and treated as literal string
269
279
  expect(mockedExecSync).toHaveBeenCalledWith(
270
- 'tmux send-keys -t "1.0; rm -rf /" "Hello"',
280
+ expect.stringContaining('-t "1.0; rm -rf /"'),
271
281
  expect.any(Object)
272
282
  );
273
283
  });
package/src/tmux.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // Pure tmux wrapper - send-keys, capture-pane, pane detection
2
+ // Pure tmux wrapper - buffer paste, capture-pane, pane detection
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
5
  import { execSync } from 'child_process';
6
+ import crypto from 'crypto';
6
7
  import type { Tmux, PaneInfo } from './types.js';
7
8
 
8
9
  // Known agent patterns for auto-detection
@@ -27,14 +28,54 @@ function detectAgentName(command: string): string | null {
27
28
  }
28
29
 
29
30
  export function createTmux(): Tmux {
31
+ function sleepMs(ms: number): void {
32
+ if (ms <= 0) return;
33
+ const buffer = new SharedArrayBuffer(4);
34
+ const view = new Int32Array(buffer);
35
+ Atomics.wait(view, 0, 0, ms);
36
+ }
37
+
38
+ function ensureTrailingNewline(message: string): string {
39
+ return message.endsWith('\n') ? message : `${message}\n`;
40
+ }
41
+
42
+ function escapeExclamation(message: string): string {
43
+ // Escape "!" to avoid shell history expansion issues
44
+ return message.replace(/!/g, '\\!');
45
+ }
46
+
47
+ function makeBufferName(): string {
48
+ const nonce = crypto.randomBytes(4).toString('hex');
49
+ return `tmt-${process.pid}-${Date.now()}-${nonce}`;
50
+ }
51
+
30
52
  return {
31
- send(paneId: string, message: string): void {
32
- execSync(`tmux send-keys -t "${paneId}" ${JSON.stringify(message)}`, {
33
- stdio: 'pipe',
34
- });
35
- execSync(`tmux send-keys -t "${paneId}" Enter`, {
36
- stdio: 'pipe',
37
- });
53
+ send(paneId: string, message: string, options?: { enterDelayMs?: number }): void {
54
+ const enterDelayMs = Math.max(0, options?.enterDelayMs ?? 500);
55
+ const bufferName = makeBufferName();
56
+ const escaped = escapeExclamation(message);
57
+ const payload = ensureTrailingNewline(escaped);
58
+
59
+ try {
60
+ execSync(`tmux set-buffer -b "${bufferName}" -- ${JSON.stringify(payload)}`, {
61
+ stdio: 'pipe',
62
+ });
63
+ execSync(`tmux paste-buffer -b "${bufferName}" -d -t "${paneId}" -p`, {
64
+ stdio: 'pipe',
65
+ });
66
+ sleepMs(enterDelayMs);
67
+ execSync(`tmux send-keys -t "${paneId}" Enter`, {
68
+ stdio: 'pipe',
69
+ });
70
+ } catch {
71
+ // Fallback to legacy send-keys if buffer/paste fails
72
+ execSync(`tmux send-keys -t "${paneId}" ${JSON.stringify(message)}`, {
73
+ stdio: 'pipe',
74
+ });
75
+ execSync(`tmux send-keys -t "${paneId}" Enter`, {
76
+ stdio: 'pipe',
77
+ });
78
+ }
38
79
  },
39
80
 
40
81
  capture(paneId: string, lines: number): string {
package/src/types.ts CHANGED
@@ -20,6 +20,7 @@ export interface ConfigDefaults {
20
20
  captureLines: number;
21
21
  maxCaptureLines: number; // max lines for final extraction (default: 2000)
22
22
  preambleEvery: number; // inject preamble every N messages (default: 3)
23
+ pasteEnterDelayMs: number; // delay after paste before Enter (default: 500)
23
24
  }
24
25
 
25
26
  export interface GlobalConfig {
@@ -32,6 +33,7 @@ export interface LocalSettings {
32
33
  mode?: 'polling' | 'wait';
33
34
  preambleMode?: 'always' | 'disabled';
34
35
  preambleEvery?: number; // local override for preamble frequency
36
+ pasteEnterDelayMs?: number; // local override for paste-enter delay
35
37
  }
36
38
 
37
39
  export interface LocalConfigFile {
@@ -62,6 +64,7 @@ export interface Flags {
62
64
  timeout?: number; // seconds
63
65
  lines?: number; // lines to capture before end marker
64
66
  noPreamble?: boolean;
67
+ team?: string; // shared team name for cross-folder collaboration
65
68
  }
66
69
 
67
70
  export interface Paths {
@@ -87,7 +90,7 @@ export interface PaneInfo {
87
90
  }
88
91
 
89
92
  export interface Tmux {
90
- send: (paneId: string, message: string) => void;
93
+ send: (paneId: string, message: string, options?: { enterDelayMs?: number }) => void;
91
94
  capture: (paneId: string, lines: number) => string;
92
95
  listPanes: () => PaneInfo[];
93
96
  getCurrentPaneId: () => string | null;