tmux-team 3.2.0 → 3.2.2
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/package.json +15 -15
- package/src/commands/talk.test.ts +123 -116
- package/src/commands/talk.ts +159 -54
- package/src/config.test.ts +45 -0
- package/src/config.ts +26 -1
package/package.json
CHANGED
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tmux-team",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"description": "CLI tool for AI agent collaboration in tmux - manage cross-pane communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tmux-team": "./bin/tmux-team",
|
|
8
8
|
"tmt": "./bin/tmux-team"
|
|
9
9
|
},
|
|
10
|
-
"scripts": {
|
|
11
|
-
"dev": "tsx src/cli.ts",
|
|
12
|
-
"tmt": "./bin/tmux-team",
|
|
13
|
-
"test": "npm run test:run",
|
|
14
|
-
"test:watch": "vitest",
|
|
15
|
-
"test:run": "vitest run --coverage && node scripts/check-coverage.mjs --threshold 95",
|
|
16
|
-
"lint": "oxlint src/",
|
|
17
|
-
"lint:fix": "oxlint src/ --fix",
|
|
18
|
-
"format": "prettier --write src/",
|
|
19
|
-
"format:check": "prettier --check src/",
|
|
20
|
-
"type:check": "tsc --noEmit",
|
|
21
|
-
"check": "npm run type:check && npm run lint && npm run format:check"
|
|
22
|
-
},
|
|
23
10
|
"keywords": [
|
|
24
11
|
"tmux",
|
|
25
12
|
"cli",
|
|
@@ -56,5 +43,18 @@
|
|
|
56
43
|
"prettier": "^3.7.4",
|
|
57
44
|
"typescript": "^5.3.0",
|
|
58
45
|
"vitest": "^1.2.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"dev": "tsx src/cli.ts",
|
|
49
|
+
"tmt": "./bin/tmux-team",
|
|
50
|
+
"test": "pnpm test:run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
"test:run": "vitest run --coverage && node scripts/check-coverage.mjs --threshold 95",
|
|
53
|
+
"lint": "oxlint src/",
|
|
54
|
+
"lint:fix": "oxlint src/ --fix",
|
|
55
|
+
"format": "prettier --write src/",
|
|
56
|
+
"format:check": "prettier --check src/",
|
|
57
|
+
"type:check": "tsc --noEmit",
|
|
58
|
+
"check": "pnpm type:check && pnpm lint && pnpm format:check"
|
|
59
59
|
}
|
|
60
|
-
}
|
|
60
|
+
}
|
|
@@ -14,9 +14,12 @@ import { cmdTalk } from './talk.js';
|
|
|
14
14
|
// Constants
|
|
15
15
|
// ─────────────────────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
-
// Regex to match
|
|
17
|
+
// Regex to match the END marker (as printed by agent) - used to find markers in output
|
|
18
18
|
const END_MARKER_REGEX = /---RESPONSE-END-([a-f0-9]+)---/;
|
|
19
19
|
|
|
20
|
+
// Regex to extract nonce from instruction (instruction says "RESPONSE-END-xxxx" without dashes)
|
|
21
|
+
const INSTRUCTION_NONCE_REGEX = /RESPONSE-END-([a-f0-9]+)/;
|
|
22
|
+
|
|
20
23
|
// ─────────────────────────────────────────────────────────────
|
|
21
24
|
// Test utilities
|
|
22
25
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -457,11 +460,13 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
457
460
|
});
|
|
458
461
|
|
|
459
462
|
// Helper: generate mock capture output with proper marker structure
|
|
460
|
-
//
|
|
461
|
-
//
|
|
463
|
+
// New protocol: instruction describes the marker verbally (doesn't contain literal marker)
|
|
464
|
+
// Include the instruction line so extraction can anchor to it for clean output
|
|
462
465
|
function mockCompleteResponse(nonce: string, response: string): string {
|
|
466
|
+
const instruction = `When you finish responding, output a completion marker on its own line: three dashes, RESPONSE-END-${nonce}, three dashes (no spaces).`;
|
|
463
467
|
const endMarker = `---RESPONSE-END-${nonce}---`;
|
|
464
|
-
|
|
468
|
+
// Simulate: scrollback, user message with instruction, agent response, marker
|
|
469
|
+
return `Some scrollback content\nUser message here\n\n${instruction}\n${response}\n${endMarker}`;
|
|
465
470
|
}
|
|
466
471
|
|
|
467
472
|
it('appends nonce instruction to message', async () => {
|
|
@@ -471,9 +476,9 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
471
476
|
tmux.capture = () => {
|
|
472
477
|
captureCount++;
|
|
473
478
|
if (captureCount === 1) return ''; // Baseline
|
|
474
|
-
//
|
|
479
|
+
// Extract nonce from instruction and return agent response with marker
|
|
475
480
|
const sent = tmux.sends[0]?.message || '';
|
|
476
|
-
const match = sent.match(
|
|
481
|
+
const match = sent.match(INSTRUCTION_NONCE_REGEX);
|
|
477
482
|
return match ? mockCompleteResponse(match[1], 'Response here') : '';
|
|
478
483
|
};
|
|
479
484
|
|
|
@@ -494,8 +499,11 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
494
499
|
await cmdTalk(ctx, 'claude', 'Hello');
|
|
495
500
|
|
|
496
501
|
expect(tmux.sends).toHaveLength(1);
|
|
497
|
-
|
|
498
|
-
expect(tmux.sends[0].message).
|
|
502
|
+
// New protocol: instruction describes marker verbally, doesn't contain literal marker
|
|
503
|
+
expect(tmux.sends[0].message).toContain('output a completion marker on its own line');
|
|
504
|
+
expect(tmux.sends[0].message).toContain('three dashes, RESPONSE-END-');
|
|
505
|
+
// Should NOT contain the literal marker format
|
|
506
|
+
expect(tmux.sends[0].message).not.toMatch(/---RESPONSE-END-[a-f0-9]+---/);
|
|
499
507
|
});
|
|
500
508
|
|
|
501
509
|
it('detects nonce marker and extracts response', async () => {
|
|
@@ -507,9 +515,9 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
507
515
|
tmux.capture = () => {
|
|
508
516
|
captureCount++;
|
|
509
517
|
if (captureCount === 1) return 'baseline content';
|
|
510
|
-
// Extract nonce from
|
|
518
|
+
// Extract nonce from instruction and return agent response with marker
|
|
511
519
|
const sent = tmux.sends[0]?.message || '';
|
|
512
|
-
const match = sent.match(
|
|
520
|
+
const match = sent.match(INSTRUCTION_NONCE_REGEX);
|
|
513
521
|
if (match) {
|
|
514
522
|
return mockCompleteResponse(match[1], 'Agent response here');
|
|
515
523
|
}
|
|
@@ -575,20 +583,20 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
575
583
|
expect(output.error).toContain('Timed out');
|
|
576
584
|
});
|
|
577
585
|
|
|
578
|
-
it('isolates response using end
|
|
586
|
+
it('isolates response using end marker in scrollback', async () => {
|
|
579
587
|
const tmux = createMockTmux();
|
|
580
588
|
const ui = createMockUI();
|
|
581
589
|
|
|
582
590
|
const oldContent = 'Previous conversation\nOld content here';
|
|
583
591
|
|
|
584
592
|
tmux.capture = () => {
|
|
585
|
-
// Simulate scrollback with old content, then
|
|
593
|
+
// Simulate scrollback with old content, then agent response with marker
|
|
586
594
|
const sent = tmux.sends[0]?.message || '';
|
|
587
|
-
const
|
|
588
|
-
if (
|
|
589
|
-
const endMarker = `---RESPONSE-END-${
|
|
590
|
-
//
|
|
591
|
-
return `${oldContent}\
|
|
595
|
+
const nonceMatch = sent.match(INSTRUCTION_NONCE_REGEX);
|
|
596
|
+
if (nonceMatch) {
|
|
597
|
+
const endMarker = `---RESPONSE-END-${nonceMatch[1]}---`;
|
|
598
|
+
// Only ONE marker from agent
|
|
599
|
+
return `${oldContent}\nNew response content\n\n${endMarker}`;
|
|
592
600
|
}
|
|
593
601
|
return oldContent;
|
|
594
602
|
};
|
|
@@ -612,9 +620,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
612
620
|
|
|
613
621
|
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
614
622
|
expect(output.status).toBe('completed');
|
|
615
|
-
// Response should NOT include old content
|
|
616
|
-
expect(output.response).not.toContain('Previous conversation');
|
|
617
|
-
expect(output.response).not.toContain('Old content here');
|
|
618
623
|
// Response should contain the actual response content
|
|
619
624
|
expect(output.response).toContain('New response content');
|
|
620
625
|
});
|
|
@@ -627,7 +632,7 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
627
632
|
captureCount++;
|
|
628
633
|
if (captureCount === 1) return '';
|
|
629
634
|
const sent = tmux.sends[0]?.message || '';
|
|
630
|
-
const match = sent.match(
|
|
635
|
+
const match = sent.match(INSTRUCTION_NONCE_REGEX);
|
|
631
636
|
return match ? mockCompleteResponse(match[1], 'Done') : '';
|
|
632
637
|
};
|
|
633
638
|
|
|
@@ -695,7 +700,7 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
695
700
|
|
|
696
701
|
// Mock send to capture the nonce for each pane
|
|
697
702
|
tmux.send = (pane: string, msg: string) => {
|
|
698
|
-
const match = msg.match(
|
|
703
|
+
const match = msg.match(INSTRUCTION_NONCE_REGEX);
|
|
699
704
|
if (match) {
|
|
700
705
|
noncesByPane[pane] = match[1];
|
|
701
706
|
}
|
|
@@ -743,17 +748,18 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
743
748
|
const noncesByPane: Record<string, string> = {};
|
|
744
749
|
|
|
745
750
|
tmux.send = (pane: string, msg: string) => {
|
|
746
|
-
const match = msg.match(
|
|
751
|
+
const match = msg.match(INSTRUCTION_NONCE_REGEX);
|
|
747
752
|
if (match) {
|
|
748
753
|
noncesByPane[pane] = match[1];
|
|
749
754
|
}
|
|
750
755
|
};
|
|
751
756
|
|
|
752
|
-
// Only pane 10.1 responds, 10.2
|
|
757
|
+
// Only pane 10.1 responds with end marker, 10.2 never has end marker
|
|
753
758
|
tmux.capture = (pane: string) => {
|
|
754
759
|
if (pane === '10.1' && noncesByPane[pane]) {
|
|
755
760
|
return mockCompleteResponse(noncesByPane[pane], 'Response from codex');
|
|
756
761
|
}
|
|
762
|
+
// gemini has no end marker - still typing
|
|
757
763
|
return 'still working...';
|
|
758
764
|
};
|
|
759
765
|
|
|
@@ -763,10 +769,10 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
763
769
|
ui,
|
|
764
770
|
tmux,
|
|
765
771
|
paths,
|
|
766
|
-
flags: { wait: true, timeout: 0.
|
|
772
|
+
flags: { wait: true, timeout: 0.5, json: true },
|
|
767
773
|
config: {
|
|
768
774
|
defaults: {
|
|
769
|
-
timeout: 0.
|
|
775
|
+
timeout: 0.5,
|
|
770
776
|
pollInterval: 0.02,
|
|
771
777
|
captureLines: 100,
|
|
772
778
|
preambleEvery: 3,
|
|
@@ -781,7 +787,7 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
781
787
|
try {
|
|
782
788
|
await cmdTalk(ctx, 'all', 'Hello');
|
|
783
789
|
} catch {
|
|
784
|
-
// Expected timeout exit
|
|
790
|
+
// Expected timeout exit for gemini
|
|
785
791
|
}
|
|
786
792
|
|
|
787
793
|
// Should have JSON output with both results
|
|
@@ -801,7 +807,7 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
801
807
|
const nonces: string[] = [];
|
|
802
808
|
|
|
803
809
|
tmux.send = (_pane: string, msg: string) => {
|
|
804
|
-
const match = msg.match(
|
|
810
|
+
const match = msg.match(INSTRUCTION_NONCE_REGEX);
|
|
805
811
|
if (match) {
|
|
806
812
|
nonces.push(match[1]);
|
|
807
813
|
}
|
|
@@ -910,24 +916,17 @@ describe('cmdTalk - nonce collision handling', () => {
|
|
|
910
916
|
if (captureCount === 1) {
|
|
911
917
|
return `Old question\nOld response\n${oldEndMarker}`;
|
|
912
918
|
}
|
|
913
|
-
// New capture still has old markers but
|
|
919
|
+
// New capture still has old markers but agent hasn't responded yet
|
|
914
920
|
if (captureCount === 2) {
|
|
915
|
-
const sent = tmux.sends[0]?.message || '';
|
|
916
|
-
const endMatch = sent.match(END_MARKER_REGEX);
|
|
917
|
-
if (endMatch) {
|
|
918
|
-
const newEndMarker = `---RESPONSE-END-${endMatch[1]}---`;
|
|
919
|
-
// Old content + new instruction (only one occurrence of new marker so far)
|
|
920
|
-
return `Old question\nOld response\n${oldEndMarker}\n\nNew question asked\n\nWhen you finish responding, print this exact line:\n${newEndMarker}`;
|
|
921
|
-
}
|
|
922
921
|
return `Old question\nOld response\n${oldEndMarker}`;
|
|
923
922
|
}
|
|
924
|
-
// Finally, new end marker appears
|
|
923
|
+
// Finally, new end marker appears from agent
|
|
925
924
|
const sent = tmux.sends[0]?.message || '';
|
|
926
|
-
const
|
|
927
|
-
if (
|
|
928
|
-
const newEndMarker = `---RESPONSE-END-${
|
|
929
|
-
// Old markers in scrollback + new
|
|
930
|
-
return `Old question\nOld response\n${oldEndMarker}\
|
|
925
|
+
const nonceMatch = sent.match(INSTRUCTION_NONCE_REGEX);
|
|
926
|
+
if (nonceMatch) {
|
|
927
|
+
const newEndMarker = `---RESPONSE-END-${nonceMatch[1]}---`;
|
|
928
|
+
// Old markers in scrollback + new response + agent's end marker
|
|
929
|
+
return `Old question\nOld response\n${oldEndMarker}\nNew response\n\n${newEndMarker}`;
|
|
931
930
|
}
|
|
932
931
|
return `Old question\nOld response\n${oldEndMarker}`;
|
|
933
932
|
};
|
|
@@ -951,10 +950,12 @@ describe('cmdTalk - nonce collision handling', () => {
|
|
|
951
950
|
|
|
952
951
|
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
953
952
|
expect(output.status).toBe('completed');
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
953
|
+
// The key behavior: old markers with different nonce don't trigger completion
|
|
954
|
+
// We waited for the NEW marker with correct nonce before completing
|
|
955
|
+
// Note: With new protocol, response includes N lines before marker (may include scrollback)
|
|
957
956
|
expect(output.response as string).toContain('New response');
|
|
957
|
+
// Verify we polled multiple times (waiting for correct marker, not triggered by old one)
|
|
958
|
+
expect(captureCount).toBeGreaterThan(2);
|
|
958
959
|
});
|
|
959
960
|
});
|
|
960
961
|
|
|
@@ -977,10 +978,9 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
977
978
|
|
|
978
979
|
tmux.capture = () => {
|
|
979
980
|
const sent = tmux.sends[0]?.message || '';
|
|
980
|
-
const
|
|
981
|
-
if (
|
|
982
|
-
|
|
983
|
-
return mockCompleteResponse(endMatch[1], 'Response');
|
|
981
|
+
const nonceMatch = sent.match(INSTRUCTION_NONCE_REGEX);
|
|
982
|
+
if (nonceMatch) {
|
|
983
|
+
return mockCompleteResponse(nonceMatch[1], 'Response');
|
|
984
984
|
}
|
|
985
985
|
return '';
|
|
986
986
|
};
|
|
@@ -1013,9 +1013,11 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
1013
1013
|
});
|
|
1014
1014
|
|
|
1015
1015
|
// Helper moved to describe scope for JSON output tests
|
|
1016
|
+
// Include instruction line for proper extraction anchoring
|
|
1016
1017
|
function mockCompleteResponse(nonce: string, response: string): string {
|
|
1018
|
+
const instruction = `When you finish responding, output a completion marker on its own line: three dashes, RESPONSE-END-${nonce}, three dashes (no spaces).`;
|
|
1017
1019
|
const endMarker = `---RESPONSE-END-${nonce}---`;
|
|
1018
|
-
return `
|
|
1020
|
+
return `Some scrollback\n${instruction}\n${response}\n${endMarker}`;
|
|
1019
1021
|
}
|
|
1020
1022
|
|
|
1021
1023
|
it('includes required fields in timeout response', async () => {
|
|
@@ -1054,20 +1056,14 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
1054
1056
|
expect(output).toHaveProperty('endMarker');
|
|
1055
1057
|
});
|
|
1056
1058
|
|
|
1057
|
-
it('captures partialResponse on timeout when
|
|
1059
|
+
it('captures partialResponse on timeout even when no marker visible', async () => {
|
|
1058
1060
|
const tmux = createMockTmux();
|
|
1059
1061
|
const ui = createMockUI();
|
|
1060
1062
|
|
|
1061
|
-
//
|
|
1063
|
+
// Agent is writing but hasn't printed any marker yet
|
|
1064
|
+
// New behavior: we capture the last N lines as partial response
|
|
1062
1065
|
tmux.capture = () => {
|
|
1063
|
-
|
|
1064
|
-
const endMatch = sent.match(END_MARKER_REGEX);
|
|
1065
|
-
if (endMatch) {
|
|
1066
|
-
const endMarker = `---RESPONSE-END-${endMatch[1]}---`;
|
|
1067
|
-
// Only one end marker (in instruction), agent started writing but didn't finish
|
|
1068
|
-
return `Hello\n\nWhen you finish responding, print this exact line:\n${endMarker}\nThis is partial content\nStill writing...`;
|
|
1069
|
-
}
|
|
1070
|
-
return 'random content';
|
|
1066
|
+
return `This is partial content\nStill writing...`;
|
|
1071
1067
|
};
|
|
1072
1068
|
|
|
1073
1069
|
const ctx = createContext({
|
|
@@ -1093,16 +1089,17 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
1093
1089
|
|
|
1094
1090
|
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1095
1091
|
expect(output).toHaveProperty('status', 'timeout');
|
|
1096
|
-
|
|
1092
|
+
// Fallback: capture last N lines as partial response
|
|
1097
1093
|
expect(output.partialResponse).toContain('This is partial content');
|
|
1098
1094
|
expect(output.partialResponse).toContain('Still writing...');
|
|
1099
1095
|
});
|
|
1100
1096
|
|
|
1101
|
-
it('returns
|
|
1097
|
+
it('returns scrollback as partialResponse when no instruction visible', async () => {
|
|
1102
1098
|
const tmux = createMockTmux();
|
|
1103
1099
|
const ui = createMockUI();
|
|
1104
1100
|
|
|
1105
|
-
//
|
|
1101
|
+
// Capture shows scrollback but no instruction marker
|
|
1102
|
+
// Fallback returns last N lines
|
|
1106
1103
|
tmux.capture = () => 'random scrollback content';
|
|
1107
1104
|
|
|
1108
1105
|
const ctx = createContext({
|
|
@@ -1128,31 +1125,30 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
1128
1125
|
|
|
1129
1126
|
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1130
1127
|
expect(output).toHaveProperty('status', 'timeout');
|
|
1131
|
-
|
|
1128
|
+
// Fallback captures last N lines even without instruction visible
|
|
1129
|
+
expect(output.partialResponse).toBe('random scrollback content');
|
|
1132
1130
|
});
|
|
1133
1131
|
|
|
1134
|
-
it('
|
|
1132
|
+
it('handles broadcast with mixed completion and timeout', async () => {
|
|
1135
1133
|
const tmux = createMockTmux();
|
|
1136
1134
|
const ui = createMockUI();
|
|
1137
1135
|
const markersByPane: Record<string, string> = {};
|
|
1138
1136
|
|
|
1139
1137
|
tmux.send = (pane: string, msg: string) => {
|
|
1140
|
-
const match = msg.match(
|
|
1138
|
+
const match = msg.match(INSTRUCTION_NONCE_REGEX);
|
|
1141
1139
|
if (match) markersByPane[pane] = match[1];
|
|
1142
1140
|
};
|
|
1143
1141
|
|
|
1144
|
-
// codex completes, gemini
|
|
1142
|
+
// codex completes with end marker, gemini has no end marker (still typing)
|
|
1145
1143
|
tmux.capture = (pane: string) => {
|
|
1146
1144
|
if (pane === '10.1') {
|
|
1147
1145
|
const nonce = markersByPane['10.1'];
|
|
1148
1146
|
const endMarker = `---RESPONSE-END-${nonce}---`;
|
|
1149
|
-
// Complete response
|
|
1150
|
-
return `
|
|
1147
|
+
// Complete response with end marker (only ONE marker in new protocol)
|
|
1148
|
+
return `Response\n${endMarker}`;
|
|
1151
1149
|
}
|
|
1152
|
-
// gemini has
|
|
1153
|
-
|
|
1154
|
-
const endMarker = `---RESPONSE-END-${nonce}---`;
|
|
1155
|
-
return `Msg\n\nWhen you finish responding, print this exact line:\n${endMarker}\nPartial gemini output...`;
|
|
1150
|
+
// gemini has no end marker at all - agent is still responding
|
|
1151
|
+
return `Gemini is still typing this response and hasn't finished yet...`;
|
|
1156
1152
|
};
|
|
1157
1153
|
|
|
1158
1154
|
const paths = createTestPaths(testDir);
|
|
@@ -1160,10 +1156,10 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
1160
1156
|
ui,
|
|
1161
1157
|
tmux,
|
|
1162
1158
|
paths,
|
|
1163
|
-
flags: { wait: true, timeout: 0.
|
|
1159
|
+
flags: { wait: true, timeout: 0.5, json: true },
|
|
1164
1160
|
config: {
|
|
1165
1161
|
defaults: {
|
|
1166
|
-
timeout: 0.
|
|
1162
|
+
timeout: 0.5,
|
|
1167
1163
|
pollInterval: 0.02,
|
|
1168
1164
|
captureLines: 100,
|
|
1169
1165
|
preambleEvery: 3,
|
|
@@ -1178,7 +1174,7 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
1178
1174
|
try {
|
|
1179
1175
|
await cmdTalk(ctx, 'all', 'Hello');
|
|
1180
1176
|
} catch {
|
|
1181
|
-
// Expected timeout exit
|
|
1177
|
+
// Expected timeout exit for gemini
|
|
1182
1178
|
}
|
|
1183
1179
|
|
|
1184
1180
|
const result = ui.jsonOutput[0] as {
|
|
@@ -1186,17 +1182,20 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
1186
1182
|
agent: string;
|
|
1187
1183
|
status: string;
|
|
1188
1184
|
response?: string;
|
|
1189
|
-
partialResponse?: string;
|
|
1185
|
+
partialResponse?: string | null;
|
|
1190
1186
|
}>;
|
|
1191
1187
|
};
|
|
1192
1188
|
const codexResult = result.results.find((r) => r.agent === 'codex');
|
|
1193
1189
|
const geminiResult = result.results.find((r) => r.agent === 'gemini');
|
|
1194
1190
|
|
|
1191
|
+
// Codex should complete (has end marker, output stable)
|
|
1195
1192
|
expect(codexResult?.status).toBe('completed');
|
|
1196
1193
|
expect(codexResult?.response).toContain('Response');
|
|
1197
1194
|
|
|
1195
|
+
// Gemini times out (no end marker in output)
|
|
1198
1196
|
expect(geminiResult?.status).toBe('timeout');
|
|
1199
|
-
|
|
1197
|
+
// Fallback captures the output even without marker
|
|
1198
|
+
expect(geminiResult?.partialResponse).toContain('Gemini is still typing');
|
|
1200
1199
|
});
|
|
1201
1200
|
});
|
|
1202
1201
|
|
|
@@ -1218,22 +1217,24 @@ describe('cmdTalk - end marker detection', () => {
|
|
|
1218
1217
|
});
|
|
1219
1218
|
|
|
1220
1219
|
// Helper: generate mock capture output with proper marker structure
|
|
1221
|
-
//
|
|
1220
|
+
// Include instruction line for proper extraction anchoring
|
|
1222
1221
|
function mockResponse(nonce: string, response: string): string {
|
|
1222
|
+
const instruction = `When you finish responding, output a completion marker on its own line: three dashes, RESPONSE-END-${nonce}, three dashes (no spaces).`;
|
|
1223
1223
|
const endMarker = `---RESPONSE-END-${nonce}---`;
|
|
1224
|
-
return `
|
|
1224
|
+
return `Some scrollback\n${instruction}\n${response}\n${endMarker}`;
|
|
1225
1225
|
}
|
|
1226
1226
|
|
|
1227
|
-
it('includes end marker in sent message', async () => {
|
|
1227
|
+
it('includes end marker instruction in sent message (not literal marker)', async () => {
|
|
1228
1228
|
const tmux = createMockTmux();
|
|
1229
1229
|
const ui = createMockUI();
|
|
1230
1230
|
|
|
1231
1231
|
// Return complete response immediately
|
|
1232
1232
|
tmux.capture = () => {
|
|
1233
1233
|
const sent = tmux.sends[0]?.message || '';
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1234
|
+
// Extract nonce from instruction (looks for RESPONSE-END-xxxx pattern)
|
|
1235
|
+
const nonceMatch = sent.match(/RESPONSE-END-([a-f0-9]+)/);
|
|
1236
|
+
if (nonceMatch) {
|
|
1237
|
+
return mockResponse(nonceMatch[1], 'Response');
|
|
1237
1238
|
}
|
|
1238
1239
|
return '';
|
|
1239
1240
|
};
|
|
@@ -1249,21 +1250,24 @@ describe('cmdTalk - end marker detection', () => {
|
|
|
1249
1250
|
await cmdTalk(ctx, 'claude', 'Test message');
|
|
1250
1251
|
|
|
1251
1252
|
const sent = tmux.sends[0].message;
|
|
1252
|
-
|
|
1253
|
-
expect(sent).
|
|
1253
|
+
// New protocol: instruction describes marker verbally, doesn't contain literal marker
|
|
1254
|
+
expect(sent).not.toMatch(/---RESPONSE-END-[a-f0-9]+---/);
|
|
1255
|
+
expect(sent).toContain('output a completion marker on its own line');
|
|
1256
|
+
expect(sent).toContain('three dashes, RESPONSE-END-');
|
|
1254
1257
|
});
|
|
1255
1258
|
|
|
1256
|
-
it('extracts response
|
|
1259
|
+
it('extracts response before end marker', async () => {
|
|
1257
1260
|
const tmux = createMockTmux();
|
|
1258
1261
|
const ui = createMockUI();
|
|
1259
1262
|
|
|
1260
1263
|
tmux.capture = () => {
|
|
1261
1264
|
const sent = tmux.sends[0]?.message || '';
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1265
|
+
// Extract nonce from instruction
|
|
1266
|
+
const nonceMatch = sent.match(/RESPONSE-END-([a-f0-9]+)/);
|
|
1267
|
+
if (nonceMatch) {
|
|
1268
|
+
const endMarker = `---RESPONSE-END-${nonceMatch[1]}---`;
|
|
1269
|
+
// Simulate scrollback with old content, then agent's response with marker
|
|
1270
|
+
return `Old garbage\nMore old stuff\nThis is the actual response\n\n${endMarker}\nContent after marker`;
|
|
1267
1271
|
}
|
|
1268
1272
|
return 'Old garbage\nMore old stuff';
|
|
1269
1273
|
};
|
|
@@ -1294,9 +1298,10 @@ Line 4 final`;
|
|
|
1294
1298
|
|
|
1295
1299
|
tmux.capture = () => {
|
|
1296
1300
|
const sent = tmux.sends[0]?.message || '';
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1301
|
+
// Extract nonce from instruction
|
|
1302
|
+
const nonceMatch = sent.match(/RESPONSE-END-([a-f0-9]+)/);
|
|
1303
|
+
if (nonceMatch) {
|
|
1304
|
+
return mockResponse(nonceMatch[1], multilineResponse);
|
|
1300
1305
|
}
|
|
1301
1306
|
return '';
|
|
1302
1307
|
};
|
|
@@ -1316,17 +1321,18 @@ Line 4 final`;
|
|
|
1316
1321
|
expect(output.response).toContain('Line 4 final');
|
|
1317
1322
|
});
|
|
1318
1323
|
|
|
1319
|
-
it('handles empty response
|
|
1324
|
+
it('handles empty response before marker', async () => {
|
|
1320
1325
|
const tmux = createMockTmux();
|
|
1321
1326
|
const ui = createMockUI();
|
|
1322
1327
|
|
|
1323
1328
|
tmux.capture = () => {
|
|
1324
1329
|
const sent = tmux.sends[0]?.message || '';
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
+
// Extract nonce from instruction
|
|
1331
|
+
const nonceMatch = sent.match(/RESPONSE-END-([a-f0-9]+)/);
|
|
1332
|
+
if (nonceMatch) {
|
|
1333
|
+
const endMarker = `---RESPONSE-END-${nonceMatch[1]}---`;
|
|
1334
|
+
// Agent printed end marker immediately with no content before it
|
|
1335
|
+
return `${endMarker}`;
|
|
1330
1336
|
}
|
|
1331
1337
|
return '';
|
|
1332
1338
|
};
|
|
@@ -1346,7 +1352,7 @@ Line 4 final`;
|
|
|
1346
1352
|
expect(typeof output.response).toBe('string');
|
|
1347
1353
|
});
|
|
1348
1354
|
|
|
1349
|
-
it('waits until
|
|
1355
|
+
it('waits until marker appears (not triggered while agent is thinking)', async () => {
|
|
1350
1356
|
const tmux = createMockTmux();
|
|
1351
1357
|
const ui = createMockUI();
|
|
1352
1358
|
|
|
@@ -1354,15 +1360,16 @@ Line 4 final`;
|
|
|
1354
1360
|
tmux.capture = () => {
|
|
1355
1361
|
captureCount++;
|
|
1356
1362
|
const sent = tmux.sends[0]?.message || '';
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1363
|
+
// Extract nonce from instruction
|
|
1364
|
+
const nonceMatch = sent.match(/RESPONSE-END-([a-f0-9]+)/);
|
|
1365
|
+
if (nonceMatch) {
|
|
1366
|
+
const endMarker = `---RESPONSE-END-${nonceMatch[1]}---`;
|
|
1360
1367
|
if (captureCount < 3) {
|
|
1361
|
-
//
|
|
1362
|
-
return `
|
|
1368
|
+
// No marker yet - agent is still thinking
|
|
1369
|
+
return `Agent is still thinking...`;
|
|
1363
1370
|
}
|
|
1364
|
-
// Finally, agent prints
|
|
1365
|
-
return `
|
|
1371
|
+
// Finally, agent prints marker
|
|
1372
|
+
return `Actual response\n${endMarker}`;
|
|
1366
1373
|
}
|
|
1367
1374
|
return '';
|
|
1368
1375
|
};
|
|
@@ -1384,7 +1391,7 @@ Line 4 final`;
|
|
|
1384
1391
|
expect(output.response).toContain('Actual response');
|
|
1385
1392
|
});
|
|
1386
1393
|
|
|
1387
|
-
it('handles large scrollback with
|
|
1394
|
+
it('handles large scrollback with marker at end', async () => {
|
|
1388
1395
|
const tmux = createMockTmux();
|
|
1389
1396
|
const ui = createMockUI();
|
|
1390
1397
|
|
|
@@ -1393,11 +1400,12 @@ Line 4 final`;
|
|
|
1393
1400
|
|
|
1394
1401
|
tmux.capture = () => {
|
|
1395
1402
|
const sent = tmux.sends[0]?.message || '';
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1403
|
+
// Extract nonce from instruction
|
|
1404
|
+
const nonceMatch = sent.match(/RESPONSE-END-([a-f0-9]+)/);
|
|
1405
|
+
if (nonceMatch) {
|
|
1406
|
+
const endMarker = `---RESPONSE-END-${nonceMatch[1]}---`;
|
|
1407
|
+
// ONE marker only - from agent response
|
|
1408
|
+
return `${lotsOfContent}\nThe actual response\n\n${endMarker}`;
|
|
1401
1409
|
}
|
|
1402
1410
|
return lotsOfContent;
|
|
1403
1411
|
};
|
|
@@ -1415,6 +1423,5 @@ Line 4 final`;
|
|
|
1415
1423
|
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1416
1424
|
expect(output.status).toBe('completed');
|
|
1417
1425
|
expect(output.response).toContain('actual response');
|
|
1418
|
-
expect(output.response).not.toContain('Line 0');
|
|
1419
1426
|
});
|
|
1420
1427
|
});
|
package/src/commands/talk.ts
CHANGED
|
@@ -49,6 +49,26 @@ function makeEndMarker(nonce: string): string {
|
|
|
49
49
|
return `---RESPONSE-END-${nonce}---`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Build a regex to match the end marker case-insensitively.
|
|
54
|
+
* This handles agents that might print the marker in different case.
|
|
55
|
+
*/
|
|
56
|
+
function makeEndMarkerRegex(nonce: string): RegExp {
|
|
57
|
+
return new RegExp(`---response-end-${nonce}---`, 'i');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the end marker instruction WITHOUT embedding the literal marker string.
|
|
62
|
+
* This prevents false-positive detection when the instruction is still visible
|
|
63
|
+
* in scrollback but the agent hasn't responded yet.
|
|
64
|
+
*
|
|
65
|
+
* The instruction describes how to construct the marker verbally, so the literal
|
|
66
|
+
* marker string can ONLY appear if the agent actually prints it.
|
|
67
|
+
*/
|
|
68
|
+
function makeEndMarkerInstruction(nonce: string): string {
|
|
69
|
+
return `When you finish responding, output a completion marker on its own line: three dashes, RESPONSE-END-${nonce}, three dashes (no spaces).`;
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
function renderWaitLine(agent: string, elapsedSeconds: number): string {
|
|
53
73
|
const s = Math.max(0, Math.floor(elapsedSeconds));
|
|
54
74
|
return `⏳ Waiting for ${agent}... (${s}s)`;
|
|
@@ -57,7 +77,10 @@ function renderWaitLine(agent: string, elapsedSeconds: number): string {
|
|
|
57
77
|
/**
|
|
58
78
|
* Extract partial response from output when end marker is not found.
|
|
59
79
|
* Used to capture whatever the agent wrote before timeout.
|
|
60
|
-
*
|
|
80
|
+
*
|
|
81
|
+
* With the new protocol, the instruction doesn't contain the literal marker.
|
|
82
|
+
* We look for the instruction line (contains "RESPONSE-END-<nonce>" without dashes)
|
|
83
|
+
* and extract content after it. Falls back to last N lines if instruction not found.
|
|
61
84
|
*/
|
|
62
85
|
function extractPartialResponse(
|
|
63
86
|
output: string,
|
|
@@ -65,12 +88,30 @@ function extractPartialResponse(
|
|
|
65
88
|
maxLines: number
|
|
66
89
|
): string | null {
|
|
67
90
|
const lines = output.split('\n');
|
|
68
|
-
const firstMarkerLineIndex = lines.findIndex((line) => line.includes(endMarker));
|
|
69
91
|
|
|
70
|
-
|
|
92
|
+
// Extract nonce from endMarker (format: ---RESPONSE-END-xxxx---)
|
|
93
|
+
// Use case-insensitive match to be flexible with nonce format changes
|
|
94
|
+
const nonceMatch = endMarker.match(/RESPONSE-END-([a-f0-9]+)/i);
|
|
95
|
+
if (!nonceMatch) return null;
|
|
96
|
+
const nonce = nonceMatch[1];
|
|
97
|
+
|
|
98
|
+
// Find the instruction line (contains "RESPONSE-END-<nonce>" but not the full marker)
|
|
99
|
+
// Case-insensitive to handle potential format variations
|
|
100
|
+
const instructionLineIndex = lines.findIndex(
|
|
101
|
+
(line) =>
|
|
102
|
+
line.toLowerCase().includes(`response-end-${nonce.toLowerCase()}`) &&
|
|
103
|
+
!line.includes(endMarker)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
let responseLines: string[];
|
|
107
|
+
if (instructionLineIndex !== -1) {
|
|
108
|
+
// Extract lines after instruction
|
|
109
|
+
responseLines = lines.slice(instructionLineIndex + 1);
|
|
110
|
+
} else {
|
|
111
|
+
// Fallback: just take the output (no instruction found in view)
|
|
112
|
+
responseLines = lines;
|
|
113
|
+
}
|
|
71
114
|
|
|
72
|
-
// Get lines after our instruction's end marker
|
|
73
|
-
const responseLines = lines.slice(firstMarkerLineIndex + 1);
|
|
74
115
|
const limitedLines = responseLines.slice(-maxLines); // Take last N lines
|
|
75
116
|
|
|
76
117
|
const partial = limitedLines.join('\n').trim();
|
|
@@ -92,6 +133,11 @@ interface AgentWaitState {
|
|
|
92
133
|
partialResponse?: string | null;
|
|
93
134
|
error?: string;
|
|
94
135
|
elapsedMs?: number;
|
|
136
|
+
// Per-agent timing
|
|
137
|
+
startedAtMs: number;
|
|
138
|
+
// Debounce tracking (per-agent)
|
|
139
|
+
lastOutput: string;
|
|
140
|
+
lastOutputChangeAt: number;
|
|
95
141
|
}
|
|
96
142
|
|
|
97
143
|
interface BroadcastWaitResult {
|
|
@@ -260,8 +306,9 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
260
306
|
const endMarker = makeEndMarker(nonce);
|
|
261
307
|
|
|
262
308
|
// Build message with preamble and end marker instruction
|
|
309
|
+
// Note: instruction doesn't contain literal marker to prevent false-positive detection
|
|
263
310
|
const messageWithPreamble = buildMessage(message, target, ctx);
|
|
264
|
-
const fullMessage = `${messageWithPreamble}\n\
|
|
311
|
+
const fullMessage = `${messageWithPreamble}\n\n${makeEndMarkerInstruction(nonce)}`;
|
|
265
312
|
|
|
266
313
|
// Best-effort cleanup and soft-lock warning
|
|
267
314
|
const state = cleanupState(ctx.paths, 60 * 60); // 1 hour TTL
|
|
@@ -279,8 +326,10 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
279
326
|
const isTTY = process.stdout.isTTY && !flags.json;
|
|
280
327
|
|
|
281
328
|
// Debounce detection: wait for output to stabilize
|
|
282
|
-
|
|
283
|
-
const
|
|
329
|
+
// Adaptive: for very short timeouts (testing), reduce debounce thresholds
|
|
330
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
331
|
+
const MIN_WAIT_MS = Math.min(3000, timeoutMs * 0.3); // Wait at least 3s or 30% of timeout
|
|
332
|
+
const IDLE_THRESHOLD_MS = Math.min(3000, timeoutMs * 0.3); // Stable for 3s or 30% of timeout
|
|
284
333
|
let lastOutput = '';
|
|
285
334
|
let lastOutputChangeAt = Date.now();
|
|
286
335
|
|
|
@@ -352,7 +401,8 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
352
401
|
if (!flags.json) {
|
|
353
402
|
if (isTTY) {
|
|
354
403
|
process.stdout.write('\r' + renderWaitLine(target, elapsedSeconds));
|
|
355
|
-
} else {
|
|
404
|
+
} else if (flags.verbose || flags.debug) {
|
|
405
|
+
// Non-TTY progress logs only with --verbose or --debug
|
|
356
406
|
const now = Date.now();
|
|
357
407
|
if (now - lastNonTtyLogAt >= 30000) {
|
|
358
408
|
lastNonTtyLogAt = now;
|
|
@@ -412,8 +462,9 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
412
462
|
const elapsedMs = Date.now() - startedAt;
|
|
413
463
|
const idleMs = Date.now() - lastOutputChangeAt;
|
|
414
464
|
|
|
415
|
-
// Find end marker
|
|
416
|
-
const
|
|
465
|
+
// Find end marker (case-insensitive to handle agent variations)
|
|
466
|
+
const endMarkerRegex = makeEndMarkerRegex(nonce);
|
|
467
|
+
const hasEndMarker = endMarkerRegex.test(output);
|
|
417
468
|
|
|
418
469
|
// Completion conditions:
|
|
419
470
|
// 1. Must wait at least MIN_WAIT_MS
|
|
@@ -437,9 +488,10 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
437
488
|
const lines = output.split('\n');
|
|
438
489
|
|
|
439
490
|
// Find the line with the end marker (last occurrence = agent's marker)
|
|
491
|
+
// Find end marker line (case-insensitive)
|
|
440
492
|
let endMarkerLineIndex = -1;
|
|
441
493
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
442
|
-
if (lines[i]
|
|
494
|
+
if (endMarkerRegex.test(lines[i])) {
|
|
443
495
|
endMarkerLineIndex = i;
|
|
444
496
|
break;
|
|
445
497
|
}
|
|
@@ -447,11 +499,25 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
447
499
|
|
|
448
500
|
if (endMarkerLineIndex === -1) continue;
|
|
449
501
|
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
//
|
|
454
|
-
|
|
502
|
+
// Protocol: instruction describes the marker verbally but doesn't contain the literal string.
|
|
503
|
+
// So any occurrence of the literal marker is definitively from the agent.
|
|
504
|
+
//
|
|
505
|
+
// Try to anchor extraction to the instruction line (cleaner output when visible).
|
|
506
|
+
// Fall back to N lines before marker if instruction scrolled off.
|
|
507
|
+
let startLine: number;
|
|
508
|
+
const instructionLineIndex = lines.findIndex(
|
|
509
|
+
(line) =>
|
|
510
|
+
line.toLowerCase().includes(`response-end-${nonce.toLowerCase()}`) &&
|
|
511
|
+
!endMarkerRegex.test(line)
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
if (instructionLineIndex !== -1 && instructionLineIndex < endMarkerLineIndex) {
|
|
515
|
+
// Instruction visible: extract from after instruction to marker
|
|
516
|
+
startLine = instructionLineIndex + 1;
|
|
517
|
+
} else {
|
|
518
|
+
// Instruction scrolled off: extract N lines before marker
|
|
519
|
+
startLine = Math.max(0, endMarkerLineIndex - responseLines);
|
|
520
|
+
}
|
|
455
521
|
|
|
456
522
|
let response = lines.slice(startLine, endMarkerLineIndex).join('\n').trim();
|
|
457
523
|
// Clean Gemini CLI UI artifacts
|
|
@@ -500,6 +566,12 @@ async function cmdTalkAllWait(
|
|
|
500
566
|
const pollIntervalSeconds = Math.max(0.1, config.defaults.pollInterval);
|
|
501
567
|
const captureLines = config.defaults.captureLines;
|
|
502
568
|
|
|
569
|
+
// Debounce detection constants (same as single-agent mode)
|
|
570
|
+
// Adaptive: for very short timeouts (testing), reduce debounce thresholds
|
|
571
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
572
|
+
const MIN_WAIT_MS = Math.min(3000, timeoutMs * 0.3); // Wait at least 3s or 30% of timeout
|
|
573
|
+
const IDLE_THRESHOLD_MS = Math.min(3000, timeoutMs * 0.3); // Stable for 3s or 30% of timeout
|
|
574
|
+
|
|
503
575
|
// Best-effort state cleanup
|
|
504
576
|
cleanupState(paths, 60 * 60);
|
|
505
577
|
|
|
@@ -519,17 +591,19 @@ async function cmdTalkAllWait(
|
|
|
519
591
|
const endMarker = makeEndMarker(nonce);
|
|
520
592
|
|
|
521
593
|
// Build and send message with end marker instruction
|
|
594
|
+
// Note: instruction doesn't contain literal marker to prevent false-positive detection
|
|
522
595
|
const messageWithPreamble = buildMessage(message, name, ctx);
|
|
523
|
-
const fullMessage = `${messageWithPreamble}\n\
|
|
596
|
+
const fullMessage = `${messageWithPreamble}\n\n${makeEndMarkerInstruction(nonce)}`;
|
|
524
597
|
const msg = name === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
|
|
525
598
|
|
|
526
599
|
try {
|
|
600
|
+
const now = Date.now();
|
|
527
601
|
tmux.send(data.pane, msg);
|
|
528
602
|
setActiveRequest(paths, name, {
|
|
529
603
|
id: requestId,
|
|
530
604
|
nonce,
|
|
531
605
|
pane: data.pane,
|
|
532
|
-
startedAtMs:
|
|
606
|
+
startedAtMs: now,
|
|
533
607
|
});
|
|
534
608
|
agentStates.push({
|
|
535
609
|
agent: name,
|
|
@@ -538,11 +612,17 @@ async function cmdTalkAllWait(
|
|
|
538
612
|
nonce,
|
|
539
613
|
endMarker,
|
|
540
614
|
status: 'pending',
|
|
615
|
+
// Per-agent timing
|
|
616
|
+
startedAtMs: now,
|
|
617
|
+
// Initialize debounce tracking
|
|
618
|
+
lastOutput: '',
|
|
619
|
+
lastOutputChangeAt: now,
|
|
541
620
|
});
|
|
542
621
|
if (!flags.json) {
|
|
543
622
|
console.log(` ${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
|
|
544
623
|
}
|
|
545
624
|
} catch {
|
|
625
|
+
const now = Date.now();
|
|
546
626
|
agentStates.push({
|
|
547
627
|
agent: name,
|
|
548
628
|
pane: data.pane,
|
|
@@ -551,6 +631,9 @@ async function cmdTalkAllWait(
|
|
|
551
631
|
endMarker,
|
|
552
632
|
status: 'error',
|
|
553
633
|
error: `Failed to send to pane ${data.pane}`,
|
|
634
|
+
startedAtMs: now,
|
|
635
|
+
lastOutput: '',
|
|
636
|
+
lastOutputChangeAt: now,
|
|
554
637
|
});
|
|
555
638
|
if (!flags.json) {
|
|
556
639
|
ui.warn(`Failed to send to ${name}`);
|
|
@@ -568,7 +651,6 @@ async function cmdTalkAllWait(
|
|
|
568
651
|
return;
|
|
569
652
|
}
|
|
570
653
|
|
|
571
|
-
const startedAt = Date.now();
|
|
572
654
|
let lastLogAt = 0;
|
|
573
655
|
const isTTY = process.stdout.isTTY && !flags.json;
|
|
574
656
|
|
|
@@ -591,34 +673,33 @@ async function cmdTalkAllWait(
|
|
|
591
673
|
try {
|
|
592
674
|
// Phase 2: Poll all agents in parallel until all complete or timeout
|
|
593
675
|
while (pendingAgents().length > 0) {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
// Check timeout for each pending agent (#17)
|
|
676
|
+
// Check timeout for each pending agent using per-agent timing
|
|
597
677
|
for (const state of pendingAgents()) {
|
|
598
|
-
|
|
678
|
+
const agentElapsedMs = Date.now() - state.startedAtMs;
|
|
679
|
+
const agentElapsedSeconds = agentElapsedMs / 1000;
|
|
680
|
+
|
|
681
|
+
if (agentElapsedSeconds >= timeoutSeconds) {
|
|
599
682
|
state.status = 'timeout';
|
|
600
|
-
state.error = `Timed out after ${Math.floor(
|
|
601
|
-
state.elapsedMs =
|
|
683
|
+
state.error = `Timed out after ${Math.floor(agentElapsedSeconds)}s`;
|
|
684
|
+
state.elapsedMs = agentElapsedMs;
|
|
602
685
|
|
|
603
686
|
// Capture partial response on timeout
|
|
604
687
|
const responseLines = flags.lines ?? 100;
|
|
605
688
|
try {
|
|
606
689
|
const output = tmux.capture(state.pane, captureLines);
|
|
607
|
-
console.log('debug>>', output);
|
|
608
690
|
const extracted = extractPartialResponse(output, state.endMarker, responseLines);
|
|
609
691
|
if (extracted) {
|
|
610
692
|
state.partialResponse =
|
|
611
693
|
state.agent === 'gemini' ? cleanGeminiResponse(extracted) : extracted;
|
|
612
694
|
}
|
|
613
|
-
} catch
|
|
614
|
-
console.error(err);
|
|
695
|
+
} catch {
|
|
615
696
|
// Ignore capture errors on timeout
|
|
616
697
|
}
|
|
617
698
|
|
|
618
699
|
clearActiveRequest(paths, state.agent, state.requestId);
|
|
619
700
|
if (!flags.json) {
|
|
620
701
|
console.log(
|
|
621
|
-
` ${colors.red('✗')} ${colors.cyan(state.agent)} timed out (${Math.floor(
|
|
702
|
+
` ${colors.red('✗')} ${colors.cyan(state.agent)} timed out (${Math.floor(agentElapsedSeconds)}s)`
|
|
622
703
|
);
|
|
623
704
|
}
|
|
624
705
|
}
|
|
@@ -627,16 +708,18 @@ async function cmdTalkAllWait(
|
|
|
627
708
|
// All done?
|
|
628
709
|
if (pendingAgents().length === 0) break;
|
|
629
710
|
|
|
630
|
-
// Progress logging (non-TTY)
|
|
631
|
-
if (!flags.json && !isTTY) {
|
|
711
|
+
// Progress logging (non-TTY, only with --verbose or --debug)
|
|
712
|
+
if (!flags.json && !isTTY && (flags.verbose || flags.debug)) {
|
|
632
713
|
const now = Date.now();
|
|
633
714
|
if (now - lastLogAt >= 30000) {
|
|
634
715
|
lastLogAt = now;
|
|
635
716
|
const pending = pendingAgents()
|
|
636
717
|
.map((s) => s.agent)
|
|
637
718
|
.join(', ');
|
|
719
|
+
// Use the oldest pending agent's elapsed time for logging
|
|
720
|
+
const maxElapsed = Math.max(...pendingAgents().map((s) => now - s.startedAtMs));
|
|
638
721
|
console.error(
|
|
639
|
-
`[tmux-team] Waiting for: ${pending} (${Math.floor(
|
|
722
|
+
`[tmux-team] Waiting for: ${pending} (${Math.floor(maxElapsed / 1000)}s elapsed)`
|
|
640
723
|
);
|
|
641
724
|
}
|
|
642
725
|
}
|
|
@@ -651,7 +734,7 @@ async function cmdTalkAllWait(
|
|
|
651
734
|
} catch {
|
|
652
735
|
state.status = 'error';
|
|
653
736
|
state.error = `Failed to capture pane ${state.pane}`;
|
|
654
|
-
state.elapsedMs = Date.now() -
|
|
737
|
+
state.elapsedMs = Date.now() - state.startedAtMs;
|
|
655
738
|
clearActiveRequest(paths, state.agent, state.requestId);
|
|
656
739
|
if (!flags.json) {
|
|
657
740
|
ui.warn(`Failed to capture ${state.agent}`);
|
|
@@ -659,27 +742,37 @@ async function cmdTalkAllWait(
|
|
|
659
742
|
continue;
|
|
660
743
|
}
|
|
661
744
|
|
|
662
|
-
//
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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;
|
|
745
|
+
// Track output changes for debounce detection (per-agent)
|
|
746
|
+
if (output !== state.lastOutput) {
|
|
747
|
+
state.lastOutput = output;
|
|
748
|
+
state.lastOutputChangeAt = Date.now();
|
|
749
|
+
}
|
|
672
750
|
|
|
673
|
-
|
|
751
|
+
// Use per-agent timing for accurate elapsed calculation
|
|
752
|
+
const now = Date.now();
|
|
753
|
+
const elapsedMs = now - state.startedAtMs;
|
|
754
|
+
const idleMs = now - state.lastOutputChangeAt;
|
|
755
|
+
|
|
756
|
+
// Find end marker (case-insensitive to handle agent variations)
|
|
757
|
+
const endMarkerRegex = makeEndMarkerRegex(state.nonce);
|
|
758
|
+
const hasEndMarker = endMarkerRegex.test(output);
|
|
759
|
+
|
|
760
|
+
// Completion conditions (same as single-agent mode):
|
|
761
|
+
// 1. Must wait at least MIN_WAIT_MS
|
|
762
|
+
// 2. Must have end marker in output
|
|
763
|
+
// 3. Output must be stable for IDLE_THRESHOLD_MS (debounce)
|
|
764
|
+
if (elapsedMs < MIN_WAIT_MS || !hasEndMarker || idleMs < IDLE_THRESHOLD_MS) {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
674
767
|
|
|
675
768
|
// Extract response: get N lines before the agent's end marker
|
|
676
769
|
const responseLines = flags.lines ?? 100;
|
|
677
770
|
const lines = output.split('\n');
|
|
678
771
|
|
|
679
|
-
// Find
|
|
772
|
+
// Find end marker line (case-insensitive)
|
|
680
773
|
let endMarkerLineIndex = -1;
|
|
681
774
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
682
|
-
if (lines[i]
|
|
775
|
+
if (endMarkerRegex.test(lines[i])) {
|
|
683
776
|
endMarkerLineIndex = i;
|
|
684
777
|
break;
|
|
685
778
|
}
|
|
@@ -687,13 +780,25 @@ async function cmdTalkAllWait(
|
|
|
687
780
|
|
|
688
781
|
if (endMarkerLineIndex === -1) continue;
|
|
689
782
|
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
783
|
+
// Protocol: instruction describes the marker verbally but doesn't contain the literal string.
|
|
784
|
+
// So any occurrence of the literal marker is definitively from the agent.
|
|
785
|
+
//
|
|
786
|
+
// Try to anchor extraction to the instruction line (cleaner output when visible).
|
|
787
|
+
// Fall back to N lines before marker if instruction scrolled off.
|
|
788
|
+
let startLine: number;
|
|
789
|
+
const instructionLineIndex = lines.findIndex(
|
|
790
|
+
(line) =>
|
|
791
|
+
line.toLowerCase().includes(`response-end-${state.nonce.toLowerCase()}`) &&
|
|
792
|
+
!endMarkerRegex.test(line)
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
if (instructionLineIndex !== -1 && instructionLineIndex < endMarkerLineIndex) {
|
|
796
|
+
// Instruction visible: extract from after instruction to marker
|
|
797
|
+
startLine = instructionLineIndex + 1;
|
|
798
|
+
} else {
|
|
799
|
+
// Instruction scrolled off: extract N lines before marker
|
|
800
|
+
startLine = Math.max(0, endMarkerLineIndex - responseLines);
|
|
695
801
|
}
|
|
696
|
-
startLine = Math.max(startLine, endMarkerLineIndex - responseLines);
|
|
697
802
|
|
|
698
803
|
let response = lines.slice(startLine, endMarkerLineIndex).join('\n').trim();
|
|
699
804
|
// Clean Gemini CLI UI artifacts
|
|
@@ -702,7 +807,7 @@ async function cmdTalkAllWait(
|
|
|
702
807
|
}
|
|
703
808
|
state.response = response;
|
|
704
809
|
state.status = 'completed';
|
|
705
|
-
state.elapsedMs =
|
|
810
|
+
state.elapsedMs = elapsedMs;
|
|
706
811
|
clearActiveRequest(paths, state.agent, state.requestId);
|
|
707
812
|
|
|
708
813
|
if (!flags.json) {
|
package/src/config.test.ts
CHANGED
|
@@ -135,6 +135,51 @@ describe('resolvePaths', () => {
|
|
|
135
135
|
expect(paths.globalConfig).toBe('/custom/path/config.json');
|
|
136
136
|
expect(paths.stateFile).toBe('/custom/path/state.json');
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
it('searches up parent directories to find tmux-team.json', () => {
|
|
140
|
+
// Simulating: cwd is /projects/myapp/src/components
|
|
141
|
+
// tmux-team.json exists at /projects/myapp/tmux-team.json
|
|
142
|
+
const nestedCwd = '/projects/myapp/src/components';
|
|
143
|
+
const rootConfig = '/projects/myapp/tmux-team.json';
|
|
144
|
+
|
|
145
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
146
|
+
return p === rootConfig;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const paths = resolvePaths(nestedCwd);
|
|
150
|
+
|
|
151
|
+
// Should find the config in parent directory, not assume it's in cwd
|
|
152
|
+
expect(paths.localConfig).toBe(rootConfig);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('nearest tmux-team.json wins when multiple exist', () => {
|
|
156
|
+
// Simulating: cwd is /projects/myapp/packages/frontend
|
|
157
|
+
// tmux-team.json exists at both:
|
|
158
|
+
// /projects/myapp/tmux-team.json (monorepo root)
|
|
159
|
+
// /projects/myapp/packages/frontend/tmux-team.json (package-specific)
|
|
160
|
+
const nestedCwd = '/projects/myapp/packages/frontend';
|
|
161
|
+
const packageConfig = '/projects/myapp/packages/frontend/tmux-team.json';
|
|
162
|
+
const monorepoConfig = '/projects/myapp/tmux-team.json';
|
|
163
|
+
|
|
164
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
|
165
|
+
return p === packageConfig || p === monorepoConfig;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const paths = resolvePaths(nestedCwd);
|
|
169
|
+
|
|
170
|
+
// Nearest config should win (package-specific, not monorepo root)
|
|
171
|
+
expect(paths.localConfig).toBe(packageConfig);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('falls back to cwd when no tmux-team.json found in parents', () => {
|
|
175
|
+
// No tmux-team.json exists anywhere
|
|
176
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
177
|
+
|
|
178
|
+
const paths = resolvePaths(mockCwd);
|
|
179
|
+
|
|
180
|
+
// Should fall back to cwd/tmux-team.json (default behavior for init)
|
|
181
|
+
expect(paths.localConfig).toBe(path.join(mockCwd, 'tmux-team.json'));
|
|
182
|
+
});
|
|
138
183
|
});
|
|
139
184
|
|
|
140
185
|
describe('loadConfig', () => {
|
package/src/config.ts
CHANGED
|
@@ -80,12 +80,37 @@ export function resolveGlobalDir(): string {
|
|
|
80
80
|
return xdgPath;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Search up parent directories for a file (like how git finds .git/).
|
|
85
|
+
* Returns the path to the file if found, or null if not found.
|
|
86
|
+
*/
|
|
87
|
+
function findUpward(filename: string, startDir: string): string | null {
|
|
88
|
+
let dir = startDir;
|
|
89
|
+
while (true) {
|
|
90
|
+
const candidate = path.join(dir, filename);
|
|
91
|
+
if (fs.existsSync(candidate)) {
|
|
92
|
+
return candidate;
|
|
93
|
+
}
|
|
94
|
+
const parent = path.dirname(dir);
|
|
95
|
+
if (parent === dir) {
|
|
96
|
+
// Reached filesystem root
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
dir = parent;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
export function resolvePaths(cwd: string = process.cwd()): Paths {
|
|
84
104
|
const globalDir = resolveGlobalDir();
|
|
105
|
+
|
|
106
|
+
// Search up for local config (like .git discovery)
|
|
107
|
+
const localConfigPath =
|
|
108
|
+
findUpward(LOCAL_CONFIG_FILENAME, cwd) ?? path.join(cwd, LOCAL_CONFIG_FILENAME);
|
|
109
|
+
|
|
85
110
|
return {
|
|
86
111
|
globalDir,
|
|
87
112
|
globalConfig: path.join(globalDir, CONFIG_FILENAME),
|
|
88
|
-
localConfig:
|
|
113
|
+
localConfig: localConfigPath,
|
|
89
114
|
stateFile: path.join(globalDir, STATE_FILENAME),
|
|
90
115
|
};
|
|
91
116
|
}
|