tmux-team 2.2.0 → 3.0.0-alpha.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.
@@ -13,7 +13,7 @@ import {
13
13
  setActiveRequest,
14
14
  incrementPreambleCounter,
15
15
  } from '../state.js';
16
- import { resolveActor } from '../pm/permissions.js';
16
+ import { resolveActor } from '../identity.js';
17
17
 
18
18
  function sleepMs(ms: number): Promise<void> {
19
19
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -32,6 +32,41 @@ function renderWaitLine(agent: string, elapsedSeconds: number): string {
32
32
  return `⏳ Waiting for ${agent}... (${s}s)`;
33
33
  }
34
34
 
35
+ /**
36
+ * Extract partial response from output when end marker is not found.
37
+ * Used to capture whatever the agent wrote before timeout.
38
+ */
39
+ function extractPartialResponse(
40
+ output: string,
41
+ startMarker: string,
42
+ _endMarker: string
43
+ ): string | null {
44
+ // Look for instruction end pattern `}]` (the instruction ends with `{end}]`)
45
+ const instructionEndPattern = '}]';
46
+ const instructionEndIndex = output.lastIndexOf(instructionEndPattern);
47
+
48
+ let responseStart = 0;
49
+ if (instructionEndIndex !== -1) {
50
+ // Find the first newline after the instruction's closing `}]`
51
+ responseStart = output.indexOf('\n', instructionEndIndex + 2);
52
+ if (responseStart !== -1) responseStart += 1;
53
+ else responseStart = instructionEndIndex + 2;
54
+ } else {
55
+ // Fallback: try to find newline after start marker
56
+ const startMarkerIndex = output.lastIndexOf(startMarker);
57
+ if (startMarkerIndex !== -1) {
58
+ responseStart = output.indexOf('\n', startMarkerIndex);
59
+ if (responseStart !== -1) responseStart += 1;
60
+ else return null; // Can't find response start
61
+ } else {
62
+ return null; // Start marker not found
63
+ }
64
+ }
65
+
66
+ const partial = output.slice(responseStart).trim();
67
+ return partial || null;
68
+ }
69
+
35
70
  // ─────────────────────────────────────────────────────────────
36
71
  // Types for broadcast wait mode
37
72
  // ─────────────────────────────────────────────────────────────
@@ -41,10 +76,11 @@ interface AgentWaitState {
41
76
  pane: string;
42
77
  requestId: string;
43
78
  nonce: string;
44
- marker: string;
45
- baseline: string;
79
+ startMarker: string;
80
+ endMarker: string;
46
81
  status: 'pending' | 'completed' | 'timeout' | 'error';
47
82
  response?: string;
83
+ partialResponse?: string | null;
48
84
  error?: string;
49
85
  elapsedMs?: number;
50
86
  }
@@ -212,11 +248,12 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
212
248
 
213
249
  const requestId = makeRequestId();
214
250
  const nonce = makeNonce();
215
- const marker = `{tmux-team-end:${nonce}}`;
251
+ const startMarker = `{tmux-team-start:${nonce}}`;
252
+ const endMarker = `{tmux-team-end:${nonce}}`;
216
253
 
217
- // Build message with preamble, then append nonce instruction
254
+ // Build message with preamble, then wrap with start/end markers
218
255
  const messageWithPreamble = buildMessage(message, target, ctx);
219
- const fullMessage = `${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${marker}]`;
256
+ const fullMessage = `${startMarker}\n${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${endMarker}]`;
220
257
 
221
258
  // Best-effort cleanup and soft-lock warning
222
259
  const state = cleanupState(ctx.paths, 60 * 60); // 1 hour TTL
@@ -227,14 +264,6 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
227
264
  );
228
265
  }
229
266
 
230
- let baseline = '';
231
- try {
232
- baseline = tmux.capture(pane, captureLines);
233
- } catch {
234
- ui.error(`Failed to capture pane ${pane}. Is tmux running?`);
235
- exit(ExitCodes.ERROR);
236
- }
237
-
238
267
  setActiveRequest(ctx.paths, target, { id: requestId, nonce, pane, startedAtMs: Date.now() });
239
268
 
240
269
  const startedAt = Date.now();
@@ -258,8 +287,22 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
258
287
  const elapsedSeconds = (Date.now() - startedAt) / 1000;
