tmux-team 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,8 @@ import { cmdConfig } from './config.js';
17
17
  import { cmdCompletion } from './completion.js';
18
18
  import { cmdHelp } from './help.js';
19
19
  import { cmdLearn } from './learn.js';
20
+ import { cmdMigrate } from './migrate.js';
21
+ import { cmdTeam } from './team.js';
20
22
 
21
23
  function createMockUI(): UI & { jsonCalls: unknown[] } {
22
24
  return {
@@ -38,6 +40,13 @@ function createMockTmux(): Tmux {
38
40
  capture: vi.fn(() => 'captured'),
39
41
  listPanes: vi.fn(() => []),
40
42
  getCurrentPaneId: vi.fn(() => null),
43
+ resolvePaneTarget: vi.fn((target: string) => target),
44
+ getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
45
+ setAgentRegistration: vi.fn(),
46
+ clearAgentRegistration: vi.fn(() => false),
47
+ listTeams: vi.fn(() => ({})),
48
+ listTeamPanes: vi.fn(() => []),
49
+ removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
41
50
  };
42
51
  }
43
52
 
@@ -54,7 +63,14 @@ function createCtx(
54
63
  const baseConfig: ResolvedConfig = {
55
64
  mode: 'polling',
56
65
  preambleMode: 'always',
57
- defaults: { timeout: 180, pollInterval: 1, captureLines: 100, maxCaptureLines: 2000, preambleEvery: 3, pasteEnterDelayMs: 500 },
66
+ defaults: {
67
+ timeout: 180,
68
+ pollInterval: 1,
69
+ captureLines: 100,
70
+ maxCaptureLines: 2000,
71
+ preambleEvery: 3,
72
+ pasteEnterDelayMs: 500,
73
+ },
58
74
  agents: {},
59
75
  paneRegistry: {},
60
76
  ...overrides?.config,
@@ -107,12 +123,14 @@ describe('basic commands', () => {
107
123
  expect((ctx.ui as any).jsonCalls[0]).toMatchObject({ created: ctx.paths.localConfig });
108
124
  });
109
125
 
110
- it('cmdAdd creates config if missing and writes new agent', () => {
126
+ it('cmdAdd writes new agent to tmux metadata', () => {
111
127
  const ctx = createCtx(testDir);
112
128
  cmdAdd(ctx, 'codex', '1.1', 'review');
113
- const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
114
- expect(saved.codex.pane).toBe('1.1');
115
- expect(saved.codex.remark).toBe('review');
129
+ expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
130
+ '1.1',
131
+ expect.objectContaining({ type: 'workspace' }),
132
+ { name: 'codex', remark: 'review' }
133
+ );
116
134
  });
117
135
 
118
136
  it('cmdAdd errors if agent exists', () => {
@@ -125,9 +143,11 @@ describe('basic commands', () => {
125
143
  const ctx = createCtx(testDir);
126
144
  (ctx.tmux.getCurrentPaneId as ReturnType<typeof vi.fn>).mockReturnValue('%5');
127
145
  cmdThis(ctx, 'myagent', 'test remark');
128
- const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
129
- expect(saved.myagent.pane).toBe('%5');
130
- expect(saved.myagent.remark).toBe('test remark');
146
+ expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
147
+ '%5',
148
+ expect.objectContaining({ type: 'workspace' }),
149
+ { name: 'myagent', remark: 'test remark' }
150
+ );
131
151
  });
132
152
 
133
153
  it('cmdThis errors when not in tmux', () => {
@@ -166,22 +186,32 @@ describe('basic commands', () => {
166
186
  });
167
187
  fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ codex: { pane: '1.1' } }, null, 2));
168
188
  cmdRemove(ctx, 'codex');
169
- expect((ctx.ui as any).jsonCalls).toEqual([{ removed: 'codex' }]);
189
+ expect((ctx.ui as any).jsonCalls).toEqual([{ removed: 'codex', source: 'legacy' }]);
170
190
  });
171
191
 
172
- it('cmdUpdate updates pane and remark; creates entry if missing', () => {
192
+ it('cmdUpdate updates pane and remark in tmux metadata', () => {
173
193
  const ctx = createCtx(testDir, { config: { paneRegistry: { codex: { pane: '1.1' } } } });
174
194
  fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({}, null, 2));
175
195
  cmdUpdate(ctx, 'codex', { pane: '2.2', remark: 'new' });
176
- const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
177
- expect(saved.codex.pane).toBe('2.2');
178
- expect(saved.codex.remark).toBe('new');
196
+ expect(ctx.tmux.clearAgentRegistration).toHaveBeenCalledWith(
197
+ 'codex',
198
+ expect.objectContaining({ type: 'workspace' })
199
+ );
200
+ expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
201
+ '2.2',
202
+ expect.objectContaining({ type: 'workspace' }),
203
+ { name: 'codex', remark: 'new' }
204
+ );
179
205
  });
180
206
 
181
207
  it('cmdUpdate errors when agent not found', () => {
182
208
  const ctx = createCtx(testDir);
183
- expect(() => cmdUpdate(ctx, 'notfound', { pane: '1.0' })).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
184
- expect(ctx.ui.error).toHaveBeenCalledWith("Agent 'notfound' not found. Use 'tmux-team add' to create.");
209
+ expect(() => cmdUpdate(ctx, 'notfound', { pane: '1.0' })).toThrow(
210
+ `exit(${ExitCodes.PANE_NOT_FOUND})`
211
+ );
212
+ expect(ctx.ui.error).toHaveBeenCalledWith(
213
+ "Agent 'notfound' not found. Use 'tmux-team add' to create."
214
+ );
185
215
  });
186
216
 
187
217
  it('cmdUpdate errors when no updates specified', () => {
@@ -197,7 +227,9 @@ describe('basic commands', () => {
197
227
  });
198
228
  fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ codex: { pane: '1.1' } }, null, 2));
199
229
  cmdUpdate(ctx, 'codex', { pane: '2.0', remark: 'updated' });
200
- expect((ctx.ui as any).jsonCalls).toEqual([{ updated: 'codex', pane: '2.0', remark: 'updated' }]);
230
+ expect((ctx.ui as any).jsonCalls).toEqual([
231
+ { updated: 'codex', pane: '2.0', remark: 'updated' },
232
+ ]);
201
233
  });
