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.
@@ -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 { cleanupState, clearActiveRequest, setActiveRequest } from '../state.js';
11
- import { resolveActor } from '../pm/permissions.js';
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
- marker: string;
40
- baseline: string;
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 marker = `{tmux-team-end:${nonce}}`;
215
+ const startMarker = `{tmux-team-start:${nonce}}`;
216
+ const endMarker = `{tmux-team-end:${nonce}}`;
192
217
 
193
- // Build message with preamble, then append nonce instruction
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: ${marker}]`;
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
- marker,
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
- const markerIndex = output.indexOf(marker);
283
- if (markerIndex === -1) continue;
284
-
285
- let startIndex = 0;
286
- const baselineIndex = baseline ? output.lastIndexOf(baseline) : -1;
287
- if (baselineIndex !== -1) {
288
- startIndex = baselineIndex + baseline.length;
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(startIndex, markerIndex).trim();
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, marker, response };
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 and capture baselines
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 marker = `{tmux-team-end:${nonce}}`;
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: ${marker}]`;
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
- marker,
391
- baseline,
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
- marker,
404
- baseline,
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
- const markerIndex = output.indexOf(state.marker);
500
- if (markerIndex === -1) continue;
501
-
502
- // Found marker - extract response
503
- let startIndex = 0;
504
- const baselineIndex = state.baseline ? output.lastIndexOf(state.baseline) : -1;
505
- if (baselineIndex !== -1) {
506
- startIndex = baselineIndex + state.baseline.length;
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(startIndex, markerIndex).trim();
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
- marker: s.marker,
572
- baseline: '', // Don't include baseline in output
601
+ startMarker: s.startMarker,
602
+ endMarker: s.endMarker,
573
603
  status: s.status,
574
604
  response: s.response,
575
605
  error: s.error,
@@ -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 { saveLocalConfig } from '../config.js';
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
- config.paneRegistry[name].pane = options.pane;
40
+ entry.pane = options.pane;
30
41
  updates.push(`pane → ${options.pane}`);
31
42
  }
32
43
 
33
44
  if (options.remark) {
34
- config.paneRegistry[name].remark = options.remark;
45
+ entry.remark = options.remark;
35
46
  updates.push(`remark updated`);
36
47
  }
37
48
 
38
- saveLocalConfig(paths, config.paneRegistry);
49
+ saveLocalConfigFile(paths, localConfig);
39
50
 
40
51
  if (flags.json) {
41
52
  ui.json({ updated: name, ...options });
@@ -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.claude?.preamble).toBe('Be helpful');
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
- agents: { claude: { preamble: 'Be brief' } },
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.agents.claude?.preamble).toBe('Be brief');
222
- expect(config.paneRegistry.claude?.pane).toBe('1.0');
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).toBeDefined();
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: Omit<GlobalConfig, 'agents'> & { agents: Record<string, never> } = {
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
- // Set pane registry (filter out $config)
159
- config.paneRegistry = paneEntries as LocalConfig;
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 });