tmux-team 2.0.0-alpha.4 → 2.2.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 +223 -17
- package/package.json +1 -1
- package/src/commands/add.ts +9 -5
- package/src/commands/config.ts +104 -9
- package/src/commands/help.ts +3 -1
- package/src/commands/preamble.ts +22 -25
- package/src/commands/remove.ts +5 -3
- package/src/commands/talk.test.ts +199 -14
- package/src/commands/talk.ts +26 -2
- package/src/commands/update.ts +16 -5
- package/src/config.test.ts +183 -9
- package/src/config.ts +29 -16
- package/src/pm/commands.test.ts +389 -55
- package/src/pm/commands.ts +312 -24
- package/src/pm/permissions.test.ts +113 -1
- package/src/pm/permissions.ts +18 -4
- package/src/pm/storage/adapter.ts +2 -0
- package/src/pm/storage/fs.test.ts +129 -1
- package/src/pm/storage/fs.ts +38 -4
- package/src/pm/storage/github.ts +96 -17
- package/src/pm/types.ts +6 -0
- package/src/state.test.ts +20 -10
- package/src/state.ts +28 -1
- package/src/types.ts +5 -1
- package/src/ui.ts +2 -1
|
@@ -63,6 +63,8 @@ function createDefaultConfig(): ResolvedConfig {
|
|
|
63
63
|
timeout: 60,
|
|
64
64
|
pollInterval: 0.1, // Fast polling for tests
|
|
65
65
|
captureLines: 100,
|
|
66
|
+
preambleEvery: 3,
|
|
67
|
+
hideOrphanTasks: false,
|
|
66
68
|
},
|
|
67
69
|
agents: {},
|
|
68
70
|
paneRegistry: {
|
|
@@ -85,7 +87,15 @@ function createContext(
|
|
|
85
87
|
const exitError = new Error('exit called');
|
|
86
88
|
(exitError as Error & { exitCode?: number }).exitCode = 0;
|
|
87
89
|
|
|
88
|
-
const
|
|
90
|
+
const baseConfig = createDefaultConfig();
|
|
91
|
+
const config = {
|
|
92
|
+
...baseConfig,
|
|
93
|
+
...overrides.config,
|
|
94
|
+
defaults: {
|
|
95
|
+
...baseConfig.defaults,
|
|
96
|
+
...overrides.config?.defaults,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
89
99
|
const flags: Flags = { json: false, verbose: false, ...overrides.flags };
|
|
90
100
|
|
|
91
101
|
return {
|
|
@@ -199,6 +209,91 @@ describe('buildMessage (via cmdTalk)', () => {
|
|
|
199
209
|
|
|
200
210
|
expect(tmux.sends[0].message).toBe('[SYSTEM: Test preamble]\n\nTest message');
|
|
201
211
|
});
|
|
212
|
+
|
|
213
|
+
it('injects preamble based on preambleEvery config (every N messages)', async () => {
|
|
214
|
+
const paths = createTestPaths(testDir);
|
|
215
|
+
fs.mkdirSync(paths.globalDir, { recursive: true });
|
|
216
|
+
|
|
217
|
+
const config = {
|
|
218
|
+
preambleMode: 'always' as const,
|
|
219
|
+
agents: { claude: { preamble: 'Be brief' } },
|
|
220
|
+
defaults: {
|
|
221
|
+
timeout: 60,
|
|
222
|
+
pollInterval: 0.1,
|
|
223
|
+
captureLines: 100,
|
|
224
|
+
preambleEvery: 3,
|
|
225
|
+
hideOrphanTasks: false,
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Message 1: should include preamble (first message)
|
|
230
|
+
const tmux1 = createMockTmux();
|
|
231
|
+
await cmdTalk(createContext({ tmux: tmux1, paths, config }), 'claude', 'Hello 1');
|
|
232
|
+
expect(tmux1.sends[0].message).toContain('[SYSTEM: Be brief]');
|
|
233
|
+
|
|
234
|
+
// Message 2: should NOT include preamble
|
|
235
|
+
const tmux2 = createMockTmux();
|
|
236
|
+
await cmdTalk(createContext({ tmux: tmux2, paths, config }), 'claude', 'Hello 2');
|
|
237
|
+
expect(tmux2.sends[0].message).toBe('Hello 2');
|
|
238
|
+
|
|
239
|
+
// Message 3: should NOT include preamble
|
|
240
|
+
const tmux3 = createMockTmux();
|
|
241
|
+
await cmdTalk(createContext({ tmux: tmux3, paths, config }), 'claude', 'Hello 3');
|
|
242
|
+
expect(tmux3.sends[0].message).toBe('Hello 3');
|
|
243
|
+
|
|
244
|
+
// Message 4: should include preamble (4 - 1 = 3, divisible by 3)
|
|
245
|
+
const tmux4 = createMockTmux();
|
|
246
|
+
await cmdTalk(createContext({ tmux: tmux4, paths, config }), 'claude', 'Hello 4');
|
|
247
|
+
expect(tmux4.sends[0].message).toContain('[SYSTEM: Be brief]');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('injects preamble every time when preambleEvery is 1', async () => {
|
|
251
|
+
const paths = createTestPaths(testDir);
|
|
252
|
+
fs.mkdirSync(paths.globalDir, { recursive: true });
|
|
253
|
+
|
|
254
|
+
const config = {
|
|
255
|
+
preambleMode: 'always' as const,
|
|
256
|
+
agents: { claude: { preamble: 'Be brief' } },
|
|
257
|
+
defaults: {
|
|
258
|
+
timeout: 60,
|
|
259
|
+
pollInterval: 0.1,
|
|
260
|
+
captureLines: 100,
|
|
261
|
+
preambleEvery: 1,
|
|
262
|
+
hideOrphanTasks: false,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// All messages should include preamble
|
|
267
|
+
for (let i = 0; i < 3; i++) {
|
|
268
|
+
const tmux = createMockTmux();
|
|
269
|
+
await cmdTalk(createContext({ tmux, paths, config }), 'claude', `Hello ${i}`);
|
|
270
|
+
expect(tmux.sends[0].message).toContain('[SYSTEM: Be brief]');
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('never injects preamble when preambleEvery is 0', async () => {
|
|
275
|
+
const paths = createTestPaths(testDir);
|
|
276
|
+
fs.mkdirSync(paths.globalDir, { recursive: true });
|
|
277
|
+
|
|
278
|
+
const config = {
|
|
279
|
+
preambleMode: 'always' as const,
|
|
280
|
+
agents: { claude: { preamble: 'Be brief' } },
|
|
281
|
+
defaults: {
|
|
282
|
+
timeout: 60,
|
|
283
|
+
pollInterval: 0.1,
|
|
284
|
+
captureLines: 100,
|
|
285
|
+
preambleEvery: 0,
|
|
286
|
+
hideOrphanTasks: false,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// No messages should include preamble
|
|
291
|
+
for (let i = 0; i < 3; i++) {
|
|
292
|
+
const tmux = createMockTmux();
|
|
293
|
+
await cmdTalk(createContext({ tmux, paths, config }), 'claude', `Hello ${i}`);
|
|
294
|
+
expect(tmux.sends[0].message).toBe(`Hello ${i}`);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
202
297
|
});
|
|
203
298
|
|
|
204
299
|
describe('cmdTalk - basic send', () => {
|
|
@@ -369,7 +464,15 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
369
464
|
tmux,
|
|
370
465
|
paths: createTestPaths(testDir),
|
|
371
466
|
flags: { wait: true, timeout: 5 },
|
|
372
|
-
config: {
|
|
467
|
+
config: {
|
|
468
|
+
defaults: {
|
|
469
|
+
timeout: 5,
|
|
470
|
+
pollInterval: 0.01,
|
|
471
|
+
captureLines: 100,
|
|
472
|
+
preambleEvery: 3,
|
|
473
|
+
hideOrphanTasks: false,
|
|
474
|
+
},
|
|
475
|
+
},
|
|
373
476
|
});
|
|
374
477
|
|
|
375
478
|
await cmdTalk(ctx, 'claude', 'Hello');
|
|
@@ -404,7 +507,15 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
404
507
|
ui,
|
|
405
508
|
paths: createTestPaths(testDir),
|
|
406
509
|
flags: { wait: true, json: true, timeout: 5 },
|
|
407
|
-
config: {
|
|
510
|
+
config: {
|
|
511
|
+
defaults: {
|
|
512
|
+
timeout: 5,
|
|
513
|
+
pollInterval: 0.01,
|
|
514
|
+
captureLines: 100,
|
|
515
|
+
preambleEvery: 3,
|
|
516
|
+
hideOrphanTasks: false,
|
|
517
|
+
},
|
|
518
|
+
},
|
|
408
519
|
});
|
|
409
520
|
|
|
410
521
|
await cmdTalk(ctx, 'claude', 'Hello');
|
|
@@ -412,7 +523,7 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
412
523
|
expect(ui.jsonOutput).toHaveLength(1);
|
|
413
524
|
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
414
525
|
expect(output.status).toBe('completed');
|
|
415
|
-
expect(output.response).
|
|
526
|
+
expect(output.response).toEqual(expect.stringContaining('Agent response here'));
|
|
416
527
|
});
|
|
417
528
|
|
|
418
529
|
it('returns timeout error with correct exit code', async () => {
|
|
@@ -427,7 +538,15 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
427
538
|
ui,
|
|
428
539
|
paths: createTestPaths(testDir),
|
|
429
540
|
flags: { wait: true, json: true, timeout: 0.1 },
|
|
430
|
-
config: {
|
|
541
|
+
config: {
|
|
542
|
+
defaults: {
|
|
543
|
+
timeout: 0.1,
|
|
544
|
+
pollInterval: 0.02,
|
|
545
|
+
captureLines: 100,
|
|
546
|
+
preambleEvery: 3,
|
|
547
|
+
hideOrphanTasks: false,
|
|
548
|
+
},
|
|
549
|
+
},
|
|
431
550
|
});
|
|
432
551
|
|
|
433
552
|
try {
|
|
@@ -468,7 +587,15 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
468
587
|
ui,
|
|
469
588
|
paths: createTestPaths(testDir),
|
|
470
589
|
flags: { wait: true, json: true, timeout: 5 },
|
|
471
|
-
config: {
|
|
590
|
+
config: {
|
|
591
|
+
defaults: {
|
|
592
|
+
timeout: 5,
|
|
593
|
+
pollInterval: 0.01,
|
|
594
|
+
captureLines: 100,
|
|
595
|
+
preambleEvery: 3,
|
|
596
|
+
hideOrphanTasks: false,
|
|
597
|
+
},
|
|
598
|
+
},
|
|
472
599
|
});
|
|
473
600
|
|
|
474
601
|
await cmdTalk(ctx, 'claude', 'Hello');
|
|
@@ -496,7 +623,15 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
496
623
|
tmux,
|
|
497
624
|
paths,
|
|
498
625
|
flags: { wait: true, timeout: 5 },
|
|
499
|
-
config: {
|
|
626
|
+
config: {
|
|
627
|
+
defaults: {
|
|
628
|
+
timeout: 5,
|
|
629
|
+
pollInterval: 0.01,
|
|
630
|
+
captureLines: 100,
|
|
631
|
+
preambleEvery: 3,
|
|
632
|
+
hideOrphanTasks: false,
|
|
633
|
+
},
|
|
634
|
+
},
|
|
500
635
|
});
|
|
501
636
|
|
|
502
637
|
await cmdTalk(ctx, 'claude', 'Hello');
|
|
@@ -517,7 +652,15 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
517
652
|
tmux,
|
|
518
653
|
paths,
|
|
519
654
|
flags: { wait: true, timeout: 0.05 },
|
|
520
|
-
config: {
|
|
655
|
+
config: {
|
|
656
|
+
defaults: {
|
|
657
|
+
timeout: 0.05,
|
|
658
|
+
pollInterval: 0.01,
|
|
659
|
+
captureLines: 100,
|
|
660
|
+
preambleEvery: 3,
|
|
661
|
+
hideOrphanTasks: false,
|
|
662
|
+
},
|
|
663
|
+
},
|
|
521
664
|
});
|
|
522
665
|
|
|
523
666
|
try {
|
|
@@ -565,7 +708,13 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
565
708
|
paths,
|
|
566
709
|
flags: { wait: true, timeout: 5 },
|
|
567
710
|
config: {
|
|
568
|
-
defaults: {
|
|
711
|
+
defaults: {
|
|
712
|
+
timeout: 5,
|
|
713
|
+
pollInterval: 0.05,
|
|
714
|
+
captureLines: 100,
|
|
715
|
+
preambleEvery: 3,
|
|
716
|
+
hideOrphanTasks: false,
|
|
717
|
+
},
|
|
569
718
|
paneRegistry: {
|
|
570
719
|
codex: { pane: '10.1' },
|
|
571
720
|
gemini: { pane: '10.2' },
|
|
@@ -606,7 +755,13 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
606
755
|
paths,
|
|
607
756
|
flags: { wait: true, timeout: 0.1, json: true },
|
|
608
757
|
config: {
|
|
609
|
-
defaults: {
|
|
758
|
+
defaults: {
|
|
759
|
+
timeout: 0.1,
|
|
760
|
+
pollInterval: 0.02,
|
|
761
|
+
captureLines: 100,
|
|
762
|
+
preambleEvery: 3,
|
|
763
|
+
hideOrphanTasks: false,
|
|
764
|
+
},
|
|
610
765
|
paneRegistry: {
|
|
611
766
|
codex: { pane: '10.1' },
|
|
612
767
|
gemini: { pane: '10.2' },
|
|
@@ -658,7 +813,13 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
658
813
|
paths,
|
|
659
814
|
flags: { wait: true, timeout: 5 },
|
|
660
815
|
config: {
|
|
661
|
-
defaults: {
|
|
816
|
+
defaults: {
|
|
817
|
+
timeout: 5,
|
|
818
|
+
pollInterval: 0.02,
|
|
819
|
+
captureLines: 100,
|
|
820
|
+
preambleEvery: 3,
|
|
821
|
+
hideOrphanTasks: false,
|
|
822
|
+
},
|
|
662
823
|
paneRegistry: {
|
|
663
824
|
codex: { pane: '10.1' },
|
|
664
825
|
gemini: { pane: '10.2' },
|
|
@@ -718,7 +879,15 @@ describe('cmdTalk - nonce collision handling', () => {
|
|
|
718
879
|
ui,
|
|
719
880
|
paths: createTestPaths(testDir),
|
|
720
881
|
flags: { wait: true, json: true, timeout: 5 },
|
|
721
|
-
config: {
|
|
882
|
+
config: {
|
|
883
|
+
defaults: {
|
|
884
|
+
timeout: 5,
|
|
885
|
+
pollInterval: 0.01,
|
|
886
|
+
captureLines: 100,
|
|
887
|
+
preambleEvery: 3,
|
|
888
|
+
hideOrphanTasks: false,
|
|
889
|
+
},
|
|
890
|
+
},
|
|
722
891
|
});
|
|
723
892
|
|
|
724
893
|
await cmdTalk(ctx, 'claude', 'Hello');
|
|
@@ -761,7 +930,15 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
761
930
|
ui,
|
|
762
931
|
paths: createTestPaths(testDir),
|
|
763
932
|
flags: { wait: true, json: true, timeout: 5 },
|
|
764
|
-
config: {
|
|
933
|
+
config: {
|
|
934
|
+
defaults: {
|
|
935
|
+
timeout: 5,
|
|
936
|
+
pollInterval: 0.01,
|
|
937
|
+
captureLines: 100,
|
|
938
|
+
preambleEvery: 3,
|
|
939
|
+
hideOrphanTasks: false,
|
|
940
|
+
},
|
|
941
|
+
},
|
|
765
942
|
});
|
|
766
943
|
|
|
767
944
|
await cmdTalk(ctx, 'claude', 'Hello');
|
|
@@ -786,7 +963,15 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
786
963
|
ui,
|
|
787
964
|
paths: createTestPaths(testDir),
|
|
788
965
|
flags: { wait: true, json: true, timeout: 0.05 },
|
|
789
|
-
config: {
|
|
966
|
+
config: {
|
|
967
|
+
defaults: {
|
|
968
|
+
timeout: 0.05,
|
|
969
|
+
pollInterval: 0.01,
|
|
970
|
+
captureLines: 100,
|
|
971
|
+
preambleEvery: 3,
|
|
972
|
+
hideOrphanTasks: false,
|
|
973
|
+
},
|
|
974
|
+
},
|
|
790
975
|
});
|
|
791
976
|
|
|
792
977
|
try {
|
package/src/commands/talk.ts
CHANGED
|
@@ -7,7 +7,12 @@ import type { WaitResult } from '../types.js';
|
|
|
7
7
|
import { ExitCodes } from '../exits.js';
|
|
8
8
|
import { colors } from '../ui.js';
|
|
9
9
|
import crypto from 'crypto';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
cleanupState,
|
|
12
|
+
clearActiveRequest,
|
|
13
|
+
setActiveRequest,
|
|
14
|
+
incrementPreambleCounter,
|
|
15
|
+
} from '../state.js';
|
|
11
16
|
import { resolveActor } from '../pm/permissions.js';
|
|
12
17
|
|
|
13
18
|
function sleepMs(ms: number): Promise<void> {
|
|
@@ -62,9 +67,12 @@ interface BroadcastWaitResult {
|
|
|
62
67
|
/**
|
|
63
68
|
* Build the final message with optional preamble.
|
|
64
69
|
* Format: [SYSTEM: <preamble>]\n\n<message>
|
|
70
|
+
*
|
|
71
|
+
* Preamble injection frequency is controlled by preambleEvery config.
|
|
72
|
+
* Default: inject every 3 messages per agent to save tokens.
|
|
65
73
|
*/
|
|
66
74
|
function buildMessage(message: string, agentName: string, ctx: Context): string {
|
|
67
|
-
const { config, flags } = ctx;
|
|
75
|
+
const { config, flags, paths } = ctx;
|
|
68
76
|
|
|
69
77
|
// Skip preamble if disabled or --no-preamble flag
|
|
70
78
|
if (config.preambleMode === 'disabled' || flags.noPreamble) {
|
|
@@ -79,6 +87,22 @@ function buildMessage(message: string, agentName: string, ctx: Context): string
|
|
|
79
87
|
return message;
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
// Check preamble frequency (preambleEvery: 0 means never, 1 means always)
|
|
91
|
+
const preambleEvery = config.defaults.preambleEvery;
|
|
92
|
+
if (preambleEvery <= 0) {
|
|
93
|
+
// preambleEvery = 0 means never inject (equivalent to disabled for this agent)
|
|
94
|
+
return message;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Increment counter and check if we should inject preamble
|
|
98
|
+
// Inject on message 1, 1+N, 1+2N, ... where N = preambleEvery
|
|
99
|
+
const count = incrementPreambleCounter(paths, agentName);
|
|
100
|
+
const shouldInject = (count - 1) % preambleEvery === 0;
|
|
101
|
+
|
|
102
|
+
if (!shouldInject) {
|
|
103
|
+
return message;
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
return `[SYSTEM: ${preamble}]\n\n${message}`;
|
|
83
107
|
}
|
|
84
108
|
|
package/src/commands/update.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// update command - modify agent config
|
|
3
3
|
// ─────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
-
import type { Context } from '../types.js';
|
|
5
|
+
import type { Context, PaneEntry } from '../types.js';
|
|
6
6
|
import { ExitCodes } from '../exits.js';
|
|
7
|
-
import {
|
|
7
|
+
import { loadLocalConfigFile, saveLocalConfigFile } from '../config.js';
|
|
8
8
|
|
|
9
9
|
export function cmdUpdate(
|
|
10
10
|
ctx: Context,
|
|
@@ -23,19 +23,30 @@ export function cmdUpdate(
|
|
|
23
23
|
exit(ExitCodes.ERROR);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Load existing config to preserve all fields (preamble, deny, etc.)
|
|
27
|
+
const localConfig = loadLocalConfigFile(paths);
|
|
28
|
+
|
|
29
|
+
// Handle edge case where local config was modified externally
|
|
30
|
+
let entry = localConfig[name] as PaneEntry | undefined;
|
|
31
|
+
if (!entry) {
|
|
32
|
+
// Fall back to in-memory paneRegistry if entry is missing
|
|
33
|
+
entry = { ...config.paneRegistry[name] };
|
|
34
|
+
localConfig[name] = entry;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
const updates: string[] = [];
|
|
27
38
|
|
|
28
39
|
if (options.pane) {
|
|
29
|
-
|
|
40
|
+
entry.pane = options.pane;
|
|
30
41
|
updates.push(`pane → ${options.pane}`);
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
if (options.remark) {
|
|
34
|
-
|
|
45
|
+
entry.remark = options.remark;
|
|
35
46
|
updates.push(`remark updated`);
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
|
|
49
|
+
saveLocalConfigFile(paths, localConfig);
|
|
39
50
|
|
|
40
51
|
if (flags.json) {
|
|
41
52
|
ui.json({ updated: name, ...options });
|
package/src/config.test.ts
CHANGED
|
@@ -161,16 +161,16 @@ describe('loadConfig', () => {
|
|
|
161
161
|
expect(config.defaults.timeout).toBe(180);
|
|
162
162
|
expect(config.defaults.pollInterval).toBe(1);
|
|
163
163
|
expect(config.defaults.captureLines).toBe(100);
|
|
164
|
+
expect(config.defaults.hideOrphanTasks).toBe(false);
|
|
164
165
|
expect(config.agents).toEqual({});
|
|
165
166
|
expect(config.paneRegistry).toEqual({});
|
|
166
167
|
});
|
|
167
168
|
|
|
168
|
-
it('loads and merges global config', () => {
|
|
169
|
+
it('loads and merges global config (mode, preambleMode, defaults only)', () => {
|
|
169
170
|
const globalConfig = {
|
|
170
171
|
mode: 'wait',
|
|
171
172
|
preambleMode: 'disabled',
|
|
172
173
|
defaults: { timeout: 120 },
|
|
173
|
-
agents: { claude: { preamble: 'Be helpful' } },
|
|
174
174
|
};
|
|
175
175
|
|
|
176
176
|
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
|
|
@@ -182,7 +182,7 @@ describe('loadConfig', () => {
|
|
|
182
182
|
expect(config.preambleMode).toBe('disabled');
|
|
183
183
|
expect(config.defaults.timeout).toBe(120);
|
|
184
184
|
expect(config.defaults.pollInterval).toBe(1); // Default preserved
|
|
185
|
-
expect(config.agents
|
|
185
|
+
expect(config.agents).toEqual({}); // No agents from global config
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
it('loads local pane registry from tmux-team.json', () => {
|
|
@@ -201,12 +201,12 @@ describe('loadConfig', () => {
|
|
|
201
201
|
expect(config.paneRegistry.codex?.pane).toBe('1.1');
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
-
it('merges both global and local config', () => {
|
|
204
|
+
it('merges both global and local config (agents from local only)', () => {
|
|
205
205
|
const globalConfig = {
|
|
206
|
-
|
|
206
|
+
mode: 'wait',
|
|
207
207
|
};
|
|
208
208
|
const localConfig = {
|
|
209
|
-
claude: { pane: '1.0' },
|
|
209
|
+
claude: { pane: '1.0', preamble: 'Be brief' },
|
|
210
210
|
};
|
|
211
211
|
|
|
212
212
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
@@ -218,8 +218,9 @@ describe('loadConfig', () => {
|
|
|
218
218
|
|
|
219
219
|
const config = loadConfig(mockPaths);
|
|
220
220
|
|
|
221
|
-
expect(config.
|
|
222
|
-
expect(config.
|
|
221
|
+
expect(config.mode).toBe('wait'); // from global
|
|
222
|
+
expect(config.agents.claude?.preamble).toBe('Be brief'); // from local
|
|
223
|
+
expect(config.paneRegistry.claude?.pane).toBe('1.0'); // from local
|
|
223
224
|
});
|
|
224
225
|
|
|
225
226
|
it('throws ConfigParseError on invalid JSON in config file', () => {
|
|
@@ -240,7 +241,180 @@ describe('loadConfig', () => {
|
|
|
240
241
|
expect(err).toBeInstanceOf(ConfigParseError);
|
|
241
242
|
const parseError = err as ConfigParseError;
|
|
242
243
|
expect(parseError.filePath).toBe(mockPaths.globalConfig);
|
|
243
|
-
expect(parseError.cause).
|
|
244
|
+
expect(parseError.cause).toBeInstanceOf(SyntaxError);
|
|
244
245
|
}
|
|
245
246
|
});
|
|
247
|
+
|
|
248
|
+
it('loads local preamble into agents config', () => {
|
|
249
|
+
const localConfig = {
|
|
250
|
+
claude: { pane: '1.0', preamble: 'Be helpful and concise' },
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
254
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
255
|
+
|
|
256
|
+
const config = loadConfig(mockPaths);
|
|
257
|
+
|
|
258
|
+
expect(config.agents.claude?.preamble).toBe('Be helpful and concise');
|
|
259
|
+
expect(config.paneRegistry.claude?.pane).toBe('1.0');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('loads local deny into agents config', () => {
|
|
263
|
+
const localConfig = {
|
|
264
|
+
claude: { pane: '1.0', deny: ['pm:task:update(status)'] },
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
268
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
269
|
+
|
|
270
|
+
const config = loadConfig(mockPaths);
|
|
271
|
+
|
|
272
|
+
expect(config.agents.claude?.deny).toEqual(['pm:task:update(status)']);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('loads preamble from local config only', () => {
|
|
276
|
+
const localConfig = {
|
|
277
|
+
claude: { pane: '1.0', preamble: 'Local preamble' },
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
281
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
282
|
+
|
|
283
|
+
const config = loadConfig(mockPaths);
|
|
284
|
+
|
|
285
|
+
expect(config.agents.claude?.preamble).toBe('Local preamble');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('loads deny from local config only', () => {
|
|
289
|
+
const localConfig = {
|
|
290
|
+
claude: { pane: '1.0', deny: ['pm:task:create'] },
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
294
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
295
|
+
|
|
296
|
+
const config = loadConfig(mockPaths);
|
|
297
|
+
|
|
298
|
+
expect(config.agents.claude?.deny).toEqual(['pm:task:create']);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('handles local config with both preamble and deny', () => {
|
|
302
|
+
const localConfig = {
|
|
303
|
+
claude: { pane: '1.0', preamble: 'Be helpful', deny: ['pm:task:update(status)'] },
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
307
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
308
|
+
|
|
309
|
+
const config = loadConfig(mockPaths);
|
|
310
|
+
|
|
311
|
+
expect(config.agents.claude?.preamble).toBe('Be helpful');
|
|
312
|
+
expect(config.agents.claude?.deny).toEqual(['pm:task:update(status)']);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('handles empty preamble in local config', () => {
|
|
316
|
+
const localConfig = {
|
|
317
|
+
claude: { pane: '1.0', preamble: '' },
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
321
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
322
|
+
|
|
323
|
+
const config = loadConfig(mockPaths);
|
|
324
|
+
|
|
325
|
+
expect(config.agents.claude?.preamble).toBe('');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('handles empty deny array in local config', () => {
|
|
329
|
+
const localConfig = {
|
|
330
|
+
claude: { pane: '1.0', deny: [] },
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
334
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
335
|
+
|
|
336
|
+
const config = loadConfig(mockPaths);
|
|
337
|
+
|
|
338
|
+
expect(config.agents.claude?.deny).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('skips entries without pane field in paneRegistry', () => {
|
|
342
|
+
const localConfig = {
|
|
343
|
+
claude: { pane: '1.0' },
|
|
344
|
+
codex: { preamble: 'Preamble only, no pane' },
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
348
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
349
|
+
|
|
350
|
+
const config = loadConfig(mockPaths);
|
|
351
|
+
|
|
352
|
+
expect(config.paneRegistry.claude?.pane).toBe('1.0');
|
|
353
|
+
expect(config.paneRegistry.codex).toBeUndefined();
|
|
354
|
+
// But preamble should still be merged
|
|
355
|
+
expect(config.agents.codex?.preamble).toBe('Preamble only, no pane');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('ignores agents field in global config (local config is SSOT)', () => {
|
|
359
|
+
// Even if global config has agents, they should be ignored
|
|
360
|
+
const globalConfig = {
|
|
361
|
+
mode: 'wait',
|
|
362
|
+
agents: {
|
|
363
|
+
// This should be ignored
|
|
364
|
+
claude: { preamble: 'Global preamble', deny: ['pm:task:delete'] },
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
const localConfig = {
|
|
368
|
+
claude: { pane: '1.0', preamble: 'Local preamble' },
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
372
|
+
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
|
373
|
+
if (p === mockPaths.globalConfig) return JSON.stringify(globalConfig);
|
|
374
|
+
if (p === mockPaths.localConfig) return JSON.stringify(localConfig);
|
|
375
|
+
return '';
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const config = loadConfig(mockPaths);
|
|
379
|
+
|
|
380
|
+
// Mode from global should work
|
|
381
|
+
expect(config.mode).toBe('wait');
|
|
382
|
+
// But agents come only from local config
|
|
383
|
+
expect(config.agents.claude?.preamble).toBe('Local preamble');
|
|
384
|
+
expect(config.agents.claude?.deny).toBeUndefined(); // Not from global
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('local config defines project-specific agent roles without global pollution', () => {
|
|
388
|
+
// No global config
|
|
389
|
+
const localConfig = {
|
|
390
|
+
claude: {
|
|
391
|
+
pane: '1.0',
|
|
392
|
+
remark: 'Main implementer',
|
|
393
|
+
preamble: 'You implement features. Ask Codex for review.',
|
|
394
|
+
deny: ['pm:task:update(status)', 'pm:milestone:update(status)'],
|
|
395
|
+
},
|
|
396
|
+
codex: {
|
|
397
|
+
pane: '1.1',
|
|
398
|
+
remark: 'Code quality guard',
|
|
399
|
+
preamble: 'You review code. You can update task status.',
|
|
400
|
+
// No deny - codex can do everything
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
405
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
406
|
+
|
|
407
|
+
const config = loadConfig(mockPaths);
|
|
408
|
+
|
|
409
|
+
// Claude has deny rules
|
|
410
|
+
expect(config.agents.claude?.deny).toEqual([
|
|
411
|
+
'pm:task:update(status)',
|
|
412
|
+
'pm:milestone:update(status)',
|
|
413
|
+
]);
|
|
414
|
+
expect(config.agents.claude?.preamble).toBe('You implement features. Ask Codex for review.');
|
|
415
|
+
|
|
416
|
+
// Codex has no deny rules (full access)
|
|
417
|
+
expect(config.agents.codex?.deny).toBeUndefined();
|
|
418
|
+
expect(config.agents.codex?.preamble).toBe('You review code. You can update task status.');
|
|
419
|
+
});
|
|
246
420
|
});
|