tmux-team 3.1.0 → 3.2.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-team",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
4
4
  "description": "CLI tool for AI agent collaboration in tmux - manage cross-pane communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1224,12 +1224,6 @@ describe('cmdTalk - end marker detection', () => {
1224
1224
  return `Message\n\nWhen you finish responding, print this exact line:\n${endMarker}\n${response}\n${endMarker}`;
1225
1225
  }
1226
1226
 
1227
- // Helper: generate mock capture where instruction scrolled off, detected via UI
1228
- function mockResponseWithUI(nonce: string, response: string): string {
1229
- const endMarker = `---RESPONSE-END-${nonce}---`;
1230
- return `${response}\n${endMarker}\n\n╭───────────────────╮\n│ > Type message │\n╰───────────────────╯`;
1231
- }
1232
-
1233
1227
  it('includes end marker in sent message', async () => {
1234
1228
  const tmux = createMockTmux();
1235
1229
  const ui = createMockUI();
@@ -1289,35 +1283,6 @@ describe('cmdTalk - end marker detection', () => {
1289
1283
  expect(output.response).toContain('actual response');
1290
1284
  });
1291
1285
 
1292
- it('detects completion via UI elements when instruction scrolled off', async () => {
1293
- const tmux = createMockTmux();
1294
- const ui = createMockUI();
1295
-
1296
- tmux.capture = () => {
1297
- const sent = tmux.sends[0]?.message || '';
1298
- const endMatch = sent.match(END_MARKER_REGEX);
1299
- if (endMatch) {
1300
- // Only ONE end marker visible (agent's), followed by CLI UI
1301
- return mockResponseWithUI(endMatch[1], 'Response from agent');
1302
- }
1303
- return '';
1304
- };
1305
-
1306
- const ctx = createContext({
1307
- tmux,
1308
- ui,
1309
- paths: createTestPaths(testDir),
1310
- flags: { wait: true, json: true, timeout: 5 },
1311
- config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100, preambleEvery: 3 } },
1312
- });
1313
-
1314
- await cmdTalk(ctx, 'claude', 'Test');
1315
-
1316
- const output = ui.jsonOutput[0] as Record<string, unknown>;
1317
- expect(output.status).toBe('completed');
1318
- expect(output.response).toContain('Response from agent');
1319
- });
1320
-
1321
1286
  it('handles multiline responses correctly', async () => {
1322
1287
  const tmux = createMockTmux();
1323
1288
  const ui = createMockUI();
@@ -278,9 +278,10 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
278
278
  let lastNonTtyLogAt = 0;
279
279
  const isTTY = process.stdout.isTTY && !flags.json;
280
280
 
281
- // Idle detection for Gemini (doesn't print end markers)
282
- const GEMINI_IDLE_THRESHOLD_MS = 5000; // 5 seconds of no output change = complete
283
- let lastOutputHash = '';
281
+ // Debounce detection: wait for output to stabilize
282
+ const MIN_WAIT_MS = 3000; // Wait at least 3 seconds before detecting completion
283
+ const IDLE_THRESHOLD_MS = 3000; // Content unchanged for 3 seconds = complete
284
+ let lastOutput = '';
284
285
  let lastOutputChangeAt = Date.now();
285
286
 
286
287
  const onSigint = (): void => {
@@ -402,40 +403,40 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
402
403
  console.error(`[DEBUG ${elapsedSec}s] Output tail:\n${output.slice(-300)}`);
403
404
  }
404
405
 
405
- // Find end marker - agent prints it when done
406
- // For long responses, our instruction may scroll off, so we check:
407
- // 1. Two occurrences (instruction + agent), OR
408
- // 2. One occurrence that's followed by only UI elements (agent printed it)
409
- const firstEndMarkerIndex = output.indexOf(endMarker);
410
- const lastEndMarkerIndex = output.lastIndexOf(endMarker);
411
-
412
- if (firstEndMarkerIndex === -1) {
413
- // No marker at all - still waiting
414
- continue;
406
+ // Track output changes for debounce detection
407
+ if (output !== lastOutput) {
408
+ lastOutput = output;
409
+ lastOutputChangeAt = Date.now();
415
410
  }
416
411
 
417
- // Check if marker is from agent (not just in our instruction)
418
- // Either: two markers (instruction scrolled, agent printed), or
419
- // one marker followed by CLI UI elements (instruction scrolled off)
420
- const afterMarker = output.slice(lastEndMarkerIndex + endMarker.length);
421
- const followedByUI = afterMarker.includes('╭') || afterMarker.includes('context left');
422
- const twoMarkers = firstEndMarkerIndex !== lastEndMarkerIndex;
412
+ const elapsedMs = Date.now() - startedAt;
413
+ const idleMs = Date.now() - lastOutputChangeAt;
414
+
415
+ // Find end marker
416
+ const hasEndMarker = output.includes(endMarker);
423
417
 
424
- if (!twoMarkers && !followedByUI) {
425
- // Marker is still in our instruction, agent hasn't responded yet
418
+ // Completion conditions:
419
+ // 1. Must wait at least MIN_WAIT_MS
420
+ // 2. Must have end marker in output
421
+ // 3. Output must be stable for IDLE_THRESHOLD_MS (debounce)
422
+ if (elapsedMs < MIN_WAIT_MS || !hasEndMarker || idleMs < IDLE_THRESHOLD_MS) {
423
+ if (flags.debug && hasEndMarker) {
424
+ console.error(
425
+ `[DEBUG] Marker found, waiting for debounce (elapsed: ${elapsedMs}ms, idle: ${idleMs}ms)`
426
+ );
427
+ }
426
428
  continue;
427
429
  }
428
430
 
429
- if (flags.debug)
430
- console.error(
431
- `[DEBUG] Agent completed (twoMarkers: ${twoMarkers}, followedByUI: ${followedByUI})`
432
- );
431
+ if (flags.debug) {
432
+ console.error(`[DEBUG] Agent completed (elapsed: ${elapsedMs}ms, idle: ${idleMs}ms)`);
433
+ }
433
434
 
434
- // Extract response: get N lines before the agent's end marker
435
+ // Extract response: get N lines before the end marker
435
436
  const responseLines = flags.lines ?? 100;
436
437
  const lines = output.split('\n');
437
438
 
438
- // Find the line with the agent's end marker (last occurrence)
439
+ // Find the line with the end marker (last occurrence = agent's marker)
439
440
  let endMarkerLineIndex = -1;
440
441
  for (let i = lines.length - 1; i >= 0; i--) {
441
442
  if (lines[i].includes(endMarker)) {
@@ -446,13 +447,9 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
446
447
 
447
448
  if (endMarkerLineIndex === -1) continue;
448
449
 
449
- // Determine where response starts
450
- let startLine = 0;
451
- if (firstEndMarkerIndex !== lastEndMarkerIndex) {
452
- // Two markers - find line after first marker (instruction)
453
- const firstMarkerLineIndex = lines.findIndex((line) => line.includes(endMarker));
454
- startLine = firstMarkerLineIndex + 1;
455
- }
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;
456
453
  // Limit to N lines before end marker
457
454
  startLine = Math.max(startLine, endMarkerLineIndex - responseLines);
458
455
 
@@ -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: path.join(cwd, LOCAL_CONFIG_FILENAME),
113
+ localConfig: localConfigPath,
89
114
  stateFile: path.join(globalDir, STATE_FILENAME),
90
115
  };
91
116
  }