tmux-team 3.0.1 → 3.1.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,11 @@ 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
+ // Idle detection for Gemini (doesn't print end markers)
282
+ const GEMINI_IDLE_THRESHOLD_MS = 5000; // 5 seconds of no output change = complete
283
+ let lastOutputHash = '';
284
+ let lastOutputChangeAt = Date.now();
285
+
273
286
  const onSigint = (): void => {
274
287
  clearActiveRequest(ctx.paths, target, requestId);
275
288
  if (!flags.json) process.stdout.write('\n');
@@ -281,6 +294,13 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
281
294
 
282
295
  try {
283
296
  const msg = target === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
297
+
298
+ if (flags.debug) {
299
+ console.error(`[DEBUG] Starting wait mode for ${target}`);
300
+ console.error(`[DEBUG] End marker: ${endMarker}`);
301
+ console.error(`[DEBUG] Message sent:\n${msg}`);
302
+ }
303
+
284
304
  tmux.send(pane, msg);
285
305
 
286
306
  while (true) {
@@ -289,11 +309,14 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
289
309
  clearActiveRequest(ctx.paths, target, requestId);
290
310
 
291
311
  // Capture partial response on timeout
312
+ const responseLines = flags.lines ?? 100;
292
313
  let partialResponse: string | null = null;
293
314
  try {
294
315
  const output = tmux.capture(pane, captureLines);
295
- const extracted = extractPartialResponse(output, startMarker, endMarker);
296
- if (extracted) partialResponse = extracted;
316
+ const extracted = extractPartialResponse(output, endMarker, responseLines);
317
+ if (extracted) {
318
+ partialResponse = target === 'gemini' ? cleanGeminiResponse(extracted) : extracted;
319
+ }
297
320
  } catch {
298
321
  // Ignore capture errors on timeout
299
322
  }
@@ -310,7 +333,6 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
310
333
  error: `Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s`,
311
334
  requestId,
312
335
  nonce,
313
- startMarker,
314
336
  endMarker,
315
337
  partialResponse,
316
338
  });
@@ -351,39 +373,94 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
351
373
  exit(ExitCodes.ERROR);
352
374
  }
353
375
 
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
376
+ // DEBUG: Log captured output
377
+ if (flags.debug) {
378
+ const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
379
+ const firstIdx = output.indexOf(endMarker);
380
+ const lastIdx = output.lastIndexOf(endMarker);
381
+ console.error(`\n[DEBUG ${elapsedSec}s] Output: ${output.length} chars`);
382
+ console.error(`[DEBUG ${elapsedSec}s] End marker: ${endMarker}`);
383
+ console.error(`[DEBUG ${elapsedSec}s] First index: ${firstIdx}, Last index: ${lastIdx}`);
384
+ console.error(
385
+ `[DEBUG ${elapsedSec}s] Two markers found: ${firstIdx !== -1 && firstIdx !== lastIdx}`
386
+ );
387
+
388
+ // Show content around markers if found
389
+ if (firstIdx !== -1) {
390
+ const context = output.slice(
391
+ Math.max(0, firstIdx - 50),
392
+ firstIdx + endMarker.length + 50
393
+ );
394
+ console.error(`[DEBUG ${elapsedSec}s] First marker context:\n---\n${context}\n---`);
395
+ }
396
+ if (lastIdx !== -1 && lastIdx !== firstIdx) {
397
+ const context = output.slice(Math.max(0, lastIdx - 50), lastIdx + endMarker.length + 50);
398
+ console.error(`[DEBUG ${elapsedSec}s] Last marker context:\n---\n${context}\n---`);
399
+ }
400
+
401
+ // Show last 300 chars of output
402
+ console.error(`[DEBUG ${elapsedSec}s] Output tail:\n${output.slice(-300)}`);
403
+ }
404
+
405
+ // Find end marker - agent prints it when done
406
+ // For long responses, our instruction may scroll off, so we check:
407
+ // 1. Two occurrences (instruction + agent), OR
408
+ // 2. One occurrence that's followed by only UI elements (agent printed it)
357
409
  const firstEndMarkerIndex = output.indexOf(endMarker);
358
410
  const lastEndMarkerIndex = output.lastIndexOf(endMarker);
