tmux-team 3.0.1 → 3.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.
@@ -19,6 +19,24 @@ function sleepMs(ms: number): Promise<void> {
19
19
  return new Promise((resolve) => setTimeout(resolve, ms));
20
20
  }
21
21
 
22
+ /**
23
+ * Clean Gemini CLI response by removing UI artifacts.
24
+ */
25
+ function cleanGeminiResponse(response: string): string {
26
+ return response
27
+ .split('\n')
28
+ .filter((line) => {
29
+ // Remove "Responding with..." status lines
30
+ if (/Responding with\s+\S+/.test(line)) return false;
31
+ // Remove empty lines with only whitespace/box chars
32
+ if (/^[\s█]*$/.test(line)) return false;
33
+ return true;
34
+ })
35
+ .map((line) => line.replace(/^[\s█]*✦?\s*/, '').replace(/[\s█]*$/, ''))
36
+ .join('\n')
37
+ .trim();
38
+ }
39
+
22
40
  function makeRequestId(): string {
23
41
  return `req_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`;
24
42
  }
@@ -27,6 +45,10 @@ function makeNonce(): string {
27
45
  return crypto.randomBytes(2).toString('hex');
28
46
  }
29
47
 
48
+ function makeEndMarker(nonce: string): string {
49
+ return `---RESPONSE-END-${nonce}---`;
50
+ }
51
+
30
52
  function renderWaitLine(agent: string, elapsedSeconds: number): string {
31
53
  const s = Math.max(0, Math.floor(elapsedSeconds));
32
54
  return `⏳ Waiting for ${agent}... (${s}s)`;
@@ -35,35 +57,23 @@ function renderWaitLine(agent: string, elapsedSeconds: number): string {
35
57
  /**
36
58
  * Extract partial response from output when end marker is not found.
37
59
  * Used to capture whatever the agent wrote before timeout.
60
+ * Looks for first occurrence of end marker (in our instruction) and extracts content after it.
38
61
  */
39
62
  function extractPartialResponse(
40
63
  output: string,
41
- startMarker: string,
42
- _endMarker: string
64
+ endMarker: string,
65
+ maxLines: number
43
66
  ): string | null {
44
- // Look for instruction end pattern `}]` (the instruction ends with `{end}]`)
45
- const instructionEndPattern = '}]';
46
- const instructionEndIndex = output.lastIndexOf(instructionEndPattern);
47
-
48
- let responseStart = 0;
49
- if (instructionEndIndex !== -1) {
50
- // Find the first newline after the instruction's closing `}]`
51
- responseStart = output.indexOf('\n', instructionEndIndex + 2);
52
- if (responseStart !== -1) responseStart += 1;
53
- else responseStart = instructionEndIndex + 2;
54
- } else {
55
- // Fallback: try to find newline after start marker
56
- const startMarkerIndex = output.lastIndexOf(startMarker);
57
- if (startMarkerIndex !== -1) {
58
- responseStart = output.indexOf('\n', startMarkerIndex);
59
- if (responseStart !== -1) responseStart += 1;
60
- else return null; // Can't find response start
61
- } else {
62
- return null; // Start marker not found
63
- }
64
- }
67
+ const lines = output.split('\n');
68
+ const firstMarkerLineIndex = lines.findIndex((line) => line.includes(endMarker));
69
+
70
+ if (firstMarkerLineIndex === -1) return null;
65
71
 
66
- const partial = output.slice(responseStart).trim();
72
+ // Get lines after our instruction's end marker
73
+ const responseLines = lines.slice(firstMarkerLineIndex + 1);
74
+ const limitedLines = responseLines.slice(-maxLines); // Take last N lines
75
+
76
+ const partial = limitedLines.join('\n').trim();
67
77
  return partial || null;
68
78
  }
69
79
 
@@ -76,7 +86,6 @@ interface AgentWaitState {
76
86
  pane: string;
77
87
  requestId: string;
78
88
  nonce: string;
79
- startMarker: string;
80
89
  endMarker: string;
81
90
  status: 'pending' | 'completed' | 'timeout' | 'error';
82
91
  response?: string;
@@ -248,12 +257,11 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
248
257
 
249
258
  const requestId = makeRequestId();
250
259
  const nonce = makeNonce();
251
- const startMarker = `{tmux-team-start:${nonce}}`;
252
- const endMarker = `{tmux-team-end:${nonce}}`;
260
+ const endMarker = makeEndMarker(nonce);
253
261
 
254
- // Build message with preamble, then wrap with start/end markers
262
+ // Build message with preamble and end marker instruction
255
263
  const messageWithPreamble = buildMessage(message, target, ctx);
256
- const fullMessage = `${startMarker}\n${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${endMarker}]`;
264
+ const fullMessage = `${messageWithPreamble}\n\nWhen you finish responding, print this exact line:\n${endMarker}`;
257
265
 
258
266
  // Best-effort cleanup and soft-lock warning
259
267
  const state = cleanupState(ctx.paths, 60 * 60); // 1 hour TTL
@@ -270,6 +278,12 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
270
278
  let lastNonTtyLogAt = 0;
271
279
  const isTTY = process.stdout.isTTY && !flags.json;
272
280
 
281
+ // Debounce detection: wait for output to stabilize
282
+ const MIN_WAIT_MS = 3000; // Wait at least 3 seconds before detecting completion
283
+ const IDLE_THRESHOLD_MS = 3000; // Content unchanged for 3 seconds = complete
284
+ let lastOutput = '';
285
+ let lastOutputChangeAt = Date.now();
286
+
273
287
  const onSigint = (): void => {
274
288
  clearActiveRequest(ctx.paths, target, requestId);
275
289
  if (!flags.json) process.stdout.write('\n');
@@ -281,6 +295,13 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
281
295
 
282
296
  try {
283
297
  const msg = target === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
298
+
299
+ if (flags.debug) {
300
+ console.error(`[DEBUG] Starting wait mode for ${target}`);
301
+ console.error(`[DEBUG] End marker: ${endMarker}`);
302
+ console.error(`[DEBUG] Message sent:\n${msg}`);
303
+ }
304
+
284
305
  tmux.send(pane, msg);
285
306
 
286
307
  while (true) {
@@ -289,11 +310,14 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
289
310
  clearActiveRequest(ctx.paths, target, requestId);
290
311
 
291
312
  // Capture partial response on timeout
313
+ const responseLines = flags.lines ?? 100;
292
314
  let partialResponse: string | null = null;
293
315
  try {
294
316
  const output = tmux.capture(pane, captureLines);
295
- const extracted = extractPartialResponse(output, startMarker, endMarker);
296
- if (extracted) partialResponse = extracted;
317
+ const extracted = extractPartialResponse(output, endMarker, responseLines);
318
+ if (extracted) {
319
+ partialResponse = target === 'gemini' ? cleanGeminiResponse(extracted) : extracted;
320
+ }
297
321
  } catch {
298
322
  // Ignore capture errors on timeout
299
323
  }
@@ -310,7 +334,6 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
310
334
  error: `Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s`,
311
335
  requestId,
312
336
  nonce,
313
- startMarker,
314
337
  endMarker,
315
338
  partialResponse,
316
339
  });
@@ -351,39 +374,90 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
351
374
  exit(ExitCodes.ERROR);
352
375
  }
353
376
 
354
- // Find end marker - it appears once in our instruction and again when agent prints it
355
- // We need TWO occurrences: one in instruction + one from agent = complete
356
- // Only ONE occurrence means it's just in instruction = still waiting
357
- const firstEndMarkerIndex = output.indexOf(endMarker);
358
- const lastEndMarkerIndex = output.lastIndexOf(endMarker);
359
- if (firstEndMarkerIndex === -1 || firstEndMarkerIndex === lastEndMarkerIndex) {
360
- // No marker or only one (in instruction) - still waiting
377
+ // DEBUG: Log captured output
378
+ if (flags.debug) {
379
+ const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
380
+ const firstIdx = output.indexOf(endMarker);
381
+ const lastIdx = output.lastIndexOf(endMarker);
382
+ console.error(`\n[DEBUG ${elapsedSec}s] Output: ${output.length} chars`);
383
+ console.error(`[DEBUG ${elapsedSec}s] End marker: ${endMarker}`);
384
+ console.error(`[DEBUG ${elapsedSec}s] First index: ${firstIdx}, Last index: ${lastIdx}`);
385
+ console.error(
386
+ `[DEBUG ${elapsedSec}s] Two markers found: ${firstIdx !== -1 && firstIdx !== lastIdx}`
387
+ );
388
+
389
+ // Show content around markers if found
390
+ if (firstIdx !== -1) {
391
+ const context = output.slice(
392
+ Math.max(0, firstIdx - 50),
393
+ firstIdx + endMarker.length + 50
394
+ );
395
+ console.error(`[DEBUG ${elapsedSec}s] First marker context:\n---\n${context}\n---`);
396
+ }
397
+ if (lastIdx !== -1 && lastIdx !== firstIdx) {
398
+ const context = output.slice(Math.max(0, lastIdx - 50), lastIdx + endMarker.length + 50);
399
+ console.error(`[DEBUG ${elapsedSec}s] Last marker context:\n---\n${context}\n---`);
400
+ }
401
+
402
+ // Show last 300 chars of output
403
+ console.error(`[DEBUG ${elapsedSec}s] Output tail:\n${output.slice(-300)}`);
404
+ }
405
+
406
+ // Track output changes for debounce detection
407
+ if (output !== lastOutput) {
408
+ lastOutput = output;
409
+ lastOutputChangeAt = Date.now();
410
+ }
411
+
412
+ const elapsedMs = Date.now() - startedAt;
413
+ const idleMs = Date.now() - lastOutputChangeAt;
414
+
415
+ // Find end marker
416
+ const hasEndMarker = output.includes(endMarker);
417
+
418
+ // Completion conditions:
419
+ // 1. Must wait at least MIN_WAIT_MS
420
+ // 2. Must have end marker in output
421
+ // 3. Output must be stable for IDLE_THRESHOLD_MS (debounce)
422
+ if (elapsedMs < MIN_WAIT_MS || !hasEndMarker || idleMs < IDLE_THRESHOLD_MS) {
423
+ if (flags.debug && hasEndMarker) {
424
+ console.error(
425
+ `[DEBUG] Marker found, waiting for debounce (elapsed: ${elapsedMs}ms, idle: ${idleMs}ms)`
426
+ );
427
+ }
361
428
  continue;
362
429
  }
363
- const endMarkerIndex = lastEndMarkerIndex;
364
-
365
- // Find the end of our instruction by looking for `}]` pattern (the instruction ends with `{end}]`)
366
- // This is more reliable than looking for newline after start marker because
367
- // the message may be word-wrapped across multiple visual lines
368
- let responseStart = 0;
369
- const instructionEndPattern = '}]';
370
- const instructionEndIndex = output.lastIndexOf(instructionEndPattern, endMarkerIndex);
371
- if (instructionEndIndex !== -1) {
372
- // Find the first newline after the instruction's closing `}]`
373
- responseStart = output.indexOf('\n', instructionEndIndex + 2);
374
- if (responseStart !== -1) responseStart += 1;
375
- else responseStart = instructionEndIndex + 2;
376
- } else {
377
- // Fallback: if no `}]` found, try to find newline after start marker
378
- const startMarkerIndex = output.lastIndexOf(startMarker);
379
- if (startMarkerIndex !== -1) {
380
- responseStart = output.indexOf('\n', startMarkerIndex);
381
- if (responseStart !== -1) responseStart += 1;
382
- else responseStart = startMarkerIndex + startMarker.length;
430
+
431
+ if (flags.debug) {
432
+ console.error(`[DEBUG] Agent completed (elapsed: ${elapsedMs}ms, idle: ${idleMs}ms)`);
433
+ }
434
+
435
+ // Extract response: get N lines before the end marker
436
+ const responseLines = flags.lines ?? 100;
437
+ const lines = output.split('\n');
438
+
439
+ // Find the line with the end marker (last occurrence = agent's marker)
440
+ let endMarkerLineIndex = -1;
441
+ for (let i = lines.length - 1; i >= 0; i--) {
442
+ if (lines[i].includes(endMarker)) {
443
+ endMarkerLineIndex = i;
444
+ break;
383
445
  }
384
446
  }
385
447
 
386
- const response = output.slice(responseStart, endMarkerIndex).trim();
448
+ if (endMarkerLineIndex === -1) continue;
449
+
450
+ // Find where response starts (after instruction's end marker, if visible)
451
+ const firstMarkerLineIndex = lines.findIndex((line) => line.includes(endMarker));
452
+ let startLine = firstMarkerLineIndex + 1;
453
+ // Limit to N lines before end marker
454
+ startLine = Math.max(startLine, endMarkerLineIndex - responseLines);
455
+
456
+ let response = lines.slice(startLine, endMarkerLineIndex).join('\n').trim();
457
+ // Clean Gemini CLI UI artifacts
458
+ if (target === 'gemini') {
459
+ response = cleanGeminiResponse(response);
460
+ }
387
461
 
388
462
  if (!flags.json && isTTY) {
389
463
  process.stdout.write('\r' + ' '.repeat(80) + '\r');
@@ -394,7 +468,7 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
394
468
 
395
469
  clearActiveRequest(ctx.paths, target, requestId);
396
470
 
397
- const result: WaitResult = { requestId, nonce, startMarker, endMarker, response };
471
+ const result: WaitResult = { requestId, nonce, endMarker, response };
398
472
  if (flags.json) {
399
473
  ui.json({ target, pane, status: 'completed', ...result });
400
474
  } else {
@@ -438,16 +512,15 @@ async function cmdTalkAllWait(
438
512
  );
439
513
  }
440
514
 
441
- // Phase 1: Send messages to all agents with start/end markers
515
+ // Phase 1: Send messages to all agents with end markers
442
516
  for (const [name, data] of targetAgents) {
443
517
  const requestId = makeRequestId();
444
518
  const nonce = makeNonce(); // Unique nonce per agent (#19)
445
- const startMarker = `{tmux-team-start:${nonce}}`;
446
- const endMarker = `{tmux-team-end:${nonce}}`;
519
+ const endMarker = makeEndMarker(nonce);
447
520
 
448
- // Build and send message with start/end markers
521
+ // Build and send message with end marker instruction
449
522
  const messageWithPreamble = buildMessage(message, name, ctx);
450
- const fullMessage = `${startMarker}\n${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${endMarker}]`;
523
+ const fullMessage = `${messageWithPreamble}\n\nWhen you finish responding, print this exact line:\n${endMarker}`;
451
524
  const msg = name === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
452
525
 
453
526
  try {
@@ -463,7 +536,6 @@ async function cmdTalkAllWait(
463
536
  pane: data.pane,
464
537
  requestId,
465
538
  nonce,
466
- startMarker,
467
539
  endMarker,
468
540
  status: 'pending',
469
541
  });
@@ -476,7 +548,6 @@ async function cmdTalkAllWait(
476
548
  pane: data.pane,
477
549
  requestId,
478
550
  nonce,
479
- startMarker,
480
551
  endMarker,
481
552
  status: 'error',
482
553
  error: `Failed to send to pane ${data.pane}`,
@@ -530,11 +601,17 @@ async function cmdTalkAllWait(
530
601
  state.elapsedMs = Math.floor(elapsedSeconds * 1000);
531
602
 
532
603
  // Capture partial response on timeout
604
+ const responseLines = flags.lines ?? 100;
533
605
  try {
534
606
  const output = tmux.capture(state.pane, captureLines);
535
- const extracted = extractPartialResponse(output, state.startMarker, state.endMarker);
536
- if (extracted) state.partialResponse = extracted;
537
- } catch {
607
+ console.log('debug>>', output);
608
+ const extracted = extractPartialResponse(output, state.endMarker, responseLines);
609
+ if (extracted) {
610
+ state.partialResponse =
611
+ state.agent === 'gemini' ? cleanGeminiResponse(extracted) : extracted;
612
+ }
613
+ } catch (err) {
614
+ console.error(err);
538
615
  // Ignore capture errors on timeout
539
616
  }
540
617
 
@@ -582,39 +659,48 @@ async function cmdTalkAllWait(
582
659
  continue;
583
660
  }
584
661
 
585
- // Find end marker - it appears once in our instruction and again when agent prints it
586
- // We need TWO occurrences: one in instruction + one from agent = complete
587
- // Only ONE occurrence means it's just in instruction = still waiting
662
+ // Find end marker
588
663
  const firstEndMarkerIndex = output.indexOf(state.endMarker);
589
664
  const lastEndMarkerIndex = output.lastIndexOf(state.endMarker);
590
- if (firstEndMarkerIndex === -1 || firstEndMarkerIndex === lastEndMarkerIndex) {
591
- // No marker or only one (in instruction) - still waiting
592
- continue;
593
- }
594
- const endMarkerIndex = lastEndMarkerIndex;
595
-
596
- // Find the end of our instruction by looking for `}]` pattern (the instruction ends with `{end}]`)
597
- // This is more reliable than looking for newline after start marker because
598
- // the message may be word-wrapped across multiple visual lines
599
- let responseStart = 0;
600
- const instructionEndPattern = '}]';
601
- const instructionEndIndex = output.lastIndexOf(instructionEndPattern, endMarkerIndex);
602
- if (instructionEndIndex !== -1) {
603
- // Find the first newline after the instruction's closing `}]`
604
- responseStart = output.indexOf('\n', instructionEndIndex + 2);
605
- if (responseStart !== -1) responseStart += 1;
606
- else responseStart = instructionEndIndex + 2;
607
- } else {
608
- // Fallback: if no `}]` found, try to find newline after start marker
609
- const startMarkerIndex = output.lastIndexOf(state.startMarker);
610
- if (startMarkerIndex !== -1) {
611
- responseStart = output.indexOf('\n', startMarkerIndex);
612
- if (responseStart !== -1) responseStart += 1;
613
- else responseStart = startMarkerIndex + state.startMarker.length;
665
+
666
+ if (firstEndMarkerIndex === -1) continue;
667
+
668
+ // Check if marker is from agent (not just in our instruction)
669
+ const afterMarker = output.slice(lastEndMarkerIndex + state.endMarker.length);
670
+ const followedByUI = afterMarker.includes('╭') || afterMarker.includes('context left');
671
+ const twoMarkers = firstEndMarkerIndex !== lastEndMarkerIndex;
672
+
673
+ if (!twoMarkers && !followedByUI) continue;
674
+
675
+ // Extract response: get N lines before the agent's end marker
676
+ const responseLines = flags.lines ?? 100;
677
+ const lines = output.split('\n');
678
+
679
+ // Find the line with the agent's end marker (last occurrence)
680
+ let endMarkerLineIndex = -1;
681
+ for (let i = lines.length - 1; i >= 0; i--) {
682
+ if (lines[i].includes(state.endMarker)) {
683
+ endMarkerLineIndex = i;
684
+ break;
614
685
  }
615
686
  }
616
687
 
617
- state.response = output.slice(responseStart, endMarkerIndex).trim();
688
+ if (endMarkerLineIndex === -1) continue;
689
+
690
+ // Determine where response starts
691
+ let startLine = 0;
692
+ if (twoMarkers) {
693
+ const firstMarkerLineIndex = lines.findIndex((line) => line.includes(state.endMarker));
694
+ startLine = firstMarkerLineIndex + 1;
695
+ }
696
+ startLine = Math.max(startLine, endMarkerLineIndex - responseLines);
697
+
698
+ let response = lines.slice(startLine, endMarkerLineIndex).join('\n').trim();
699
+ // Clean Gemini CLI UI artifacts
700
+ if (state.agent === 'gemini') {
701
+ response = cleanGeminiResponse(response);
702
+ }
703
+ state.response = response;
618
704
  state.status = 'completed';
619
705
  state.elapsedMs = Date.now() - startedAt;
620
706
  clearActiveRequest(paths, state.agent, state.requestId);
@@ -676,7 +762,6 @@ function outputBroadcastResults(
676
762
  pane: s.pane,
677
763
  requestId: s.requestId,
678
764
  nonce: s.nonce,
679
- startMarker: s.startMarker,
680
765
  endMarker: s.endMarker,
681
766
  status: s.status,
682
767
  response: s.response,
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { Paths, ResolvedConfig, UI, Tmux } from './types.js';
3
+
4
+ describe('createContext', () => {
5
+ beforeEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ it('wires argv, flags, paths, config, ui, tmux', async () => {
10
+ vi.resetModules();
11
+
12
+ const paths: Paths = {
13
+ globalDir: '/g',
14
+ globalConfig: '/g/config.json',
15
+ localConfig: '/p/tmux-team.json',
16
+ stateFile: '/g/state.json',
17
+ };
18
+ const config: ResolvedConfig = {
19
+ mode: 'polling',
20
+ preambleMode: 'always',
21
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, preambleEvery: 3 },
22
+ agents: {},
23
+ paneRegistry: {},
24
+ };
25
+ const ui: UI = {
26
+ info: vi.fn(),
27
+ success: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ table: vi.fn(),
31
+ json: vi.fn(),
32
+ };
33
+ const tmux: Tmux = {
34
+ send: vi.fn(),
35
+ capture: vi.fn(),
36
+ listPanes: vi.fn(() => []),
37
+ getCurrentPaneId: vi.fn(() => null),
38
+ };
39
+
40
+ vi.doMock('./config.js', () => ({
41
+ resolvePaths: () => paths,
42
+ loadConfig: () => config,
43
+ }));
44
+ vi.doMock('./ui.js', () => ({ createUI: () => ui }));
45
+ vi.doMock('./tmux.js', () => ({ createTmux: () => tmux }));
46
+
47
+ const { createContext } = await import('./context.js');
48
+ const ctx = createContext({ argv: ['a'], flags: { json: false, verbose: false }, cwd: '/p' });
49
+
50
+ expect(ctx.argv).toEqual(['a']);
51
+ expect(ctx.paths).toEqual(paths);
52
+ expect(ctx.config).toEqual(config);
53
+ expect(ctx.ui).toBe(ui);
54
+ expect(ctx.tmux).toBe(tmux);
55
+ });
56
+
57
+ it('ctx.exit calls process.exit', async () => {
58
+ vi.resetModules();
59
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
60
+ throw new Error(`exit(${code})`);
61
+ }) as any);
62
+
63
+ const { createContext } = await import('./context.js');
64
+ const ctx = createContext({ argv: [], flags: { json: false, verbose: false } });
65
+ expect(() => ctx.exit(2)).toThrow('exit(2)');
66
+ expect(exitSpy).toHaveBeenCalledWith(2);
67
+ });
68
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { PaneEntry } from './types.js';
3
+ import { resolveActor } from './identity.js';
4
+ import { execSync } from 'child_process';
5
+
6
+ vi.mock('child_process', () => ({
7
+ execSync: vi.fn(),
8
+ }));
9
+
10
+ const mockedExec = vi.mocked(execSync);
11
+
12
+ describe('resolveActor', () => {
13
+ const paneRegistry: Record<string, PaneEntry> = {
14
+ claude: { pane: '10.0' },
15
+ codex: { pane: '10.1' },
16
+ };
17
+
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ delete process.env.TMT_AGENT_NAME;
21
+ delete process.env.TMUX_TEAM_ACTOR;
22
+ delete process.env.TMUX;
23
+ delete process.env.TMUX_PANE;
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ it('returns default actor when not in tmux and no env var', () => {
31
+ const res = resolveActor(paneRegistry);
32
+ expect(res).toEqual({ actor: 'human', source: 'default' });
33
+ });
34
+
35
+ it('uses env actor when not in tmux', () => {
36
+ process.env.TMT_AGENT_NAME = 'claude';
37
+ const res = resolveActor(paneRegistry);
38
+ expect(res).toEqual({ actor: 'claude', source: 'env' });
39
+ });
40
+
41
+ it('uses pane identity when in tmux and pane matches registry', () => {
42
+ process.env.TMUX = '1';
43
+ process.env.TMUX_PANE = '%99';
44
+ mockedExec.mockReturnValue('10.1\n');
45
+ const res = resolveActor(paneRegistry);
46
+ expect(res.actor).toBe('codex');
47
+ expect(res.source).toBe('pane');
48
+ });
49
+
50
+ it('warns on identity mismatch (env vs pane)', () => {
51
+ process.env.TMUX = '1';
52
+ process.env.TMUX_PANE = '%99';
53
+ process.env.TMT_AGENT_NAME = 'claude';
54
+ mockedExec.mockReturnValue('10.1\n');
55
+ const res = resolveActor(paneRegistry);
56
+ expect(res.actor).toBe('codex');
57
+ expect(res.warning).toContain('Identity mismatch');
58
+ });
59
+
60
+ it('uses env actor with warning when pane is unregistered', () => {
61
+ process.env.TMUX = '1';
62
+ process.env.TMUX_PANE = '%99';
63
+ process.env.TMT_AGENT_NAME = 'someone';
64
+ mockedExec.mockReturnValue('99.9\n');
65
+ const res = resolveActor(paneRegistry);
66
+ expect(res.actor).toBe('someone');
67
+ expect(res.source).toBe('env');
68
+ expect(res.warning).toContain('Unregistered pane');
69
+ });
70
+ });
package/src/state.test.ts CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  cleanupState,
14
14
  setActiveRequest,
15
15
  clearActiveRequest,
16
+ getPreambleCounter,
17
+ incrementPreambleCounter,
16
18
  type AgentRequestState,
17
19
  } from './state.js';
18
20
 
@@ -318,4 +320,16 @@ describe('State Management', () => {
318
320
  expect(state.requests.codex).toMatchObject({ id: '2', nonce: 'b' });
319
321
  });
320
322
  });
323
+
324
+ describe('preamble counters', () => {
325
+ it('returns 0 when counter is missing', () => {
326
+ expect(getPreambleCounter(paths, 'claude')).toBe(0);
327
+ });
328
+
329
+ it('increments and persists counter', () => {
330
+ expect(incrementPreambleCounter(paths, 'claude')).toBe(1);
331
+ expect(incrementPreambleCounter(paths, 'claude')).toBe(2);
332
+ expect(getPreambleCounter(paths, 'claude')).toBe(2);
333
+ });
334
+ });
321
335
  });
package/src/tmux.test.ts CHANGED
@@ -161,6 +161,56 @@ describe('createTmux', () => {
161
161
  });
162
162
  });
163
163
 
164
+ describe('listPanes', () => {
165
+ it('returns parsed panes and suggestedName', () => {
166
+ mockedExecSync.mockReturnValue('%1\tcodex\n%2\tzsh\n');
167
+ const tmux = createTmux();
168
+ const panes = tmux.listPanes();
169
+ expect(panes).toEqual([
170
+ { id: '%1', command: 'codex', suggestedName: 'codex' },
171
+ { id: '%2', command: 'zsh', suggestedName: null },
172
+ ]);
173
+ });
174
+
175
+ it('returns empty list on error', () => {
176
+ mockedExecSync.mockImplementationOnce(() => {
177
+ throw new Error('no tmux');
178
+ });
179
+ const tmux = createTmux();
180
+ expect(tmux.listPanes()).toEqual([]);
181
+ });
182
+ });
183
+
184
+ describe('getCurrentPaneId', () => {
185
+ it('returns TMUX_PANE when set', () => {
186
+ const old = process.env.TMUX_PANE;
187
+ process.env.TMUX_PANE = '%9';
188
+ const tmux = createTmux();
189
+ expect(tmux.getCurrentPaneId()).toBe('%9');
190
+ process.env.TMUX_PANE = old;
191
+ });
192
+
193
+ it('falls back to tmux display-message', () => {
194
+ const old = process.env.TMUX_PANE;
195
+ delete process.env.TMUX_PANE;
196
+ mockedExecSync.mockReturnValue('%7\n');
197
+ const tmux = createTmux();
198
+ expect(tmux.getCurrentPaneId()).toBe('%7');
199
+ process.env.TMUX_PANE = old;
200
+ });
201
+
202
+ it('returns null on failure', () => {
203
+ const old = process.env.TMUX_PANE;
204
+ delete process.env.TMUX_PANE;
205
+ mockedExecSync.mockImplementationOnce(() => {
206
+ throw new Error('fail');
207
+ });
208
+ const tmux = createTmux();
209
+ expect(tmux.getCurrentPaneId()).toBeNull();
210
+ process.env.TMUX_PANE = old;
211
+ });
212
+ });
213
+
164
214
  describe('pane ID handling', () => {
165
215
  it('accepts window.pane format', () => {
166
216
  mockedExecSync.mockReturnValue('');