tmux-team 3.3.0 → 4.0.0-beta.0
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 +11 -0
- package/package.json +15 -16
- package/skills/claude/team.md +2 -2
- package/skills/codex/SKILL.md +2 -2
- package/src/cli.test.ts +1 -1
- package/src/commands/basic-commands.test.ts +4 -2
- package/src/commands/config-command.test.ts +8 -1
- package/src/commands/config.ts +36 -2
- package/src/commands/install.test.ts +1 -1
- package/src/commands/learn.ts +3 -3
- package/src/commands/preamble.test.ts +1 -1
- package/src/commands/talk.test.ts +52 -57
- package/src/commands/talk.ts +10 -10
- package/src/config.test.ts +1 -0
- package/src/config.ts +4 -0
- package/src/context.test.ts +1 -1
- package/src/tmux.test.ts +40 -30
- package/src/tmux.ts +43 -8
- package/src/types.ts +3 -1
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
|
}
|
package/package.json
CHANGED
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tmux-team",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-beta.0",
|
|
4
4
|
"description": "CLI tool for AI agent collaboration in tmux - manage cross-pane communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tmux-team": "./bin/tmux-team",
|
|
8
8
|
"tmt": "./bin/tmux-team"
|
|
9
9
|
},
|
|
10
|
-
"scripts": {
|
|
11
|
-
"dev": "tsx src/cli.ts",
|
|
12
|
-
"tmt": "./bin/tmux-team",
|
|
13
|
-
"test": "pnpm test:run",
|
|
14
|
-
"test:watch": "vitest",
|
|
15
|
-
"test:run": "vitest run --coverage && node scripts/check-coverage.mjs --threshold 95",
|
|
16
|
-
"lint": "oxlint src/",
|
|
17
|
-
"lint:fix": "oxlint src/ --fix",
|
|
18
|
-
"format": "prettier --write src/",
|
|
19
|
-
"format:check": "prettier --check src/",
|
|
20
|
-
"type:check": "tsc --noEmit",
|
|
21
|
-
"check": "pnpm type:check && pnpm lint && pnpm format:check"
|
|
22
|
-
},
|
|
23
10
|
"keywords": [
|
|
24
11
|
"tmux",
|
|
25
12
|
"cli",
|
|
@@ -37,7 +24,6 @@
|
|
|
37
24
|
"engines": {
|
|
38
25
|
"node": ">=18"
|
|
39
26
|
},
|
|
40
|
-
"packageManager": "pnpm@9.15.4",
|
|
41
27
|
"os": [
|
|
42
28
|
"darwin",
|
|
43
29
|
"linux"
|
|
@@ -57,5 +43,18 @@
|
|
|
57
43
|
"prettier": "^3.7.4",
|
|
58
44
|
"typescript": "^5.3.0",
|
|
59
45
|
"vitest": "^1.2.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"dev": "tsx src/cli.ts",
|
|
49
|
+
"tmt": "./bin/tmux-team",
|
|
50
|
+
"test": "pnpm test:run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
"test:run": "vitest run --coverage && node scripts/check-coverage.mjs --threshold 90 --branches 85",
|
|
53
|
+
"lint": "oxlint src/",
|
|
54
|
+
"lint:fix": "oxlint src/ --fix",
|
|
55
|
+
"format": "prettier --write src/",
|
|
56
|
+
"format:check": "prettier --check src/",
|
|
57
|
+
"type:check": "tsc --noEmit",
|
|
58
|
+
"check": "pnpm type:check && pnpm lint && pnpm format:check"
|
|
60
59
|
}
|
|
61
|
-
}
|
|
60
|
+
}
|
package/skills/claude/team.md
CHANGED
|
@@ -39,8 +39,8 @@ tmux-team list
|
|
|
39
39
|
|
|
40
40
|
## Notes
|
|
41
41
|
|
|
42
|
-
- `talk`
|
|
43
|
-
-
|
|
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/skills/codex/SKILL.md
CHANGED
|
@@ -39,8 +39,8 @@ tmux-team list
|
|
|
39
39
|
|
|
40
40
|
## Notes
|
|
41
41
|
|
|
42
|
-
- `talk`
|
|
43
|
-
-
|
|
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
|
},
|
|
@@ -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(
|
|
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: {
|
|
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,
|
package/src/commands/config.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
};
|
package/src/commands/learn.ts
CHANGED
|
@@ -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
|
|
21
|
-
2.
|
|
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
|
}
|
|
@@ -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('
|
|
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
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
776
|
-
|
|
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
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
823
|
-
|
|
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
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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 =
|
|
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');
|
package/src/commands/talk.ts
CHANGED
|
@@ -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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
@@ -28,6 +28,7 @@ const DEFAULT_CONFIG: GlobalConfig = {
|
|
|
28
28
|
captureLines: 100,
|
|
29
29
|
maxCaptureLines: 2000, // max lines for final extraction (expandable capture)
|
|
30
30
|
preambleEvery: 3, // inject preamble every N messages
|
|
31
|
+
pasteEnterDelayMs: 500, // delay after paste before Enter
|
|
31
32
|
},
|
|
32
33
|
};
|
|
33
34
|
|
|
@@ -180,6 +181,9 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
180
181
|
if (localSettings.preambleEvery !== undefined) {
|
|
181
182
|
config.defaults.preambleEvery = localSettings.preambleEvery;
|
|
182
183
|
}
|
|
184
|
+
if (localSettings.pasteEnterDelayMs !== undefined) {
|
|
185
|
+
config.defaults.pasteEnterDelayMs = localSettings.pasteEnterDelayMs;
|
|
186
|
+
}
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
// Build pane registry and agents config from local entries
|
package/src/context.test.ts
CHANGED
|
@@ -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/tmux.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// Tmux Wrapper Tests -
|
|
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('
|
|
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
|
|
32
|
+
expect.stringContaining('tmux set-buffer -b "tmt-'),
|
|
33
33
|
expect.any(Object)
|
|
34
34
|
);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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('
|
|
45
|
+
it('adds a trailing newline to the buffer payload', () => {
|
|
51
46
|
const tmux = createTmux();
|
|
52
47
|
|
|
53
|
-
tmux.send('1.0', '
|
|
48
|
+
tmux.send('1.0', 'Line 1\nLine 2', { enterDelayMs: 0 });
|
|
54
49
|
|
|
55
50
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
|
56
|
-
expect.stringContaining('"
|
|
51
|
+
expect.stringContaining('"Line 1\\nLine 2\\n"'),
|
|
57
52
|
expect.any(Object)
|
|
58
53
|
);
|
|
59
54
|
});
|
|
60
55
|
|
|
61
|
-
it('
|
|
56
|
+
it('escapes special characters in message', () => {
|
|
62
57
|
const tmux = createTmux();
|
|
63
58
|
|
|
64
|
-
tmux.send('1.0', '
|
|
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('"
|
|
62
|
+
expect.stringContaining('"Hello \\"world\\" with \'quotes\'\\n"'),
|
|
69
63
|
expect.any(Object)
|
|
70
64
|
);
|
|
71
65
|
});
|
|
72
66
|
|
|
73
|
-
it('
|
|
74
|
-
const error = new Error(
|
|
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
|
-
|
|
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
|
-
'
|
|
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 -
|
|
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,48 @@ 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 makeBufferName(): string {
|
|
43
|
+
const nonce = crypto.randomBytes(4).toString('hex');
|
|
44
|
+
return `tmt-${process.pid}-${Date.now()}-${nonce}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
return {
|
|
31
|
-
send(paneId: string, message: string): void {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
send(paneId: string, message: string, options?: { enterDelayMs?: number }): void {
|
|
49
|
+
const enterDelayMs = Math.max(0, options?.enterDelayMs ?? 500);
|
|
50
|
+
const bufferName = makeBufferName();
|
|
51
|
+
const payload = ensureTrailingNewline(message);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
execSync(`tmux set-buffer -b "${bufferName}" -- ${JSON.stringify(payload)}`, {
|
|
55
|
+
stdio: 'pipe',
|
|
56
|
+
});
|
|
57
|
+
execSync(`tmux paste-buffer -b "${bufferName}" -d -t "${paneId}" -p`, {
|
|
58
|
+
stdio: 'pipe',
|
|
59
|
+
});
|
|
60
|
+
sleepMs(enterDelayMs);
|
|
61
|
+
execSync(`tmux send-keys -t "${paneId}" Enter`, {
|
|
62
|
+
stdio: 'pipe',
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
// Fallback to legacy send-keys if buffer/paste fails
|
|
66
|
+
execSync(`tmux send-keys -t "${paneId}" ${JSON.stringify(message)}`, {
|
|
67
|
+
stdio: 'pipe',
|
|
68
|
+
});
|
|
69
|
+
execSync(`tmux send-keys -t "${paneId}" Enter`, {
|
|
70
|
+
stdio: 'pipe',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
38
73
|
},
|
|
39
74
|
|
|
40
75
|
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 {
|
|
@@ -87,7 +89,7 @@ export interface PaneInfo {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
export interface Tmux {
|
|
90
|
-
send: (paneId: string, message: string) => void;
|
|
92
|
+
send: (paneId: string, message: string, options?: { enterDelayMs?: number }) => void;
|
|
91
93
|
capture: (paneId: string, lines: number) => string;
|
|
92
94
|
listPanes: () => PaneInfo[];
|
|
93
95
|
getCurrentPaneId: () => string | null;
|