359
- if (firstEndMarkerIndex === -1 || firstEndMarkerIndex === lastEndMarkerIndex) {
360
- // No marker or only one (in instruction) - still waiting
411
+
412
+ if (firstEndMarkerIndex === -1) {
413
+ // No marker at all - still waiting
361
414
  continue;
362
415
  }
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;
416
+
417
+ // Check if marker is from agent (not just in our instruction)
418
+ // Either: two markers (instruction scrolled, agent printed), or
419
+ // one marker followed by CLI UI elements (instruction scrolled off)
420
+ const afterMarker = output.slice(lastEndMarkerIndex + endMarker.length);
421
+ const followedByUI = afterMarker.includes('╭') || afterMarker.includes('context left');
422
+ const twoMarkers = firstEndMarkerIndex !== lastEndMarkerIndex;
423
+
424
+ if (!twoMarkers && !followedByUI) {
425
+ // Marker is still in our instruction, agent hasn't responded yet
426
+ continue;
427
+ }
428
+
429
+ if (flags.debug)
430
+ console.error(
431
+ `[DEBUG] Agent completed (twoMarkers: ${twoMarkers}, followedByUI: ${followedByUI})`
432
+ );
433
+
434
+ // Extract response: get N lines before the agent's end marker
435
+ const responseLines = flags.lines ?? 100;
436
+ const lines = output.split('\n');
437
+
438
+ // Find the line with the agent's end marker (last occurrence)
439
+ let endMarkerLineIndex = -1;
440
+ for (let i = lines.length - 1; i >= 0; i--) {
441
+ if (lines[i].includes(endMarker)) {
442
+ endMarkerLineIndex = i;
443
+ break;
383
444
  }
384
445
  }
385
446
 
386
- const response = output.slice(responseStart, endMarkerIndex).trim();
447
+ if (endMarkerLineIndex === -1) continue;
448
+
449
+ // Determine where response starts
450
+ let startLine = 0;
451
+ if (firstEndMarkerIndex !== lastEndMarkerIndex) {
452
+ // Two markers - find line after first marker (instruction)
453
+ const firstMarkerLineIndex = lines.findIndex((line) => line.includes(endMarker));
454
+ startLine = firstMarkerLineIndex + 1;
455
+ }
456
+ // Limit to N lines before end marker
457
+ startLine = Math.max(startLine, endMarkerLineIndex - responseLines);
458
+
459
+ let response = lines.slice(startLine, endMarkerLineIndex).join('\n').trim();
460
+ // Clean Gemini CLI UI artifacts
461
+ if (target === 'gemini') {
462
+ response = cleanGeminiResponse(response);
463
+ }
387
464
 
388
465
  if (!flags.json && isTTY) {
389
466
  process.stdout.write('\r' + ' '.repeat(80) + '\r');
@@ -394,7 +471,7 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
394
471
 
395
472
  clearActiveRequest(ctx.paths, target, requestId);
396
473
 
397
- const result: WaitResult = { requestId, nonce, startMarker, endMarker, response };
474
+ const result: WaitResult = { requestId, nonce, endMarker, response };
398
475
  if (flags.json) {
399
476
  ui.json({ target, pane, status: 'completed', ...result });
400
477
  } else {
@@ -438,16 +515,15 @@ async function cmdTalkAllWait(
438
515
  );
439
516
  }
440
517
 
441
- // Phase 1: Send messages to all agents with start/end markers
518
+ // Phase 1: Send messages to all agents with end markers
442
519
  for (const [name, data] of targetAgents) {
443
520
  const requestId = makeRequestId();
444
521
  const nonce = makeNonce(); // Unique nonce per agent (#19)
445
- const startMarker = `{tmux-team-start:${nonce}}`;
446
- const endMarker = `{tmux-team-end:${nonce}}`;
522
+ const endMarker = makeEndMarker(nonce);
447
523
 
448
- // Build and send message with start/end markers
524
+ // Build and send message with end marker instruction
449
525
  const messageWithPreamble = buildMessage(message, name, ctx);
450
- const fullMessage = `${startMarker}\n${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${endMarker}]`;
526
+ const fullMessage = `${messageWithPreamble}\n\nWhen you finish responding, print this exact line:\n${endMarker}`;
451
527
  const msg = name === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
452
528
 
453
529
  try {
@@ -463,7 +539,6 @@ async function cmdTalkAllWait(
463
539
  pane: data.pane,
464
540
  requestId,
465
541
  nonce,
466
- startMarker,
467
542
  endMarker,
468
543
  status: 'pending',
469
544
  });
@@ -476,7 +551,6 @@ async function cmdTalkAllWait(
476
551
  pane: data.pane,
477
552
  requestId,
478
553
  nonce,
479
- startMarker,
480
554
  endMarker,
481
555
  status: 'error',
482
556
  error: `Failed to send to pane ${data.pane}`,
@@ -530,11 +604,17 @@ async function cmdTalkAllWait(
530
604
  state.elapsedMs = Math.floor(elapsedSeconds * 1000);
531
605
 
532
606
  // Capture partial response on timeout
607
+ const responseLines = flags.lines ?? 100;
533
608
  try {
534
609
  const output = tmux.capture(state.pane, captureLines);
535
- const extracted = extractPartialResponse(output, state.startMarker, state.endMarker);
536
- if (extracted) state.partialResponse = extracted;
537
- } catch {
610
+ console.log('debug>>', output);
611
+ const extracted = extractPartialResponse(output, state.endMarker, responseLines);
612
+ if (extracted) {
613
+ state.partialResponse =
614
+ state.agent === 'gemini' ? cleanGeminiResponse(extracted) : extracted;
615
+ }
616
+ } catch (err) {
617
+ console.error(err);
538
618
  // Ignore capture errors on timeout
539
619
  }
540
620
 
@@ -582,39 +662,48 @@ async function cmdTalkAllWait(
582
662
  continue;
583
663
  }
584
664
 
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
665
+ // Find end marker
588
666
  const firstEndMarkerIndex = output.indexOf(state.endMarker);
589
667
  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;
668
+
669
+ if (firstEndMarkerIndex === -1) continue;
670
+
671
+ // Check if marker is from agent (not just in our instruction)
672
+ const afterMarker = output.slice(lastEndMarkerIndex + state.endMarker.length);
673
+ const followedByUI = afterMarker.includes('╭') || afterMarker.includes('context left');
674
+ const twoMarkers = firstEndMarkerIndex !== lastEndMarkerIndex;
675
+
676
+ if (!twoMarkers && !followedByUI) continue;
677
+
678
+ // Extract response: get N lines before the agent's end marker
679
+ const responseLines = flags.lines ?? 100;
680
+ const lines = output.split('\n');
681
+
682
+ // Find the line with the agent's end marker (last occurrence)
683
+ let endMarkerLineIndex = -1;
684
+ for (let i = lines.length - 1; i >= 0; i--) {
685
+ if (lines[i].includes(state.endMarker)) {
686
+ endMarkerLineIndex = i;
687
+ break;
614
688
  }
615
689
  }
616
690
 
617
- state.response = output.slice(responseStart, endMarkerIndex).trim();
691
+ if (endMarkerLineIndex === -1) continue;
692
+
693
+ // Determine where response starts
694
+ let startLine = 0;
695
+ if (twoMarkers) {
696
+ const firstMarkerLineIndex = lines.findIndex((line) => line.includes(state.endMarker));
697
+ startLine = firstMarkerLineIndex + 1;
698
+ }
699
+ startLine = Math.max(startLine, endMarkerLineIndex - responseLines);
700
+
701
+ let response = lines.slice(startLine, endMarkerLineIndex).join('\n').trim();
702
+ // Clean Gemini CLI UI artifacts
703
+ if (state.agent === 'gemini') {
704
+ response = cleanGeminiResponse(response);
705
+ }
706
+ state.response = response;
618
707
  state.status = 'completed';
619
708
  state.elapsedMs = Date.now() - startedAt;
620
709
  clearActiveRequest(paths, state.agent, state.requestId);
@@ -676,7 +765,6 @@ function outputBroadcastResults(
676
765
  pane: s.pane,
677
766
  requestId: s.requestId,
678
767
  nonce: s.nonce,
679
- startMarker: s.startMarker,
680
768
  endMarker: s.endMarker,
681
769
  status: s.status,
682
770
  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('');