tmux-team 3.0.0 → 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.
- package/README.md +56 -248
- package/package.json +5 -3
- package/skills/README.md +9 -10
- package/src/cli.test.ts +163 -0
- package/src/cli.ts +29 -18
- package/src/commands/basic-commands.test.ts +252 -0
- package/src/commands/config-command.test.ts +116 -0
- package/src/commands/help.ts +19 -3
- package/src/commands/install.test.ts +205 -0
- package/src/commands/install.ts +207 -0
- package/src/commands/learn.ts +80 -0
- package/src/commands/setup.test.ts +175 -0
- package/src/commands/setup.ts +163 -0
- package/src/commands/talk.test.ts +169 -101
- package/src/commands/talk.ts +186 -98
- 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/version.ts +1 -1
- 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,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,
|
|
296
|
-
if (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
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
360
|
-
|
|
411
|
+
|
|
412
|
+
if (firstEndMarkerIndex === -1) {
|
|
413
|
+
// No marker at all - still waiting
|
|
361
414
|
continue;
|
|
362
415
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
if (
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
446
|
-
const endMarker = `{tmux-team-end:${nonce}}`;
|
|
522
|
+
const endMarker = makeEndMarker(nonce);
|
|
447
523
|
|
|
448
|
-
// Build and send message with
|
|
524
|
+
// Build and send message with end marker instruction
|
|
449
525
|
const messageWithPreamble = buildMessage(message, name, ctx);
|
|
450
|
-
const fullMessage = `${
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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('');
|