259
288
  if (elapsedSeconds >= timeoutSeconds) {
260
289
  clearActiveRequest(ctx.paths, target, requestId);
290
+
291
+ // Capture partial response on timeout
292
+ let partialResponse: string | null = null;
293
+ try {
294
+ const output = tmux.capture(pane, captureLines);
295
+ const extracted = extractPartialResponse(output, startMarker, endMarker);
296
+ if (extracted) partialResponse = extracted;
297
+ } catch {
298
+ // Ignore capture errors on timeout
299
+ }
300
+
301
+ if (isTTY) {
302
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
303
+ }
304
+
261
305
  if (flags.json) {
262
- // Single JSON output with error field (don't call ui.error separately)
263
306
  ui.json({
264
307
  target,
265
308
  pane,
@@ -267,14 +310,19 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
267
310
  error: `Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s`,
268
311
  requestId,
269
312
  nonce,
270
- marker,
313
+ startMarker,
314
+ endMarker,
315
+ partialResponse,
271
316
  });
272
317
  exit(ExitCodes.TIMEOUT);
273
318
  }
274
- if (isTTY) {
275
- process.stdout.write('\r' + ' '.repeat(80) + '\r');
276
- }
319
+
277
320
  ui.error(`Timed out waiting for ${target} after ${Math.floor(timeoutSeconds)}s.`);
321
+ if (partialResponse) {
322
+ console.log();
323
+ console.log(colors.yellow(`─── Partial response from ${target} (${pane}) ───`));
324
+ console.log(partialResponse);
325
+ }
278
326
  exit(ExitCodes.TIMEOUT);
279
327
  }
280
328
 
@@ -283,7 +331,7 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
283
331
  process.stdout.write('\r' + renderWaitLine(target, elapsedSeconds));
