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.
- package/README.md +55 -259
- package/package.json +16 -14
- package/skills/README.md +9 -10
- package/src/cli.test.ts +163 -0
- package/src/cli.ts +20 -17
- package/src/commands/basic-commands.test.ts +252 -0
- package/src/commands/config-command.test.ts +116 -0
- package/src/commands/help.ts +4 -1
- package/src/commands/install.test.ts +205 -0
- package/src/commands/install.ts +207 -0
- package/src/commands/setup.test.ts +175 -0
- package/src/commands/setup.ts +163 -0
- package/src/commands/talk.test.ts +144 -111
- package/src/commands/talk.ts +185 -100
- package/src/context.test.ts +68 -0
- package/src/identity.test.ts +70 -0
- package/src/state.test.ts +14 -0
- package/src/tmux.test.ts +50 -0
- package/src/tmux.ts +66 -2
- package/src/types.ts +10 -1
- package/src/ui.test.ts +63 -0
- package/src/version.test.ts +31 -0
- package/src/commands/install-skill.ts +0 -148
package/src/commands/talk.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
64
|
+
endMarker: string,
|
|
65
|
+
maxLines: number
|
|
43
66
|
): string | null {
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
252
|
-
const endMarker = `{tmux-team-end:${nonce}}`;
|
|
260
|
+
const endMarker = makeEndMarker(nonce);
|
|
253
261
|
|
|
254
|
-
// Build message with preamble
|
|
262
|
+
// Build message with preamble and end marker instruction
|
|
255
263
|
const messageWithPreamble = buildMessage(message, target, ctx);
|
|
256
|
-
const fullMessage = `${
|
|
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,
|
|
296
|
-
if (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
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
446
|
-
const endMarker = `{tmux-team-end:${nonce}}`;
|
|
519
|
+
const endMarker = makeEndMarker(nonce);
|
|
447
520
|
|
|
448
|
-
// Build and send message with
|
|
521
|
+
// Build and send message with end marker instruction
|
|
449
522
|
const messageWithPreamble = buildMessage(message, name, ctx);
|
|
450
|
-
const fullMessage = `${
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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('');
|