tmux-team 2.1.0 → 3.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -53
- package/package.json +1 -1
- package/src/cli.ts +0 -5
- package/src/commands/add.ts +9 -5
- package/src/commands/config.ts +62 -9
- package/src/commands/help.ts +2 -2
- package/src/commands/preamble.ts +22 -25
- package/src/commands/remove.ts +5 -3
- package/src/commands/talk.test.ts +479 -44
- package/src/commands/talk.ts +95 -65
- package/src/commands/update.ts +16 -5
- package/src/config.test.ts +182 -9
- package/src/config.ts +28 -16
- package/src/identity.ts +89 -0
- package/src/state.test.ts +20 -10
- package/src/state.ts +28 -1
- package/src/types.ts +6 -2
- package/src/ui.ts +2 -1
- package/src/version.ts +1 -1
- package/src/pm/commands.test.ts +0 -1128
- package/src/pm/commands.ts +0 -723
- package/src/pm/manager.test.ts +0 -377
- package/src/pm/manager.ts +0 -146
- package/src/pm/permissions.test.ts +0 -332
- package/src/pm/permissions.ts +0 -279
- package/src/pm/storage/adapter.ts +0 -55
- package/src/pm/storage/fs.test.ts +0 -384
- package/src/pm/storage/fs.ts +0 -256
- package/src/pm/storage/github.ts +0 -763
- package/src/pm/types.ts +0 -85
package/src/commands/talk.ts
CHANGED
|
@@ -7,8 +7,13 @@ 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 {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
cleanupState,
|
|
12
|
+
clearActiveRequest,
|
|
13
|
+
setActiveRequest,
|
|
14
|
+
incrementPreambleCounter,
|
|
15
|
+
} from '../state.js';
|
|
16
|
+
import { resolveActor } from '../identity.js';
|
|
12
17
|
|
|
13
18
|
function sleepMs(ms: number): Promise<void> {
|
|
14
19
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -36,8 +41,8 @@ interface AgentWaitState {
|
|
|
36
41
|
pane: string;
|
|
37
42
|
requestId: string;
|
|
38
43
|
nonce: string;
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
startMarker: string;
|
|
45
|
+
endMarker: string;
|
|
41
46
|
status: 'pending' | 'completed' | 'timeout' | 'error';
|
|
42
47
|
response?: string;
|
|
43
48
|
error?: string;
|
|
@@ -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
|
|
|
@@ -188,11 +212,12 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
188
212
|
|
|
189
213
|
const requestId = makeRequestId();
|
|
190
214
|
const nonce = makeNonce();
|
|
191
|
-
const
|
|
215
|
+
const startMarker = `{tmux-team-start:${nonce}}`;
|
|
216
|
+
const endMarker = `{tmux-team-end:${nonce}}`;
|
|
192
217
|
|
|
193
|
-
// Build message with preamble, then
|
|
218
|
+
// Build message with preamble, then wrap with start/end markers
|
|
194
219
|
const messageWithPreamble = buildMessage(message, target, ctx);
|
|
195
|
-
const fullMessage = `${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${
|
|
220
|
+
const fullMessage = `${startMarker}\n${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${endMarker}]`;
|
|
196
221
|
|
|
197
222
|
// Best-effort cleanup and soft-lock warning
|
|
198
223
|
const state = cleanupState(ctx.paths, 60 * 60); // 1 hour TTL
|
|
@@ -203,14 +228,6 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
203
228
|
);
|
|
204
229
|
}
|
|
205
230
|
|
|
206
|
-
let baseline = '';
|
|
207
|
-
try {
|
|
208
|
-
baseline = tmux.capture(pane, captureLines);
|
|
209
|
-
} catch {
|
|
210
|
-
ui.error(`Failed to capture pane ${pane}. Is tmux running?`);
|
|
211
|
-
exit(ExitCodes.ERROR);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
231
|
setActiveRequest(ctx.paths, target, { id: requestId, nonce, pane, startedAtMs: Date.now() });
|
|
215
232
|
|
|
216
233
|
const startedAt = Date.now();
|
|
@@ -243,7 +260,8 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
243
260
|
error: `Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s`,
|
|
244
261
|
requestId,
|
|
245
262
|
nonce,
|
|
246
|
-
|
|
263
|
+
startMarker,
|
|
264
|
+
endMarker,
|
|
247
265
|
});
|
|
248
266
|
exit(ExitCodes.TIMEOUT);
|
|
249
267
|
}
|
|
@@ -279,16 +297,32 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
279
297
|
exit(ExitCodes.ERROR);
|
|
280
298
|
}
|
|
281
299
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
300
|
+
// Find end marker (use lastIndexOf because the marker appears in the instruction AND agent's response)
|
|
301
|
+
const endMarkerIndex = output.lastIndexOf(endMarker);
|
|
302
|
+
if (endMarkerIndex === -1) continue;
|
|
303
|
+
|
|
304
|
+
// Find the end of our instruction by looking for `}]` pattern (the instruction ends with `{end}]`)
|
|
305
|
+
// This is more reliable than looking for newline after start marker because
|
|
306
|
+
// the message may be word-wrapped across multiple visual lines
|
|
307
|
+
let responseStart = 0;
|
|
308
|
+
const instructionEndPattern = '}]';
|
|
309
|
+
const instructionEndIndex = output.lastIndexOf(instructionEndPattern, endMarkerIndex);
|
|
310
|
+
if (instructionEndIndex !== -1) {
|
|
311
|
+
// Find the first newline after the instruction's closing `}]`
|
|
312
|
+
responseStart = output.indexOf('\n', instructionEndIndex + 2);
|
|
313
|
+
if (responseStart !== -1) responseStart += 1;
|
|
314
|
+
else responseStart = instructionEndIndex + 2;
|
|
315
|
+
} else {
|
|
316
|
+
// Fallback: if no `}]` found, try to find newline after start marker
|
|
317
|
+
const startMarkerIndex = output.lastIndexOf(startMarker);
|
|
318
|
+
if (startMarkerIndex !== -1) {
|
|
319
|
+
responseStart = output.indexOf('\n', startMarkerIndex);
|
|
320
|
+
if (responseStart !== -1) responseStart += 1;
|
|
321
|
+
else responseStart = startMarkerIndex + startMarker.length;
|
|
322
|
+
}
|
|
289
323
|
}
|
|
290
324
|
|
|
291
|
-
const response = output.slice(
|
|
325
|
+
const response = output.slice(responseStart, endMarkerIndex).trim();
|
|
292
326
|
|
|
293
327
|
if (!flags.json && isTTY) {
|
|
294
328
|
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
@@ -299,7 +333,7 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
299
333
|
|
|
300
334
|
clearActiveRequest(ctx.paths, target, requestId);
|
|
301
335
|
|
|
302
|
-
const result: WaitResult = { requestId, nonce,
|
|
336
|
+
const result: WaitResult = { requestId, nonce, startMarker, endMarker, response };
|
|
303
337
|
if (flags.json) {
|
|
304
338
|
ui.json({ target, pane, status: 'completed', ...result });
|
|
305
339
|
} else {
|
|
@@ -343,35 +377,16 @@ async function cmdTalkAllWait(
|
|
|
343
377
|
);
|
|
344
378
|
}
|
|
345
379
|
|
|
346
|
-
// Phase 1: Send messages to all agents
|
|
380
|
+
// Phase 1: Send messages to all agents with start/end markers
|
|
347
381
|
for (const [name, data] of targetAgents) {
|
|
348
382
|
const requestId = makeRequestId();
|
|
349
383
|
const nonce = makeNonce(); // Unique nonce per agent (#19)
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
let baseline = '';
|
|
353
|
-
try {
|
|
354
|
-
baseline = tmux.capture(data.pane, captureLines);
|
|
355
|
-
} catch {
|
|
356
|
-
agentStates.push({
|
|
357
|
-
agent: name,
|
|
358
|
-
pane: data.pane,
|
|
359
|
-
requestId,
|
|
360
|
-
nonce,
|
|
361
|
-
marker,
|
|
362
|
-
baseline: '',
|
|
363
|
-
status: 'error',
|
|
364
|
-
error: `Failed to capture pane ${data.pane}`,
|
|
365
|
-
});
|
|
366
|
-
if (!flags.json) {
|
|
367
|
-
ui.warn(`Failed to capture ${name} (${data.pane})`);
|
|
368
|
-
}
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
384
|
+
const startMarker = `{tmux-team-start:${nonce}}`;
|
|
385
|
+
const endMarker = `{tmux-team-end:${nonce}}`;
|
|
371
386
|
|
|
372
|
-
// Build and send message
|
|
387
|
+
// Build and send message with start/end markers
|
|
373
388
|
const messageWithPreamble = buildMessage(message, name, ctx);
|
|
374
|
-
const fullMessage = `${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${
|
|
389
|
+
const fullMessage = `${startMarker}\n${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${endMarker}]`;
|
|
375
390
|
const msg = name === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
|
|
376
391
|
|
|
377
392
|
try {
|
|
@@ -387,8 +402,8 @@ async function cmdTalkAllWait(
|
|
|
387
402
|
pane: data.pane,
|
|
388
403
|
requestId,
|
|
389
404
|
nonce,
|
|
390
|
-
|
|
391
|
-
|
|
405
|
+
startMarker,
|
|
406
|
+
endMarker,
|
|
392
407
|
status: 'pending',
|
|
393
408
|
});
|
|
394
409
|
if (!flags.json) {
|
|
@@ -400,8 +415,8 @@ async function cmdTalkAllWait(
|
|
|
400
415
|
pane: data.pane,
|
|
401
416
|
requestId,
|
|
402
417
|
nonce,
|
|
403
|
-
|
|
404
|
-
|
|
418
|
+
startMarker,
|
|
419
|
+
endMarker,
|
|
405
420
|
status: 'error',
|
|
406
421
|
error: `Failed to send to pane ${data.pane}`,
|
|
407
422
|
});
|
|
@@ -496,17 +511,32 @@ async function cmdTalkAllWait(
|
|
|
496
511
|
continue;
|
|
497
512
|
}
|
|
498
513
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
514
|
+
// Find end marker (use lastIndexOf because the marker appears in the instruction AND agent's response)
|
|
515
|
+
const endMarkerIndex = output.lastIndexOf(state.endMarker);
|
|
516
|
+
if (endMarkerIndex === -1) continue;
|
|
517
|
+
|
|
518
|
+
// Find the end of our instruction by looking for `}]` pattern (the instruction ends with `{end}]`)
|
|
519
|
+
// This is more reliable than looking for newline after start marker because
|
|
520
|
+
// the message may be word-wrapped across multiple visual lines
|
|
521
|
+
let responseStart = 0;
|
|
522
|
+
const instructionEndPattern = '}]';
|
|
523
|
+
const instructionEndIndex = output.lastIndexOf(instructionEndPattern, endMarkerIndex);
|
|
524
|
+
if (instructionEndIndex !== -1) {
|
|
525
|
+
// Find the first newline after the instruction's closing `}]`
|
|
526
|
+
responseStart = output.indexOf('\n', instructionEndIndex + 2);
|
|
527
|
+
if (responseStart !== -1) responseStart += 1;
|
|
528
|
+
else responseStart = instructionEndIndex + 2;
|
|
529
|
+
} else {
|
|
530
|
+
// Fallback: if no `}]` found, try to find newline after start marker
|
|
531
|
+
const startMarkerIndex = output.lastIndexOf(state.startMarker);
|
|
532
|
+
if (startMarkerIndex !== -1) {
|
|
533
|
+
responseStart = output.indexOf('\n', startMarkerIndex);
|
|
534
|
+
if (responseStart !== -1) responseStart += 1;
|
|
535
|
+
else responseStart = startMarkerIndex + state.startMarker.length;
|
|
536
|
+
}
|
|
507
537
|
}
|
|
508
538
|
|
|
509
|
-
state.response = output.slice(
|
|
539
|
+
state.response = output.slice(responseStart, endMarkerIndex).trim();
|
|
510
540
|
state.status = 'completed';
|
|
511
541
|
state.elapsedMs = Date.now() - startedAt;
|
|
512
542
|
clearActiveRequest(paths, state.agent, state.requestId);
|
|
@@ -568,8 +598,8 @@ function outputBroadcastResults(
|
|
|
568
598
|
pane: s.pane,
|
|
569
599
|
requestId: s.requestId,
|
|
570
600
|
nonce: s.nonce,
|
|
571
|
-
|
|
572
|
-
|
|
601
|
+
startMarker: s.startMarker,
|
|
602
|
+
endMarker: s.endMarker,
|
|
573
603
|
status: s.status,
|
|
574
604
|
response: s.response,
|
|
575
605
|
error: s.error,
|
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
|
@@ -165,12 +165,11 @@ describe('loadConfig', () => {
|
|
|
165
165
|
expect(config.paneRegistry).toEqual({});
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
it('loads and merges global config', () => {
|
|
168
|
+
it('loads and merges global config (mode, preambleMode, defaults only)', () => {
|
|
169
169
|
const globalConfig = {
|
|
170
170
|
mode: 'wait',
|
|
171
171
|
preambleMode: 'disabled',
|
|
172
172
|
defaults: { timeout: 120 },
|
|
173
|
-
agents: { claude: { preamble: 'Be helpful' } },
|
|
174
173
|
};
|
|
175
174
|
|
|
176
175
|
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
|
|
@@ -182,7 +181,7 @@ describe('loadConfig', () => {
|
|
|
182
181
|
expect(config.preambleMode).toBe('disabled');
|
|
183
182
|
expect(config.defaults.timeout).toBe(120);
|
|
184
183
|
expect(config.defaults.pollInterval).toBe(1); // Default preserved
|
|
185
|
-
expect(config.agents
|
|
184
|
+
expect(config.agents).toEqual({}); // No agents from global config
|
|
186
185
|
});
|
|
187
186
|
|
|
188
187
|
it('loads local pane registry from tmux-team.json', () => {
|
|
@@ -201,12 +200,12 @@ describe('loadConfig', () => {
|
|
|
201
200
|
expect(config.paneRegistry.codex?.pane).toBe('1.1');
|
|
202
201
|
});
|
|
203
202
|
|
|
204
|
-
it('merges both global and local config', () => {
|
|
203
|
+
it('merges both global and local config (agents from local only)', () => {
|
|
205
204
|
const globalConfig = {
|
|
206
|
-
|
|
205
|
+
mode: 'wait',
|
|
207
206
|
};
|
|
208
207
|
const localConfig = {
|
|
209
|
-
claude: { pane: '1.0' },
|
|
208
|
+
claude: { pane: '1.0', preamble: 'Be brief' },
|
|
210
209
|
};
|
|
211
210
|
|
|
212
211
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
@@ -218,8 +217,9 @@ describe('loadConfig', () => {
|
|
|
218
217
|
|
|
219
218
|
const config = loadConfig(mockPaths);
|
|
220
219
|
|
|
221
|
-
expect(config.
|
|
222
|
-
expect(config.
|
|
220
|
+
expect(config.mode).toBe('wait'); // from global
|
|
221
|
+
expect(config.agents.claude?.preamble).toBe('Be brief'); // from local
|
|
222
|
+
expect(config.paneRegistry.claude?.pane).toBe('1.0'); // from local
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
it('throws ConfigParseError on invalid JSON in config file', () => {
|
|
@@ -240,7 +240,180 @@ describe('loadConfig', () => {
|
|
|
240
240
|
expect(err).toBeInstanceOf(ConfigParseError);
|
|
241
241
|
const parseError = err as ConfigParseError;
|
|
242
242
|
expect(parseError.filePath).toBe(mockPaths.globalConfig);
|
|
243
|
-
expect(parseError.cause).
|
|
243
|
+
expect(parseError.cause).toBeInstanceOf(SyntaxError);
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
|
+
|
|
247
|
+
it('loads local preamble into agents config', () => {
|
|
248
|
+
const localConfig = {
|
|
249
|
+
claude: { pane: '1.0', preamble: 'Be helpful and concise' },
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
253
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
254
|
+
|
|
255
|
+
const config = loadConfig(mockPaths);
|
|
256
|
+
|
|
257
|
+
expect(config.agents.claude?.preamble).toBe('Be helpful and concise');
|
|
258
|
+
expect(config.paneRegistry.claude?.pane).toBe('1.0');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('loads local deny into agents config', () => {
|
|
262
|
+
const localConfig = {
|
|
263
|
+
claude: { pane: '1.0', deny: ['pm:task:update(status)'] },
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
267
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
268
|
+
|
|
269
|
+
const config = loadConfig(mockPaths);
|
|
270
|
+
|
|
271
|
+
expect(config.agents.claude?.deny).toEqual(['pm:task:update(status)']);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('loads preamble from local config only', () => {
|
|
275
|
+
const localConfig = {
|
|
276
|
+
claude: { pane: '1.0', preamble: 'Local preamble' },
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
280
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
281
|
+
|
|
282
|
+
const config = loadConfig(mockPaths);
|
|
283
|
+
|
|
284
|
+
expect(config.agents.claude?.preamble).toBe('Local preamble');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('loads deny from local config only', () => {
|
|
288
|
+
const localConfig = {
|
|
289
|
+
claude: { pane: '1.0', deny: ['pm:task:create'] },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
293
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
294
|
+
|
|
295
|
+
const config = loadConfig(mockPaths);
|
|
296
|
+
|
|
297
|
+
expect(config.agents.claude?.deny).toEqual(['pm:task:create']);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('handles local config with both preamble and deny', () => {
|
|
301
|
+
const localConfig = {
|
|
302
|
+
claude: { pane: '1.0', preamble: 'Be helpful', deny: ['pm:task:update(status)'] },
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
306
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
307
|
+
|
|
308
|
+
const config = loadConfig(mockPaths);
|
|
309
|
+
|
|
310
|
+
expect(config.agents.claude?.preamble).toBe('Be helpful');
|
|
311
|
+
expect(config.agents.claude?.deny).toEqual(['pm:task:update(status)']);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('handles empty preamble in local config', () => {
|
|
315
|
+
const localConfig = {
|
|
316
|
+
claude: { pane: '1.0', preamble: '' },
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
320
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
321
|
+
|
|
322
|
+
const config = loadConfig(mockPaths);
|
|
323
|
+
|
|
324
|
+
expect(config.agents.claude?.preamble).toBe('');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('handles empty deny array in local config', () => {
|
|
328
|
+
const localConfig = {
|
|
329
|
+
claude: { pane: '1.0', deny: [] },
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
333
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
334
|
+
|
|
335
|
+
const config = loadConfig(mockPaths);
|
|
336
|
+
|
|
337
|
+
expect(config.agents.claude?.deny).toEqual([]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('skips entries without pane field in paneRegistry', () => {
|
|
341
|
+
const localConfig = {
|
|
342
|
+
claude: { pane: '1.0' },
|
|
343
|
+
codex: { preamble: 'Preamble only, no pane' },
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
347
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
348
|
+
|
|
349
|
+
const config = loadConfig(mockPaths);
|
|
350
|
+
|
|
351
|
+
expect(config.paneRegistry.claude?.pane).toBe('1.0');
|
|
352
|
+
expect(config.paneRegistry.codex).toBeUndefined();
|
|
353
|
+
// But preamble should still be merged
|
|
354
|
+
expect(config.agents.codex?.preamble).toBe('Preamble only, no pane');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('ignores agents field in global config (local config is SSOT)', () => {
|
|
358
|
+
// Even if global config has agents, they should be ignored
|
|
359
|
+
const globalConfig = {
|
|
360
|
+
mode: 'wait',
|
|
361
|
+
agents: {
|
|
362
|
+
// This should be ignored
|
|
363
|
+
claude: { preamble: 'Global preamble', deny: ['pm:task:delete'] },
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
const localConfig = {
|
|
367
|
+
claude: { pane: '1.0', preamble: 'Local preamble' },
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
371
|
+
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
|
372
|
+
if (p === mockPaths.globalConfig) return JSON.stringify(globalConfig);
|
|
373
|
+
if (p === mockPaths.localConfig) return JSON.stringify(localConfig);
|
|
374
|
+
return '';
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const config = loadConfig(mockPaths);
|
|
378
|
+
|
|
379
|
+
// Mode from global should work
|
|
380
|
+
expect(config.mode).toBe('wait');
|
|
381
|
+
// But agents come only from local config
|
|
382
|
+
expect(config.agents.claude?.preamble).toBe('Local preamble');
|
|
383
|
+
expect(config.agents.claude?.deny).toBeUndefined(); // Not from global
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('local config defines project-specific agent roles without global pollution', () => {
|
|
387
|
+
// No global config
|
|
388
|
+
const localConfig = {
|
|
389
|
+
claude: {
|
|
390
|
+
pane: '1.0',
|
|
391
|
+
remark: 'Main implementer',
|
|
392
|
+
preamble: 'You implement features. Ask Codex for review.',
|
|
393
|
+
deny: ['pm:task:update(status)', 'pm:milestone:update(status)'],
|
|
394
|
+
},
|
|
395
|
+
codex: {
|
|
396
|
+
pane: '1.1',
|
|
397
|
+
remark: 'Code quality guard',
|
|
398
|
+
preamble: 'You review code. You can update task status.',
|
|
399
|
+
// No deny - codex can do everything
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
404
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
405
|
+
|
|
406
|
+
const config = loadConfig(mockPaths);
|
|
407
|
+
|
|
408
|
+
// Claude has deny rules
|
|
409
|
+
expect(config.agents.claude?.deny).toEqual([
|
|
410
|
+
'pm:task:update(status)',
|
|
411
|
+
'pm:milestone:update(status)',
|
|
412
|
+
]);
|
|
413
|
+
expect(config.agents.claude?.preamble).toBe('You implement features. Ask Codex for review.');
|
|
414
|
+
|
|
415
|
+
// Codex has no deny rules (full access)
|
|
416
|
+
expect(config.agents.codex?.deny).toBeUndefined();
|
|
417
|
+
expect(config.agents.codex?.preamble).toBe('You review code. You can update task status.');
|
|
418
|
+
});
|
|
246
419
|
});
|
package/src/config.ts
CHANGED
|
@@ -19,15 +19,15 @@ const LOCAL_CONFIG_FILENAME = 'tmux-team.json';
|
|
|
19
19
|
const STATE_FILENAME = 'state.json';
|
|
20
20
|
|
|
21
21
|
// Default configuration values
|
|
22
|
-
const DEFAULT_CONFIG:
|
|
22
|
+
const DEFAULT_CONFIG: GlobalConfig = {
|
|
23
23
|
mode: 'polling',
|
|
24
24
|
preambleMode: 'always',
|
|
25
25
|
defaults: {
|
|
26
26
|
timeout: 180,
|
|
27
27
|
pollInterval: 1,
|
|
28
28
|
captureLines: 100,
|
|
29
|
+
preambleEvery: 3, // inject preamble every 3 messages
|
|
29
30
|
},
|
|
30
|
-
agents: {},
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -130,7 +130,7 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
130
130
|
paneRegistry: {},
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
-
// Merge global config
|
|
133
|
+
// Merge global config (mode, preambleMode, defaults only)
|
|
134
134
|
const globalConfig = loadJsonFile<Partial<GlobalConfig>>(paths.globalConfig);
|
|
135
135
|
if (globalConfig) {
|
|
136
136
|
if (globalConfig.mode) config.mode = globalConfig.mode;
|
|
@@ -138,12 +138,10 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
138
138
|
if (globalConfig.defaults) {
|
|
139
139
|
config.defaults = { ...config.defaults, ...globalConfig.defaults };
|
|
140
140
|
}
|
|
141
|
-
if (globalConfig.agents) {
|
|
142
|
-
config.agents = { ...config.agents, ...globalConfig.agents };
|
|
143
|
-
}
|
|
144
141
|
}
|
|
145
142
|
|
|
146
|
-
// Load local config (pane registry + optional settings)
|
|
143
|
+
// Load local config (pane registry + optional settings + agent config)
|
|
144
|
+
// Local config is the SSOT for agent configuration (preamble, deny)
|
|
147
145
|
const localConfigFile = loadJsonFile<LocalConfigFile>(paths.localConfig);
|
|
148
146
|
if (localConfigFile) {
|
|
149
147
|
// Extract local settings if present
|
|
@@ -153,22 +151,36 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
153
151
|
if (localSettings) {
|
|
154
152
|
if (localSettings.mode) config.mode = localSettings.mode;
|
|
155
153
|
if (localSettings.preambleMode) config.preambleMode = localSettings.preambleMode;
|
|
154
|
+
if (localSettings.preambleEvery !== undefined) {
|
|
155
|
+
config.defaults.preambleEvery = localSettings.preambleEvery;
|
|
156
|
+
}
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
//
|
|
159
|
-
|
|
159
|
+
// Build pane registry and agents config from local entries
|
|
160
|
+
for (const [agentName, entry] of Object.entries(paneEntries)) {
|
|
161
|
+
const paneEntry = entry as LocalConfig[string];
|
|
162
|
+
|
|
163
|
+
// Add to pane registry if has valid pane field
|
|
164
|
+
if (paneEntry.pane) {
|
|
165
|
+
config.paneRegistry[agentName] = paneEntry;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Build agents config from preamble/deny fields
|
|
169
|
+
const hasPreamble = Object.prototype.hasOwnProperty.call(paneEntry, 'preamble');
|
|
170
|
+
const hasDeny = Object.prototype.hasOwnProperty.call(paneEntry, 'deny');
|
|
171
|
+
|
|
172
|
+
if (hasPreamble || hasDeny) {
|
|
173
|
+
config.agents[agentName] = {
|
|
174
|
+
...(hasPreamble && { preamble: paneEntry.preamble }),
|
|
175
|
+
...(hasDeny && { deny: paneEntry.deny }),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
160
179
|
}
|
|
161
180
|
|
|
162
181
|
return config;
|
|
163
182
|
}
|
|
164
183
|
|
|
165
|
-
export function saveLocalConfig(
|
|
166
|
-
paths: Paths,
|
|
167
|
-
paneRegistry: Record<string, { pane: string; remark?: string }>
|
|
168
|
-
): void {
|
|
169
|
-
fs.writeFileSync(paths.localConfig, JSON.stringify(paneRegistry, null, 2) + '\n');
|
|
170
|
-
}
|
|
171
|
-
|
|
172
184
|
export function ensureGlobalDir(paths: Paths): void {
|
|
173
185
|
if (!fs.existsSync(paths.globalDir)) {
|
|
174
186
|
fs.mkdirSync(paths.globalDir, { recursive: true });
|