284
332
  } else {
285
333
  const now = Date.now();
286
- if (now - lastNonTtyLogAt >= 5000) {
334
+ if (now - lastNonTtyLogAt >= 30000) {
287
335
  lastNonTtyLogAt = now;
288
336
  console.error(
289
337
  `[tmux-team] Waiting for ${target} (${Math.floor(elapsedSeconds)}s elapsed)`
@@ -303,16 +351,39 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
303
351
  exit(ExitCodes.ERROR);
304
352
  }
305
353
 
306
- const markerIndex = output.indexOf(marker);
307
- if (markerIndex === -1) continue;
308
-
309
- let startIndex = 0;
310
- const baselineIndex = baseline ? output.lastIndexOf(baseline) : -1;
311
- if (baselineIndex !== -1) {
312
- startIndex = baselineIndex + baseline.length;
354
+ // Find end marker - it appears once in our instruction and again when agent prints it
355
+ // We need TWO occurrences: one in instruction + one from agent = complete
356
+ // Only ONE occurrence means it's just in instruction = still waiting
357
+ const firstEndMarkerIndex = output.indexOf(endMarker);
358
+ const lastEndMarkerIndex = output.lastIndexOf(endMarker);
359
+ if (firstEndMarkerIndex === -1 || firstEndMarkerIndex === lastEndMarkerIndex) {
360
+ // No marker or only one (in instruction) - still waiting
361
+ continue;
362
+ }
363
+ const endMarkerIndex = lastEndMarkerIndex;
364
+
365
+ // Find the end of our instruction by looking for `}]` pattern (the instruction ends with `{end}]`)
366
+ // This is more reliable than looking for newline after start marker because
367
+ // the message may be word-wrapped across multiple visual lines
368
+ let responseStart = 0;
369
+ const instructionEndPattern = '}]';
370
+ const instructionEndIndex = output.lastIndexOf(instructionEndPattern, endMarkerIndex);
371
+ if (instructionEndIndex !== -1) {
372
+ // Find the first newline after the instruction's closing `}]`
373
+ responseStart = output.indexOf('\n', instructionEndIndex + 2);
374
+ if (responseStart !== -1) responseStart += 1;
375
+ else responseStart = instructionEndIndex + 2;
376
+ } else {
377
+ // Fallback: if no `}]` found, try to find newline after start marker
378
+ const startMarkerIndex = output.lastIndexOf(startMarker);
379
+ if (startMarkerIndex !== -1) {
380
+ responseStart = output.indexOf('\n', startMarkerIndex);
381
+ if (responseStart !== -1) responseStart += 1;
382
+ else responseStart = startMarkerIndex + startMarker.length;
383
+ }
313
384
  }
314
385
 
315
- const response = output.slice(startIndex, markerIndex).trim();
386
+ const response = output.slice(responseStart, endMarkerIndex).trim();
316
387
 
317
388
  if (!flags.json && isTTY) {
318
389
  process.stdout.write('\r' + ' '.repeat(80) + '\r');
@@ -323,7 +394,7 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
323
394
 
324
395
  clearActiveRequest(ctx.paths, target, requestId);
325
396
 
326
- const result: WaitResult = { requestId, nonce, marker, response };
397
+ const result: WaitResult = { requestId, nonce, startMarker, endMarker, response };
327
398
  if (flags.json) {
328
399
  ui.json({ target, pane, status: 'completed', ...result });
329
400
  } else {
@@ -367,35 +438,16 @@ async function cmdTalkAllWait(
367
438
  );
368
439
  }
369
440
 
370
- // Phase 1: Send messages to all agents and capture baselines
441
+ // Phase 1: Send messages to all agents with start/end markers
371
442
  for (const [name, data] of targetAgents) {
372
443
  const requestId = makeRequestId();
373
444
  const nonce = makeNonce(); // Unique nonce per agent (#19)
374
- const marker = `{tmux-team-end:${nonce}}`;
445
+ const startMarker = `{tmux-team-start:${nonce}}`;
446
+ const endMarker = `{tmux-team-end:${nonce}}`;
375
447
 
376
- let baseline = '';
377
- try {
378
- baseline = tmux.capture(data.pane, captureLines);
379
- } catch {
380
- agentStates.push({
381
- agent: name,
382
- pane: data.pane,
383
- requestId,
384
- nonce,
385
- marker,
386
- baseline: '',
387
- status: 'error',
388
- error: `Failed to capture pane ${data.pane}`,
389
- });
390
- if (!flags.json) {
391
- ui.warn(`Failed to capture ${name} (${data.pane})`);
392
- }
393
- continue;
394
- }
395
-
396
- // Build and send message
448
+ // Build and send message with start/end markers
397
449
  const messageWithPreamble = buildMessage(message, name, ctx);
398
- const fullMessage = `${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${marker}]`;
450
+ const fullMessage = `${startMarker}\n${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${endMarker}]`;
399
451
  const msg = name === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
400
452
 
401
453
  try {
@@ -411,8 +463,8 @@ async function cmdTalkAllWait(
411
463
  pane: data.pane,
412
464
  requestId,
413
465
  nonce,
414
- marker,
415
- baseline,
466
+ startMarker,
467
+ endMarker,
416
468
  status: 'pending',
417
469
  });
418
470
  if (!flags.json) {
@@ -424,8 +476,8 @@ async function cmdTalkAllWait(
424
476
  pane: data.pane,
425
477
  requestId,
426
478
  nonce,
427
- marker,
428
- baseline,
479
+ startMarker,
480
+ endMarker,
429
481
  status: 'error',
430
482
  error: `Failed to send to pane ${data.pane}`,
431
483
  });
@@ -476,6 +528,16 @@ async function cmdTalkAllWait(
476
528
  state.status = 'timeout';
477
529
  state.error = `Timed out after ${Math.floor(timeoutSeconds)}s`;
478
530
  state.elapsedMs = Math.floor(elapsedSeconds * 1000);
531
+
532
+ // Capture partial response on timeout
533
+ try {
534
+ const output = tmux.capture(state.pane, captureLines);
535
+ const extracted = extractPartialResponse(output, state.startMarker, state.endMarker);
536
+ if (extracted) state.partialResponse = extracted;
537
+ } catch {
538
+ // Ignore capture errors on timeout
539
+ }
540
+
479
541
  clearActiveRequest(paths, state.agent, state.requestId);
480
542
  if (!flags.json) {
481
543
  console.log(
@@ -491,7 +553,7 @@ async function cmdTalkAllWait(
491
553
  // Progress logging (non-TTY)
492
554
  if (!flags.json && !isTTY) {
493
555
  const now = Date.now();
494
- if (now - lastLogAt >= 5000) {
556
+ if (now - lastLogAt >= 30000) {
495
557
  lastLogAt = now;
496
558
  const pending = pendingAgents()
497
559
  .map((s) => s.agent)
@@ -520,17 +582,39 @@ async function cmdTalkAllWait(
520
582
  continue;
521
583
  }
522
584
 
523
- const markerIndex = output.indexOf(state.marker);
524
- if (markerIndex === -1) continue;
525
-
526
- // Found marker - extract response
527
- let startIndex = 0;
528
- const baselineIndex = state.baseline ? output.lastIndexOf(state.baseline) : -1;
529
- if (baselineIndex !== -1) {
530
- startIndex = baselineIndex + state.baseline.length;
585
+ // Find end marker - it appears once in our instruction and again when agent prints it
586
+ // We need TWO occurrences: one in instruction + one from agent = complete
587
+ // Only ONE occurrence means it's just in instruction = still waiting
588
+ const firstEndMarkerIndex = output.indexOf(state.endMarker);
589
+ const lastEndMarkerIndex = output.lastIndexOf(state.endMarker);
590
+ if (firstEndMarkerIndex === -1 || firstEndMarkerIndex === lastEndMarkerIndex) {
591
+ // No marker or only one (in instruction) - still waiting
592
+ continue;
593
+ }
594
+ const endMarkerIndex = lastEndMarkerIndex;
595
+
596
+ // Find the end of our instruction by looking for `}]` pattern (the instruction ends with `{end}]`)
597
+ // This is more reliable than looking for newline after start marker because
598
+ // the message may be word-wrapped across multiple visual lines
599
+ let responseStart = 0;
600
+ const instructionEndPattern = '}]';
601
+ const instructionEndIndex = output.lastIndexOf(instructionEndPattern, endMarkerIndex);
602
+ if (instructionEndIndex !== -1) {
603
+ // Find the first newline after the instruction's closing `}]`
604
+ responseStart = output.indexOf('\n', instructionEndIndex + 2);
605
+ if (responseStart !== -1) responseStart += 1;
606
+ else responseStart = instructionEndIndex + 2;
607
+ } else {
608
+ // Fallback: if no `}]` found, try to find newline after start marker
609
+ const startMarkerIndex = output.lastIndexOf(state.startMarker);
610
+ if (startMarkerIndex !== -1) {
611
+ responseStart = output.indexOf('\n', startMarkerIndex);
612
+ if (responseStart !== -1) responseStart += 1;
613
+ else responseStart = startMarkerIndex + state.startMarker.length;
614
+ }
531
615
  }
532
616
 
533
- state.response = output.slice(startIndex, markerIndex).trim();
617
+ state.response = output.slice(responseStart, endMarkerIndex).trim();
534
618
  state.status = 'completed';
535
619
  state.elapsedMs = Date.now() - startedAt;
536
620
  clearActiveRequest(paths, state.agent, state.requestId);
@@ -592,10 +676,11 @@ function outputBroadcastResults(
592
676
  pane: s.pane,
593
677
  requestId: s.requestId,
594
678
  nonce: s.nonce,
595
- marker: s.marker,
596
- baseline: '', // Don't include baseline in output
679
+ startMarker: s.startMarker,
680
+ endMarker: s.endMarker,
597
681
  status: s.status,
598
682
  response: s.response,
683
+ partialResponse: s.partialResponse,
599
684
  error: s.error,
600
685
  elapsedMs: s.elapsedMs,
601
686
  })),
@@ -617,6 +702,10 @@ function outputBroadcastResults(
617
702
  console.log(colors.cyan(`─── Response from ${state.agent} (${state.pane}) ───`));
618
703
  console.log(state.response);
619
704
  console.log();
705
+ } else if (state.status === 'timeout' && state.partialResponse) {
706
+ console.log(colors.yellow(`─── Partial response from ${state.agent} (${state.pane}) ───`));
707
+ console.log(state.partialResponse);
708
+ console.log();
620
709
  }
621
710
  }
622
711
  }
@@ -161,7 +161,6 @@ describe('loadConfig', () => {
161
161
  expect(config.defaults.timeout).toBe(180);
162
162
  expect(config.defaults.pollInterval).toBe(1);
163
163
  expect(config.defaults.captureLines).toBe(100);
164
- expect(config.defaults.hideOrphanTasks).toBe(false);
165
164
  expect(config.agents).toEqual({});
166
165
  expect(config.paneRegistry).toEqual({});
167
166
  });
package/src/config.ts CHANGED
@@ -27,7 +27,6 @@ const DEFAULT_CONFIG: GlobalConfig = {
27
27
  pollInterval: 1,
28
28
  captureLines: 100,
29
29
  preambleEvery: 3, // inject preamble every 3 messages
30
- hideOrphanTasks: false, // hide tasks without milestone in list
31
30
  },
32
31
  };
33
32
 
@@ -0,0 +1,89 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Identity resolution - determine current agent from tmux pane
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import { execSync } from 'child_process';
6
+ import type { PaneEntry } from './types.js';
7
+
8
+ export interface ActorResolution {
9
+ actor: string;
10
+ source: 'pane' | 'env' | 'default';
11
+ warning?: string;
12
+ }
13
+
14
+ /**
15
+ * Get current tmux pane ID (e.g., "1.0").
16
+ */
17
+ function getCurrentPane(): string | null {
18
+ if (!process.env.TMUX) {
19
+ return null;
20
+ }
21
+
22
+ const tmuxPane = process.env.TMUX_PANE;
23
+ if (!tmuxPane) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const result = execSync(
29
+ `tmux display-message -p -t "${tmuxPane}" '#{window_index}.#{pane_index}'`,
30
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
31
+ );
32
+ return result.trim();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Find agent name by pane ID.
40
+ */
41
+ function findAgentByPane(paneRegistry: Record<string, PaneEntry>, paneId: string): string | null {
42
+ for (const [agentName, entry] of Object.entries(paneRegistry)) {
43
+ if (entry.pane === paneId) {
44
+ return agentName;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Resolve current actor using pane registry as primary source.
52
+ */
53
+ export function resolveActor(paneRegistry: Record<string, PaneEntry>): ActorResolution {
54
+ const envActor = process.env.TMT_AGENT_NAME || process.env.TMUX_TEAM_ACTOR;
55
+ const currentPane = getCurrentPane();
56
+
57
+ // Not in tmux - use env var or default to human
58
+ if (!currentPane) {
59
+ if (envActor) {
60
+ return { actor: envActor, source: 'env' };
61
+ }
62
+ return { actor: 'human', source: 'default' };
63
+ }
64
+
65
+ // In tmux - look up pane in registry
66
+ const paneAgent = findAgentByPane(paneRegistry, currentPane);
67
+
68
+ if (paneAgent) {
69
+ if (envActor && envActor !== paneAgent) {
70
+ return {
71
+ actor: paneAgent,
72
+ source: 'pane',
73
+ warning: `⚠️ Identity mismatch: TMT_AGENT_NAME="${envActor}" but pane ${currentPane} is registered to "${paneAgent}". Using pane identity.`,
74
+ };
75
+ }
76
+ return { actor: paneAgent, source: 'pane' };
77
+ }
78
+
79
+ // Pane not in registry
80
+ if (envActor) {
81
+ return {
82
+ actor: envActor,
83
+ source: 'env',
84
+ warning: `⚠️ Unregistered pane: pane ${currentPane} is not in registry. Using TMT_AGENT_NAME="${envActor}".`,
85
+ };
86
+ }
87
+
88
+ return { actor: 'human', source: 'default' };
89
+ }
package/src/types.ts CHANGED
@@ -19,7 +19,6 @@ export interface ConfigDefaults {
19
19
  pollInterval: number; // seconds
20
20
  captureLines: number;
21
21
  preambleEvery: number; // inject preamble every N messages (default: 3)
22
- hideOrphanTasks: boolean; // hide tasks without milestone in list (default: false)
23
22
  }
24
23
 
25
24
  export interface GlobalConfig {
@@ -86,7 +85,8 @@ export interface Tmux {
86
85
  export interface WaitResult {
87
86
  requestId: string;
88
87
  nonce: string;
89
- marker: string;
88
+ startMarker: string;
89
+ endMarker: string;
90
90
  response: string;
91
91
  }
92
92
 
package/src/version.ts CHANGED
@@ -14,7 +14,7 @@ function getVersion(): string {
14
14
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
15
15
  return pkg.version;
16
16
  } catch {
17
- return '2.0.0';
17
+ return '3.0.0';
18
18
  }
19
19
  }
20
20