tmux-team 3.2.1 → 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 CHANGED
@@ -1,25 +1,12 @@
1
1
  {
2
2
  "name": "tmux-team",
3
- "version": "3.2.1",
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 new end marker format
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
- // The end marker must appear TWICE: once in instruction, once from "agent"
461
- // New format: ---RESPONSE-END-NONCE---
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
- return `Hello\n\nWhen you finish responding, print this exact line:\n${endMarker}\n${response}\n${endMarker}`;
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
- // Return marker on second capture - must include instruction AND agent's end marker
479
+ // Extract nonce from instruction and return agent response with marker
475
480
  const sent = tmux.sends[0]?.message || '';
476
- const match = sent.match(END_MARKER_REGEX);
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
- expect(tmux.sends[0].message).toContain('When you finish responding, print this exact line:');
498
- expect(tmux.sends[0].message).toMatch(/---RESPONSE-END-[a-f0-9]+---/);
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 sent message and return matching marker
518
+ // Extract nonce from instruction and return agent response with marker
511
519
  const sent = tmux.sends[0]?.message || '';
512
- const match = sent.match(END_MARKER_REGEX);
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 markers in scrollback', async () => {
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 our instruction (with end marker), response, and agent's end marker
593
+ // Simulate scrollback with old content, then agent response with marker
586
594
  const sent = tmux.sends[0]?.message || '';
587
- const endMatch = sent.match(END_MARKER_REGEX);
588
- if (endMatch) {
589
- const endMarker = `---RESPONSE-END-${endMatch[1]}---`;
590
- // Must include end marker TWICE: once in instruction, once from "agent"
591
- return `${oldContent}\n\nMessage content here\n\nWhen you finish responding, print this exact line:\n${endMarker}\nNew response content\n\n${endMarker}`;
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(END_MARKER_REGEX);
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(END_MARKER_REGEX);
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(END_MARKER_REGEX);
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 times out
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.1, json: true },
772
+ flags: { wait: true, timeout: 0.5, json: true },
767
773
  config: {
768
774
  defaults: {
769
- timeout: 0.1,
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(END_MARKER_REGEX);
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 new request markers not complete yet
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 - must have TWO occurrences of new end marker
923
+ // Finally, new end marker appears from agent
925
924
  const sent = tmux.sends[0]?.message || '';
926
- const endMatch = sent.match(END_MARKER_REGEX);
927
- if (endMatch) {
928
- const newEndMarker = `---RESPONSE-END-${endMatch[1]}---`;
929
- // Old markers in scrollback + new instruction (with end marker) + response + agent's end marker
930
- return `Old question\nOld response\n${oldEndMarker}\n\nNew question asked\n\nWhen you finish responding, print this exact line:\n${newEndMarker}\nNew response\n\n${newEndMarker}`;
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
- // Response should be from the new markers, not triggered by old markers
955
- expect(output.response as string).not.toContain('Old response');
956
- expect(output.response as string).not.toContain('Old question');
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 endMatch = sent.match(END_MARKER_REGEX);
981
- if (endMatch) {
982
- // Must have TWO end markers: one in instruction, one from "agent"
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 `Hello\n\nWhen you finish responding, print this exact line:\n${endMarker}\n${response}\n${endMarker}`;
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 agent started responding', async () => {
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
- // Simulate agent started responding but didn't finish (only ONE end marker in instruction, no second from agent)
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
- const sent = tmux.sends[0]?.message || '';
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
- expect(output).toHaveProperty('partialResponse');
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 null partialResponse when nothing captured', async () => {
1097
+ it('returns scrollback as partialResponse when no instruction visible', async () => {
1102
1098
  const tmux = createMockTmux();
1103
1099
  const ui = createMockUI();
1104
1100
 
1105
- // Nothing meaningful in the capture
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
- expect(output.partialResponse).toBeNull();
1128
+ // Fallback captures last N lines even without instruction visible
1129
+ expect(output.partialResponse).toBe('random scrollback content');
1132
1130
  });
1133
1131
 
1134
- it('captures partialResponse in broadcast timeout', async () => {
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(END_MARKER_REGEX);
1138
+ const match = msg.match(INSTRUCTION_NONCE_REGEX);
1141
1139
  if (match) markersByPane[pane] = match[1];
1142
1140
  };
1143
1141
 
1144
- // codex completes, gemini times out with partial response
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: two end markers
1150
- return `Msg\n\nWhen you finish responding, print this exact line:\n${endMarker}\nResponse\n${endMarker}`;
1147
+ // Complete response with end marker (only ONE marker in new protocol)
1148
+ return `Response\n${endMarker}`;
1151
1149
  }
1152
- // gemini has partial response - only one end marker (in instruction)
1153
- const nonce = markersByPane['10.2'];
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.1, json: true },
1159
+ flags: { wait: true, timeout: 0.5, json: true },
1164
1160
  config: {
1165
1161
  defaults: {
1166
- timeout: 0.1,
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
- expect(geminiResult?.partialResponse).toContain('Partial gemini output');
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
- // The end marker must appear TWICE: once in instruction, once from "agent"
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 `Message\n\nWhen you finish responding, print this exact line:\n${endMarker}\n${response}\n${endMarker}`;
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
- const endMatch = sent.match(END_MARKER_REGEX);
1235
- if (endMatch) {
1236
- return mockResponse(endMatch[1], 'Response');
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
- expect(sent).toMatch(/---RESPONSE-END-[a-f0-9]+---/);
1253
- expect(sent).toContain('When you finish responding, print this exact line:');
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 between two end markers', async () => {
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
- const endMatch = sent.match(END_MARKER_REGEX);
1263
- if (endMatch) {
1264
- const endMarker = `---RESPONSE-END-${endMatch[1]}---`;
1265
- // Simulate scrollback with old content, instruction, response, and agent's end marker
1266
- return `Old garbage\nMore old stuff\nMessage\n\nWhen you finish responding, print this exact line:\n${endMarker}\nThis is the actual response\n\n${endMarker}\nContent after marker`;
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
- const endMatch = sent.match(END_MARKER_REGEX);
1298
- if (endMatch) {
1299
- return mockResponse(endMatch[1], multilineResponse);
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 between markers', async () => {
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
- const endMatch = sent.match(END_MARKER_REGEX);
1326
- if (endMatch) {
1327
- const endMarker = `---RESPONSE-END-${endMatch[1]}---`;
1328
- // Agent printed end marker immediately with no content
1329
- return `Message here\n\nWhen you finish responding, print this exact line:\n${endMarker}\n${endMarker}`;
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 second marker appears (not triggered by instruction alone)', async () => {
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
- const endMatch = sent.match(END_MARKER_REGEX);
1358
- if (endMatch) {
1359
- const endMarker = `---RESPONSE-END-${endMatch[1]}---`;
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
- // Only ONE end marker (in instruction) - should keep waiting
1362
- return `Message\n\nWhen you finish responding, print this exact line:\n${endMarker}\nAgent is still thinking...`;
1368
+ // No marker yet - agent is still thinking
1369
+ return `Agent is still thinking...`;
1363
1370
  }
1364
- // Finally, agent prints second marker
1365
- return `Message\n\nWhen you finish responding, print this exact line:\n${endMarker}\nActual response\n${endMarker}`;
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 markers at edges', async () => {
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
- const endMatch = sent.match(END_MARKER_REGEX);
1397
- if (endMatch) {
1398
- const endMarker = `---RESPONSE-END-${endMatch[1]}---`;
1399
- // TWO end markers: one in instruction, one from "agent" response
1400
- return `${lotsOfContent}\nMessage\n\nWhen you finish responding, print this exact line:\n${endMarker}\n\nThe actual response\n\n${endMarker}`;
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
  });
@@ -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
- * Looks for first occurrence of end marker (in our instruction) and extracts content after it.
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
- if (firstMarkerLineIndex === -1) return null;
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\nWhen you finish responding, print this exact line:\n${endMarker}`;
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
- 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
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 hasEndMarker = output.includes(endMarker);
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].includes(endMarker)) {
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
- // 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);
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\nWhen you finish responding, print this exact line:\n${endMarker}`;
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: Date.now(),
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
- const elapsedSeconds = (Date.now() - startedAt) / 1000;
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
- if (elapsedSeconds >= timeoutSeconds) {
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(timeoutSeconds)}s`;
601
- state.elapsedMs = Math.floor(elapsedSeconds * 1000);
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 (err) {
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(elapsedSeconds)}s)`
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(elapsedSeconds)}s elapsed)`
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() - startedAt;
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
- // Find end marker
663
- const firstEndMarkerIndex = output.indexOf(state.endMarker);
664
- const lastEndMarkerIndex = output.lastIndexOf(state.endMarker);
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;
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
- if (!twoMarkers && !followedByUI) continue;
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 the line with the agent's end marker (last occurrence)
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].includes(state.endMarker)) {
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
- // 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;
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 = Date.now() - startedAt;
810
+ state.elapsedMs = elapsedMs;
706
811
  clearActiveRequest(paths, state.agent, state.requestId);
707
812
 
708
813
  if (!flags.json) {