202
234
 
203
235
  it('cmdList outputs JSON when --json', () => {
@@ -215,6 +247,14 @@ describe('basic commands', () => {
215
247
  expect(ctx.ui.info).toHaveBeenCalled();
216
248
  });
217
249
 
250
+ it('cmdList prints team hint when no shared team agents exist', () => {
251
+ const ctx = createCtx(testDir, { flags: { team: 'egp' } });
252
+ cmdList(ctx);
253
+ expect(ctx.ui.info).toHaveBeenCalledWith(
254
+ 'No agents in team "egp". Use \'tmt this <name> --team egp\' to add one.'
255
+ );
256
+ });
257
+
218
258
  it('cmdList prints table when agents exist', () => {
219
259
  const ctx = createCtx(testDir, {
220
260
  config: { paneRegistry: { claude: { pane: '1.0', remark: 'main' } } },
@@ -223,6 +263,16 @@ describe('basic commands', () => {
223
263
  expect(ctx.ui.table).toHaveBeenCalled();
224
264
  });
225
265
 
266
+ it('cmdList warns when using legacy registry', () => {
267
+ const ctx = createCtx(testDir, {
268
+ config: { paneRegistry: { claude: { pane: '1.0' } }, registrySource: 'legacy' },
269
+ });
270
+ cmdList(ctx);
271
+ expect(ctx.ui.warn).toHaveBeenCalledWith(
272
+ 'Using legacy tmux-team.json registry. Run `tmt migrate` to store registrations in tmux.'
273
+ );
274
+ });
275
+
226
276
  it('cmdList shows dash for missing remark', () => {
227
277
  const ctx = createCtx(testDir, {
228
278
  config: { paneRegistry: { claude: { pane: '1.0' } } }, // no remark
@@ -234,6 +284,98 @@ describe('basic commands', () => {
234
284
  expect(tableCall[1][0][2]).toBe('-');
235
285
  });
236
286
 
287
+ it('cmdList can list a shared team by positional name', () => {
288
+ const ctx = createCtx(testDir);
289
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
290
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
291
+ {
292
+ pane: '%1',
293
+ target: 'main:1.0',
294
+ cwd: '/repo',
295
+ command: 'claude',
296
+ suggestedName: 'claude',
297
+ registrations: [{ scopeType: 'team', scope: 'egp', agent: 'claude' }],
298
+ },
299
+ ]);
300
+
301
+ cmdList(ctx, 'egp');
302
+
303
+ expect(ctx.ui.table).toHaveBeenCalledWith(
304
+ ['NAME', 'PANE', 'TARGET', 'CWD', 'CMD', 'REMARK'],
305
+ [['claude', '%1', 'main:1.0', '/repo', 'claude', '-']]
306
+ );
307
+ });
308
+
309
+ it('cmdList can show pane status by pane target', () => {
310
+ const ctx = createCtx(testDir);
311
+ (ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockImplementation((target: string) =>
312
+ target === 'main:1.0' ? '%1' : null
313
+ );
314
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
315
+ {
316
+ pane: '%1',
317
+ target: 'main:1.0',
318
+ cwd: '/repo',
319
+ command: 'claude',
320
+ suggestedName: 'claude',
321
+ registrations: [
322
+ { scopeType: 'workspace', scope: '/repo', agent: 'claude' },
323
+ { scopeType: 'team', scope: 'egp', agent: 'reviewer', remark: 'strict' },
324
+ ],
325
+ },
326
+ ]);
327
+
328
+ cmdList(ctx, 'main.1.0');
329
+
330
+ expect(ctx.tmux.resolvePaneTarget).toHaveBeenCalledWith('main.1.0');
331
+ expect(ctx.tmux.resolvePaneTarget).toHaveBeenCalledWith('main:1.0');
332
+ expect(ctx.ui.table).toHaveBeenCalledWith(
333
+ ['SCOPE', 'NAME', 'REMARK'],
334
+ [
335
+ ['workspace:/repo', 'claude', '-'],
336
+ ['team:egp', 'reviewer', 'strict'],
337
+ ]
338
+ );
339
+ });
340
+
341
+ it('cmdList outputs pane status JSON and handles unregistered panes', () => {
342
+ const ctx = createCtx(testDir, { flags: { json: true } });
343
+ (ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue('%9');
344
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
345
+ {
346
+ pane: '%9',
347
+ target: 'main:9.0',
348
+ cwd: '/tmp',
349
+ command: 'zsh',
350
+ suggestedName: null,
351
+ registrations: [],
352
+ },
353
+ ]);
354
+
355
+ cmdList(ctx, '9.0');
356
+
357
+ expect((ctx.ui as any).jsonCalls).toEqual([
358
+ {
359
+ pane: {
360
+ pane: '%9',
361
+ target: 'main:9.0',
362
+ cwd: '/tmp',
363
+ command: 'zsh',
364
+ suggestedName: null,
365
+ registrations: [],
366
+ },
367
+ },
368
+ ]);
369
+ });
370
+
371
+ it('cmdList errors when positional target is neither team nor pane', () => {
372
+ const ctx = createCtx(testDir);
373
+ (ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue(null);
374
+
375
+ expect(() => cmdList(ctx, 'missing')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
376
+ expect(ctx.ui.error).toHaveBeenCalledWith("Pane or team 'missing' not found.");
377
+ });
378
+
237
379
  it('cmdCheck captures pane output', () => {
238
380
  const ctx = createCtx(testDir, {
239
381
  config: { paneRegistry: { claude: { pane: '1.0' } } },
@@ -249,6 +391,31 @@ describe('basic commands', () => {
249
391
  expect(() => cmdCheck(ctx, 'nope')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
250
392
  });
251
393
 
394
+ it('cmdCheck points at shared teams when a missing agent is only registered there', () => {
395
+ const ctx = createCtx(testDir);
396
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
397
+ alpha: ['codex'],
398
+ beta: ['codex'],
399
+ });
400
+
401
+ expect(() => cmdCheck(ctx, 'codex')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
402
+ expect(ctx.ui.error).toHaveBeenCalledWith(
403
+ "Agent 'codex' is in multiple shared teams: alpha, beta. Specify one with --team <team>."
404
+ );
405
+ });
406
+
407
+ it('cmdCheck points at the single shared team when there is no ambiguity', () => {
408
+ const ctx = createCtx(testDir);
409
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
410
+ alpha: ['codex'],
411
+ });
412
+
413
+ expect(() => cmdCheck(ctx, 'codex')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
414
+ expect(ctx.ui.error).toHaveBeenCalledWith(
415
+ "Agent 'codex' is in shared team 'alpha'. Specify it: tmt check codex --team alpha"
416
+ );
417
+ });
418
+
252
419
  it('cmdCheck errors when tmux capture fails', () => {
253
420
  const ctx = createCtx(testDir, {
254
421
  config: { paneRegistry: { claude: { pane: '1.0' } } },
@@ -327,7 +494,9 @@ describe('basic commands', () => {
327
494
  it('cmdConfig set errors when not enough args', () => {
328
495
  const ctx = createCtx(testDir);
329
496
  expect(() => cmdConfig(ctx, ['set', 'mode'])).toThrow(`exit(${ExitCodes.ERROR})`);
330
- expect(ctx.ui.error).toHaveBeenCalledWith('Usage: tmux-team config set <key> <value> [--global]');
497
+ expect(ctx.ui.error).toHaveBeenCalledWith(
498
+ 'Usage: tmux-team config set <key> <value> [--global]'
499
+ );
331
500
  });
332
501
 
333
502
  it('cmdConfig errors on unknown subcommand', () => {
@@ -360,6 +529,354 @@ describe('basic commands', () => {
360
529
  );
361
530
  });
362
531
 
532
+ it('cmdMigrate dry-run reports legacy entries without writing tmux metadata', () => {
533
+ const ctx = createCtx(testDir, { flags: { json: true } });
534
+ fs.writeFileSync(
535
+ ctx.paths.localConfig,
536
+ JSON.stringify({ claude: { pane: '1.1', remark: 'review' } }, null, 2)
537
+ );
538
+
539
+ cmdMigrate(ctx, ['--dry-run']);
540
+
541
+ expect(ctx.tmux.setAgentRegistration).not.toHaveBeenCalled();
542
+ expect((ctx.ui as any).jsonCalls[0]).toMatchObject({
543
+ dryRun: true,
544
+ migrated: 0,
545
+ items: [{ agent: 'claude', fromPane: '1.1', pane: '1.1', status: 'ready' }],
546
+ });
547
+ });
548
+
549
+ it('cmdMigrate writes tmux metadata and can clean legacy entries', () => {
550
+ const ctx = createCtx(testDir);
551
+ fs.writeFileSync(
552
+ ctx.paths.localConfig,
553
+ JSON.stringify({
554
+ $config: { mode: 'wait' },
555
+ claude: { pane: '1.1', remark: 'review', preamble: 'Be helpful' },
556
+ })
557
+ );
558
+
559
+ cmdMigrate(ctx, ['--cleanup']);
560
+
561
+ expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
562
+ '1.1',
563
+ expect.objectContaining({ type: 'workspace' }),
564
+ { name: 'claude', remark: 'review', preamble: 'Be helpful' }
565
+ );
566
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
567
+ expect(saved.$config.mode).toBe('wait');
568
+ expect(saved.claude).toBeUndefined();
569
+ });
570
+
571
+ it('cmdMigrate errors when a legacy pane cannot be resolved', () => {
572
+ const ctx = createCtx(testDir);
573
+ (ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue(null);
574
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ claude: { pane: 'missing' } }));
575
+
576
+ expect(() => cmdMigrate(ctx, [])).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
577
+ });
578
+
579
+ it('cmdMigrate reports when no legacy agents exist', () => {
580
+ const ctx = createCtx(testDir);
581
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ $config: { mode: 'wait' } }));
582
+
583
+ cmdMigrate(ctx, []);
584
+
585
+ expect(ctx.ui.info).toHaveBeenCalledWith(`No legacy agents found in ${ctx.paths.localConfig}`);
586
+ });
587
+
588
+ it('cmdTeam lists team names by default', () => {
589
+ const ctx = createCtx(testDir, { flags: { json: true } });
590
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
591
+ egp: ['claude', 'codex'],
592
+ });
593
+
594
+ cmdTeam(ctx, []);
595
+
596
+ expect((ctx.ui as any).jsonCalls).toEqual([{ teams: { egp: ['claude', 'codex'] } }]);
597
+ });
598
+
599
+ it('cmdTeam panes lists all pane team/workspace scopes', () => {
600
+ const ctx = createCtx(testDir, { flags: { json: true } });
601
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
602
+ egp: ['claude', 'codex'],
603
+ });
604
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
605
+ {
606
+ pane: '%1',
607
+ target: 'main:1.0',
608
+ cwd: '/repo',
609
+ command: 'claude',
610
+ suggestedName: 'claude',
611
+ registrations: [
612
+ { scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
613
+ { scopeType: 'team', scope: 'egp', agent: 'claude' },
614
+ ],
615
+ },
616
+ ]);
617
+
618
+ cmdTeam(ctx, ['panes']);
619
+
620
+ expect((ctx.ui as any).jsonCalls).toEqual([
621
+ {
622
+ teams: { egp: ['claude', 'codex'] },
623
+ panes: [
624
+ {
625
+ pane: '%1',
626
+ target: 'main:1.0',
627
+ cwd: '/repo',
628
+ command: 'claude',
629
+ suggestedName: 'claude',
630
+ registrations: [
631
+ { scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
632
+ { scopeType: 'team', scope: 'egp', agent: 'claude' },
633
+ ],
634
+ },
635
+ ],
636
+ },
637
+ ]);
638
+ });
639
+
640
+ it('cmdTeam shows empty state and errors on unknown subcommands', () => {
641
+ const ctx = createCtx(testDir);
642
+
643
+ cmdTeam(ctx, []);
644
+ expect(ctx.ui.info).toHaveBeenCalledWith('No shared teams found.');
645
+
646
+ expect(() => cmdTeam(ctx, ['wat'])).toThrow(`exit(${ExitCodes.ERROR})`);
647
+ });
648
+
649
+ it('cmdTeam summary keeps shared-team aggregate view', () => {
650
+ const ctx = createCtx(testDir);
651
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
652
+
653
+ cmdTeam(ctx, ['ls', '--summary']);
654
+
655
+ expect(ctx.ui.table).toHaveBeenCalledWith(['TEAM', 'AGENTS'], [['egp', 'claude']]);
656
+ });
657
+
658
+ it('cmdTeam ls lists members for one team', () => {
659
+ const ctx = createCtx(testDir);
660
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
661
+ {
662
+ pane: '%2',
663
+ target: 'main:2.0',
664
+ cwd: '/repo',
665
+ command: 'codex',
666
+ suggestedName: 'codex',
667
+ registrations: [{ scopeType: 'team', scope: 'egp', agent: 'codex', remark: 'review' }],
668
+ },
669
+ {
670
+ pane: '%1',
671
+ target: 'main:1.0',
672
+ cwd: '/repo',
673
+ command: 'claude',
674
+ suggestedName: 'claude',
675
+ registrations: [{ scopeType: 'team', scope: 'egp', agent: 'claude' }],
676
+ },
677
+ ]);
678
+
679
+ cmdTeam(ctx, ['ls', 'egp']);
680
+
681
+ expect(ctx.ui.table).toHaveBeenCalledWith(
682
+ ['NAME', 'PANE', 'TARGET', 'CWD', 'CMD', 'REMARK'],
683
+ [
684
+ ['claude', '%1', 'main:1.0', '/repo', 'claude', '-'],
685
+ ['codex', '%2', 'main:2.0', '/repo', 'codex', 'review'],
686
+ ]
687
+ );
688
+ });
689
+
690
+ it('cmdTeam ls outputs JSON and reports empty teams', () => {
691
+ const ctx = createCtx(testDir, { flags: { json: true } });
692
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([]);
693
+
694
+ cmdTeam(ctx, ['ls', 'missing']);
695
+
696
+ expect((ctx.ui as any).jsonCalls).toEqual([{ team: 'missing', members: [] }]);
697
+
698
+ const humanCtx = createCtx(testDir);
699
+ cmdTeam(humanCtx, ['ls', 'missing']);
700
+ expect(humanCtx.ui.info).toHaveBeenCalledWith(
701
+ 'No agents in team "missing". Use \'tmt team add missing <name>\' to add one.'
702
+ );
703
+ });
704
+
705
+ it('cmdTeam add registers current pane in a team', () => {
706
+ const ctx = createCtx(testDir);
707
+ (ctx.tmux.getCurrentPaneId as ReturnType<typeof vi.fn>).mockReturnValue('%5');
708
+ (ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue('%5');
709
+
710
+ cmdTeam(ctx, ['add', 'egp', 'claude']);
711
+
712
+ expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
713
+ '%5',
714
+ { type: 'team', teamName: 'egp' },
715
+ { name: 'claude' }
716
+ );
717
+ });
718
+
719
+ it('cmdTeam add supports explicit panes, remarks, JSON, and duplicate errors', () => {
720
+ const ctx = createCtx(testDir, { flags: { json: true } });
721
+ (ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue('%6');
722
+
723
+ cmdTeam(ctx, ['add', 'egp', 'codex', '1.2', 'reviewer']);
724
+
725
+ expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
726
+ '%6',
727
+ { type: 'team', teamName: 'egp' },
728
+ { name: 'codex', remark: 'reviewer' }
729
+ );
730
+ expect((ctx.ui as any).jsonCalls).toEqual([
731
+ { added: 'codex', team: 'egp', pane: '%6', remark: 'reviewer' },
732
+ ]);
733
+
734
+ const duplicateCtx = createCtx(testDir);
735
+ (duplicateCtx.tmux.getAgentRegistry as ReturnType<typeof vi.fn>).mockReturnValue({
736
+ paneRegistry: { codex: { pane: '%6' } },
737
+ agents: {},
738
+ });
739
+ expect(() => cmdTeam(duplicateCtx, ['add', 'egp', 'codex', '1.2'])).toThrow(
740
+ `exit(${ExitCodes.ERROR})`
741
+ );
742
+ });
743
+
744
+ it('cmdTeam add requires a pane when outside tmux', () => {
745
+ const ctx = createCtx(testDir);
746
+
747
+ expect(() => cmdTeam(ctx, ['add', 'egp', 'codex'])).toThrow(`exit(${ExitCodes.ERROR})`);
748
+ expect(ctx.ui.error).toHaveBeenCalledWith(
749
+ 'Not running inside tmux. Provide a pane target: tmt team add <team> <name> <pane>'
750
+ );
751
+ });
752
+
753
+ it('cmdTeam add validates arguments and pane targets', () => {
754
+ const ctx = createCtx(testDir);
755
+ expect(() => cmdTeam(ctx, ['add', 'egp'])).toThrow(`exit(${ExitCodes.ERROR})`);
756
+ expect(ctx.ui.error).toHaveBeenCalledWith(
757
+ 'Usage: tmux-team team add <team> <name> [pane] [remark]'
758
+ );
759
+
760
+ const paneCtx = createCtx(testDir);
761
+ (paneCtx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue(null);
762
+ expect(() => cmdTeam(paneCtx, ['add', 'egp', 'codex', 'missing'])).toThrow(
763
+ `exit(${ExitCodes.PANE_NOT_FOUND})`
764
+ );
765
+ expect(paneCtx.ui.error).toHaveBeenCalledWith("Pane 'missing' not found. Is tmux running?");
766
+ });
767
+
768
+ it('cmdTeam panes table groups by team/workspace scope before pane order', () => {
769
+ const ctx = createCtx(testDir);
770
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
771
+ (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
772
+ {
773
+ pane: '%3',
774
+ target: 'main:3.0',
775
+ cwd: '/tmp',
776
+ command: 'zsh',
777
+ suggestedName: null,
778
+ registrations: [],
779
+ },
780
+ {
781
+ pane: '%1',
782
+ target: 'main:1.0',
783
+ cwd: '/repo',
784
+ command: 'claude',
785
+ suggestedName: 'claude',
786
+ registrations: [{ scopeType: 'workspace', scope: '/repo', agent: 'claude' }],
787
+ },
788
+ {
789
+ pane: '%2',
790
+ target: 'main:2.0',
791
+ cwd: '/repo',
792
+ command: 'codex',
793
+ suggestedName: 'codex',
794
+ registrations: [{ scopeType: 'team', scope: 'beta', agent: 'codex' }],
795
+ },
796
+ {
797
+ pane: '%4',
798
+ target: 'main:4.0',
799
+ cwd: '/repo',
800
+ command: 'gemini',
801
+ suggestedName: 'gemini',
802
+ registrations: [{ scopeType: 'team', scope: 'alpha', agent: 'gemini' }],
803
+ },
804
+ ]);
805
+
806
+ cmdTeam(ctx, ['panes']);
807
+
808
+ expect(logSpy.mock.calls.map((call) => call[0])).toEqual([
809
+ 'Team: alpha (gemini)',
810
+ '',
811
+ 'Team: beta (codex)',
812
+ '',
813
+ 'Workspace: /repo (claude)',
814
+ '',
815
+ 'Unregistered panes',
816
+ ]);
817
+ expect((ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[0])).toEqual([
818
+ ['PANE', 'TARGET', 'CWD', 'CMD'],
819
+ ['PANE', 'TARGET', 'CWD', 'CMD'],
820
+ ['PANE', 'TARGET', 'CWD', 'CMD'],
821
+ ['PANE', 'TARGET', 'CWD', 'CMD'],
822
+ ]);
823
+ expect((ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls[0][1]).toEqual([
824
+ ['%4', 'main:4.0', '/repo', 'gemini'],
825
+ ]);
826
+ logSpy.mockRestore();
827
+ });
828
+
829
+ it('cmdTeam rm supports dry-run and force removal', () => {
830
+ const ctx = createCtx(testDir);
831
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
832
+ egp: ['claude'],
833
+ });
834
+ (ctx.tmux.removeTeam as ReturnType<typeof vi.fn>).mockReturnValue({
835
+ removed: 1,
836
+ agents: ['claude'],
837
+ });
838
+
839
+ cmdTeam(ctx, ['rm', 'egp', '--dry-run']);
840
+ expect(ctx.tmux.removeTeam).not.toHaveBeenCalled();
841
+
842
+ ctx.flags.force = true;
843
+ cmdTeam(ctx, ['rm', 'egp']);
844
+ expect(ctx.tmux.removeTeam).toHaveBeenCalledWith('egp');
845
+ });
846
+
847
+ it('cmdTeam rm requires --force and errors for missing teams', () => {
848
+ const ctx = createCtx(testDir);
849
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
850
+
851
+ expect(() => cmdTeam(ctx, ['rm'])).toThrow(`exit(${ExitCodes.ERROR})`);
852
+ expect(ctx.ui.error).toHaveBeenCalledWith('Usage: tmux-team team rm <team> --force');
853
+
854
+ expect(() => cmdTeam(ctx, ['rm', 'egp'])).toThrow(`exit(${ExitCodes.ERROR})`);
855
+
856
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({});
857
+ expect(() => cmdTeam(ctx, ['rm', 'missing', '--force'])).toThrow(
858
+ `exit(${ExitCodes.PANE_NOT_FOUND})`
859
+ );
860
+ });
861
+
862
+ it('cmdTeam rm outputs JSON in dry-run and force modes', () => {
863
+ const ctx = createCtx(testDir, { flags: { json: true } });
864
+ (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
865
+ (ctx.tmux.removeTeam as ReturnType<typeof vi.fn>).mockReturnValue({
866
+ removed: 1,
867
+ agents: ['claude'],
868
+ });
869
+
870
+ cmdTeam(ctx, ['rm', 'egp', '--dry-run']);
871
+ ctx.flags.force = true;
872
+ cmdTeam(ctx, ['rm', 'egp']);
873
+
874
+ expect((ctx.ui as any).jsonCalls).toEqual([
875
+ { team: 'egp', dryRun: true, agents: ['claude'], removed: 0 },
876
+ { team: 'egp', removed: 1, agents: ['claude'] },
877
+ ]);
878
+ });
879
+
363
880
  it('cmdCompletion prints scripts', () => {
364
881
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
365
882
  cmdCompletion('bash');
@@ -6,11 +6,31 @@ import type { Context } from '../types.js';
6
6
  import { ExitCodes } from '../exits.js';
7
7
  import { colors } from '../ui.js';
8
8
 
9
+ function teamHintForMissingAgent(ctx: Context, target: string): string | null {
10
+ if (ctx.flags.team) return null;
11
+
12
+ const matches = Object.entries(ctx.tmux.listTeams())
13
+ .filter(([, agents]) => agents.includes(target))
14
+ .map(([teamName]) => teamName)
15
+ .sort();
16
+
17
+ if (matches.length === 0) return null;
18
+ if (matches.length === 1) {
19
+ return `Agent '${target}' is in shared team '${matches[0]}'. Specify it: tmt check ${target} --team ${matches[0]}`;
20
+ }
21
+ return `Agent '${target}' is in multiple shared teams: ${matches.join(', ')}. Specify one with --team <team>.`;
22
+ }
23
+
9
24
  export function cmdCheck(ctx: Context, target: string, lines?: number): void {
10
25
  const { ui, config, tmux, flags, exit } = ctx;
11
26
 
12
27
  if (!config.paneRegistry[target]) {
13
28
  const available = Object.keys(config.paneRegistry).join(', ');
29
+ const teamHint = teamHintForMissingAgent(ctx, target);
30
+ if (teamHint) {
31
+ ui.error(teamHint);
32
+ exit(ExitCodes.PANE_NOT_FOUND);
33
+ }
14
34
  ui.error(`Agent '${target}' not found. Available: ${available || 'none'}`);
15
35
  exit(ExitCodes.PANE_NOT_FOUND);
16
36
  }