tmux-team 4.1.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.
package/README.md CHANGED
@@ -68,9 +68,12 @@ visible on both sides, use a [shared team](#shared-teams).
68
68
  | `talk <agent> "msg"` | Send message and wait for response |
69
69
  | `talk all "msg"` | Broadcast to all agents |
70
70
  | `check <agent> [lines]` | Read agent's pane output |
71
- | `list` | Show agents in the current workspace (or `--team <name>`) |
71
+ | `list [team\|pane]` | Show current workspace agents, one shared team, or one pane's registrations |
72
72
  | `migrate [--dry-run] [--cleanup]` | Move legacy `tmux-team.json` entries into tmux pane metadata |
73
- | `team ls [--summary\|--json]` | Inspect tmux panes grouped by scope; `--summary` aggregates shared teams |
73
+ | `team` | List shared team names |
74
+ | `team ls <team>` | List members of a shared team |
75
+ | `team add <team> <name> [pane]` | Add current or specified pane to a shared team |
76
+ | `team panes [--json]` | Inspect tmux panes grouped by scope |
74
77
  | `team rm <team> --force` | Remove a shared team registration from every pane |
75
78
  | `learn` | Show educational guide |
76
79
 
@@ -95,13 +98,31 @@ tmt config set pasteEnterDelayMs 500
95
98
  Agent registrations live in tmux pane metadata, scoped per workspace by
96
99
  default. The same-folder workflow never needs `--team`.
97
100
 
98
- **List agents in this workspace:**
101
+ **List agents and status:**
99
102
  ```bash
100
- tmt ls
101
- tmt ls --team myproject # or list a shared team
103
+ tmt ls # agents in this workspace
104
+ tmt ls myproject # members of a shared team
105
+ tmt ls 10.1 # registrations on a pane
106
+ tmt ls main.10.1 # shorthand for main:10.1
102
107
  ```
103
108
 
104
- **Inspect every tmux pane** with `tmt team ls`. Output is grouped by scope —
109
+ **Manage shared teams** with the `team` namespace:
110
+
111
+ ```bash
112
+ tmt team # list shared team names
113
+ tmt team ls myproject # list team members
114
+ tmt team add myproject claude # add current pane to a team
115
+ tmt team add myproject codex 1.1 # add a specific pane to a team
116
+ tmt team rm myproject --force # remove a team from every pane
117
+ ```
118
+
119
+ A single pane can belong to multiple teams. Commands never guess across teams:
120
+ `tmt talk codex` uses the current workspace, while `tmt talk codex --team
121
+ myproject` uses only that shared team. If an agent name appears in multiple
122
+ shared teams and is not in the current workspace, tmux-team asks you to specify
123
+ the team.
124
+
125
+ **Inspect every tmux pane** with `tmt team panes`. Output is grouped by scope —
105
126
  shared teams first, then workspaces, then unregistered panes — and each
106
127
  section's title lists the agents living there:
107
128
 
@@ -121,9 +142,8 @@ PANE TARGET CWD CMD
121
142
  ```
122
143
 
123
144
  ```bash
124
- tmt team ls # grouped pane inventory (default)
125
- tmt team ls --summary # collapse to a shared-team aggregate (TEAM / AGENTS)
126
- tmt team ls --json # { teams, panes } incl. each pane's registrations
145
+ tmt team panes # grouped pane inventory
146
+ tmt team panes --json # { teams, panes } incl. each pane's registrations
127
147
  ```
128
148
 
129
149
  **Add an agent from any pane.** Targets can be `%pane_id`, `window.pane`, or
@@ -213,8 +233,8 @@ tmt add codex 1.1
213
233
  ```bash
214
234
  tmt this frontend-claude --team acme-app # from ~/acme/frontend
215
235
  tmt this backend-codex --team acme-app # from ~/acme/backend
216
- tmt ls --team acme-app # list members
217
- tmt team ls --summary # all shared teams at a glance
236
+ tmt team # list shared teams
237
+ tmt team ls acme-app # list members
218
238
  tmt team rm acme-app --force # remove the team from every pane
219
239
  ```
220
240
 
package/package.json CHANGED
@@ -1,25 +1,12 @@
1
1
  {
2
2
  "name": "tmux-team",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
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": "pnpm test:run",
14
- "test:watch": "vitest",
15
- "test:run": "vitest run --coverage && node scripts/check-coverage.mjs --threshold 90 --branches 85",
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": "pnpm type:check && pnpm lint && pnpm format:check"
22
- },
23
10
  "keywords": [
24
11
  "tmux",
25
12
  "cli",
@@ -37,7 +24,6 @@
37
24
  "engines": {
38
25
  "node": ">=18"
39
26
  },
40
- "packageManager": "pnpm@9.15.4",
41
27
  "os": [
42
28
  "darwin",
43
29
  "linux"
@@ -57,5 +43,18 @@
57
43
  "prettier": "^3.7.4",
58
44
  "typescript": "^5.3.0",
59
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 90 --branches 85",
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"
60
59
  }
61
- }
60
+ }
package/src/cli.ts CHANGED
@@ -166,7 +166,11 @@ function main(): void {
166
166
 
167
167
  case 'list':
168
168
  case 'ls':
169
- cmdList(ctx);
169
+ if (args[0] === undefined) {
170
+ cmdList(ctx);
171
+ } else {
172
+ cmdList(ctx, args[0]);
173
+ }
170
174
  break;
171
175
 
172
176
  case 'add':
@@ -284,6 +284,98 @@ describe('basic commands', () => {
284
284
  expect(tableCall[1][0][2]).toBe('-');
285
285
  });
286
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
+
287
379
  it('cmdCheck captures pane output', () => {
288
380
  const ctx = createCtx(testDir, {
289
381
  config: { paneRegistry: { claude: { pane: '1.0' } } },
@@ -299,6 +391,31 @@ describe('basic commands', () => {
299
391
  expect(() => cmdCheck(ctx, 'nope')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
300
392
  });
301
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
+
302
419
  it('cmdCheck errors when tmux capture fails', () => {
303
420
  const ctx = createCtx(testDir, {
304
421
  config: { paneRegistry: { claude: { pane: '1.0' } } },
@@ -468,7 +585,18 @@ describe('basic commands', () => {
468
585
  expect(ctx.ui.info).toHaveBeenCalledWith(`No legacy agents found in ${ctx.paths.localConfig}`);
469
586
  });
470
587
 
471
- it('cmdTeam lists all pane team/workspace scopes', () => {
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', () => {
472
600
  const ctx = createCtx(testDir, { flags: { json: true } });
473
601
  (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
474
602
  egp: ['claude', 'codex'],
@@ -487,7 +615,7 @@ describe('basic commands', () => {
487
615
  },
488
616
  ]);
489
617
 
490
- cmdTeam(ctx, ['ls']);
618
+ cmdTeam(ctx, ['panes']);
491
619
 
492
620
  expect((ctx.ui as any).jsonCalls).toEqual([
493
621
  {
@@ -512,8 +640,8 @@ describe('basic commands', () => {
512
640
  it('cmdTeam shows empty state and errors on unknown subcommands', () => {
513
641
  const ctx = createCtx(testDir);
514
642
 
515
- cmdTeam(ctx, ['ls']);
516
- expect(ctx.ui.info).toHaveBeenCalledWith('No tmux panes found.');
643
+ cmdTeam(ctx, []);
644
+ expect(ctx.ui.info).toHaveBeenCalledWith('No shared teams found.');
517
645
 
518
646
  expect(() => cmdTeam(ctx, ['wat'])).toThrow(`exit(${ExitCodes.ERROR})`);
519
647
  });
@@ -527,7 +655,117 @@ describe('basic commands', () => {
527
655
  expect(ctx.ui.table).toHaveBeenCalledWith(['TEAM', 'AGENTS'], [['egp', 'claude']]);
528
656
  });
529
657
 
530
- it('cmdTeam table groups by team/workspace scope before pane order', () => {
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', () => {
531
769
  const ctx = createCtx(testDir);
532
770
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
533
771
  (ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
@@ -565,7 +803,7 @@ describe('basic commands', () => {
565
803
  },
566
804
  ]);
567
805
 
568
- cmdTeam(ctx, ['ls']);
806
+ cmdTeam(ctx, ['panes']);
569
807
 
570
808
  expect(logSpy.mock.calls.map((call) => call[0])).toEqual([
571
809
  'Team: alpha (gemini)',
@@ -610,6 +848,9 @@ describe('basic commands', () => {
610
848
  const ctx = createCtx(testDir);
611
849
  (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
612
850
 
851
+ expect(() => cmdTeam(ctx, ['rm'])).toThrow(`exit(${ExitCodes.ERROR})`);
852
+ expect(ctx.ui.error).toHaveBeenCalledWith('Usage: tmux-team team rm <team> --force');
853
+
613
854
  expect(() => cmdTeam(ctx, ['rm', 'egp'])).toThrow(`exit(${ExitCodes.ERROR})`);
614
855
 
615
856
  (ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({});
@@ -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
  }
@@ -47,13 +47,13 @@ ${colors.yellow('USAGE')}
47
47
  ${colors.yellow('COMMANDS')}
48
48
  ${colors.green('talk')} <target> <message> Send message to an agent (or "all")
49
49
  ${colors.green('check')} <target> [lines] Capture output from agent's pane
50
- ${colors.green('list')} List all configured agents
50
+ ${colors.green('list')} [team|pane] List workspace, team, or pane status
51
51
  ${colors.green('add')} <name> <pane> [remark] Add a new agent
52
52
  ${colors.green('this')} <name> [remark] Register current pane as an agent
53
53
  ${colors.green('update')} <name> [options] Update an agent's config
54
54
  ${colors.green('remove')} <name> Remove an agent
55
55
  ${colors.green('migrate')} [--dry-run] Copy legacy JSON registry to tmux metadata
56
- ${colors.green('team')} [ls|rm] Inspect pane scopes and manage teams
56
+ ${colors.green('team')} [ls|add|rm|panes] Manage shared teams
57
57
  ${colors.green('install')} [claude|codex] Install tmux-team for an AI agent
58
58
  ${colors.green('init')} Create empty tmux-team.json
59
59
  ${colors.green('config')} [show|set|clear] View/modify settings
@@ -88,13 +88,15 @@ ${colors.yellow('EXAMPLES')}${
88
88
  tmux-team check codex ${colors.dim('← read response later')}`
89
89
  }
90
90
  tmux-team list --json
91
+ tmux-team list acme-app
92
+ tmux-team list main.1.0
91
93
  tmux-team add codex 10.1 "Code review specialist"
92
94
 
93
95
  ${colors.yellow('CONFIG')}
94
96
  Runtime: tmux pane metadata (agent registry)
95
97
  Local: ./tmux-team.json (legacy registry + $config override)
96
98
  Global: ~/.config/tmux-team/config.json (settings)
97
- Teams: tmux pane metadata; team ls shows pane cwd + workspace/team scopes
99
+ Teams: tmux pane metadata; team panes shows cwd + workspace/team scopes
98
100
 
99
101
  ${colors.yellow('CHANGE MODE')}
100
102
  tmux-team config set mode wait ${colors.dim('Enable wait mode (local)')}
@@ -3,9 +3,22 @@
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
5
  import type { Context } from '../types.js';
6
+ import { listPaneStatus, listTeamMembers } from './team.js';
6
7
 
7
- export function cmdList(ctx: Context): void {
8
+ export function cmdList(ctx: Context, target?: string): void {
8
9
  const { ui, config, flags } = ctx;
10
+
11
+ if (target) {
12
+ const teams = ctx.tmux.listTeams();
13
+ if (teams[target]) {
14
+ listTeamMembers(ctx, [target]);
15
+ return;
16
+ }
17
+
18
+ listPaneStatus(ctx, target);
19
+ return;
20
+ }
21
+
9
22
  const agents = Object.entries(config.paneRegistry);
10
23
 
11
24
  if (flags.json) {
@@ -468,6 +468,27 @@ describe('cmdTalk - basic send', () => {
468
468
  expect(ui.errors[0]).toContain("Agent 'unknown' not found");
469
469
  });
470
470
 
471
+ it('requires explicit team when a missing agent appears in shared teams', async () => {
472
+ const tmux = createMockTmux();
473
+ vi.spyOn(tmux, 'listTeams').mockReturnValue({
474
+ frontend: ['codex'],
475
+ release: ['codex'],
476
+ });
477
+ const ui = createMockUI();
478
+ const ctx = createContext({
479
+ tmux,
480
+ ui,
481
+ paths: createTestPaths(testDir),
482
+ config: { paneRegistry: {} },
483
+ });
484
+
485
+ await expect(cmdTalk(ctx, 'codex', 'Hello')).rejects.toThrow('exit(3)');
486
+
487
+ expect(ui.errors).toEqual([
488
+ "Agent 'codex' is in multiple shared teams: frontend, release. Specify one with --team <team>.",
489
+ ]);
490
+ });
491
+
471
492
  it('outputs JSON when --json flag is set', async () => {
472
493
  const tmux = createMockTmux();
473
494
  const ui = createMockUI();
@@ -319,6 +319,21 @@ function buildMessage(message: string, agentName: string, ctx: Context): string
319
319
  return `[SYSTEM: ${preamble}]\n\n${message}`;
320
320
  }
321
321
 
322
+ function teamHintForMissingAgent(ctx: Context, target: string): string | null {
323
+ if (ctx.flags.team) return null;
324
+
325
+ const matches = Object.entries(ctx.tmux.listTeams())
326
+ .filter(([, agents]) => agents.includes(target))
327
+ .map(([teamName]) => teamName)
328
+ .sort();
329
+
330
+ if (matches.length === 0) return null;
331
+ if (matches.length === 1) {
332
+ return `Agent '${target}' is in shared team '${matches[0]}'. Specify it: tmt talk ${target} "..." --team ${matches[0]}`;
333
+ }
334
+ return `Agent '${target}' is in multiple shared teams: ${matches.join(', ')}. Specify one with --team <team>.`;
335
+ }
336
+
322
337
  export async function cmdTalk(ctx: Context, target: string, message: string): Promise<void> {
323
338
  const { ui, config, tmux, flags, exit } = ctx;
324
339
  const waitEnabled = Boolean(flags.wait) || config.mode === 'wait';
@@ -389,6 +404,11 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
389
404
  // Single agent
390
405
  if (!config.paneRegistry[target]) {
391
406
  const available = Object.keys(config.paneRegistry).join(', ');
407
+ const teamHint = teamHintForMissingAgent(ctx, target);
408
+ if (teamHint) {
409
+ ui.error(teamHint);
410
+ exit(ExitCodes.PANE_NOT_FOUND);
411
+ }
392
412
  ui.error(`Agent '${target}' not found. Available: ${available || 'none'}`);
393
413
  exit(ExitCodes.PANE_NOT_FOUND);
394
414
  }
@@ -2,44 +2,57 @@
2
2
  // team command - inspect pane team/workspace scope and manage explicit shared teams
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
- import type { Context, TeamPaneInfo, TeamPaneRegistration } from '../types.js';
5
+ import type { Context, RegistryScope, TeamPaneInfo, TeamPaneRegistration } from '../types.js';
6
6
  import { ExitCodes } from '../exits.js';
7
+ import { registrationFromEntry } from '../registry.js';
7
8
 
8
9
  export function cmdTeam(ctx: Context, args: string[]): void {
9
- const subcommand = args[0] ?? 'ls';
10
+ const subcommand = args[0];
10
11
 
11
12
  switch (subcommand) {
13
+ case undefined:
14
+ listTeamNames(ctx, []);
15
+ break;
12
16
  case 'ls':
13
17
  case 'list':
14
- listTeams(ctx, args.slice(1));
18
+ listTeamMembers(ctx, args.slice(1));
19
+ break;
20
+ case 'add':
21
+ addTeamMember(ctx, args.slice(1));
15
22
  break;
16
23
  case 'rm':
17
24
  case 'remove':
18
25
  removeTeam(ctx, args.slice(1));
19
26
  break;
27
+ case 'panes':
28
+ case 'inventory':
29
+ listPaneInventory(ctx, args.slice(1));
30
+ break;
20
31
  default:
21
32
  ctx.ui.error(`Unknown team subcommand: ${subcommand}`);
22
- ctx.ui.error('Usage: tmux-team team [ls [--summary]|rm <team> --force]');
33
+ ctx.ui.error(
34
+ 'Usage: tmux-team team [ls <team>|add <team> <name> [pane]|rm <team> --force|panes]'
35
+ );
23
36
  ctx.exit(ExitCodes.ERROR);
24
37
  }
25
38
  }
26
39
 
27
- function listTeams(ctx: Context, args: string[]): void {
40
+ function listTeamNames(ctx: Context, args: string[]): void {
28
41
  const teams = ctx.tmux.listTeams();
29
- const panes = ctx.tmux.listTeamPanes();
30
42
  const summaryOnly = args.includes('--summary');
31
43
 
32
44
  if (ctx.flags.json) {
33
- ctx.ui.json({ teams, panes });
45
+ ctx.ui.json({ teams });
46
+ return;
47
+ }
48
+
49
+ const rows = Object.entries(teams).sort(([a], [b]) => a.localeCompare(b));
50
+ if (rows.length === 0) {
51
+ ctx.ui.info('No shared teams found.');
34
52
  return;
35
53
  }
36
54
 
37
55
  if (summaryOnly) {
38
- const rows = Object.entries(teams).sort(([a], [b]) => a.localeCompare(b));
39
- if (rows.length === 0) {
40
- ctx.ui.info('No shared teams found.');
41
- return;
42
- }
43
56
  ctx.ui.table(
44
57
  ['TEAM', 'AGENTS'],
45
58
  rows.map(([teamName, agents]) => [teamName, agents.join(', ') || '-'])
@@ -47,6 +60,92 @@ function listTeams(ctx: Context, args: string[]): void {
47
60
  return;
48
61
  }
49
62
 
63
+ ctx.ui.table(
64
+ ['TEAM', 'MEMBERS'],
65
+ rows.map(([teamName, agents]) => [teamName, String(agents.length)])
66
+ );
67
+ }
68
+
69
+ export function listTeamMembers(ctx: Context, args: string[]): void {
70
+ const teamName = args.find((arg) => !arg.startsWith('-'));
71
+ if (!teamName) {
72
+ listTeamNames(ctx, args);
73
+ return;
74
+ }
75
+
76
+ const rows = teamMemberRows(ctx.tmux.listTeamPanes(), teamName);
77
+ if (ctx.flags.json) {
78
+ ctx.ui.json({ team: teamName, members: rows.map(rowToMemberJson) });
79
+ return;
80
+ }
81
+
82
+ if (rows.length === 0) {
83
+ ctx.ui.info(
84
+ `No agents in team "${teamName}". Use 'tmt team add ${teamName} <name>' to add one.`
85
+ );
86
+ return;
87
+ }
88
+
89
+ ctx.ui.table(['NAME', 'PANE', 'TARGET', 'CWD', 'CMD', 'REMARK'], rows);
90
+ }
91
+
92
+ function addTeamMember(ctx: Context, args: string[]): void {
93
+ const [teamName, name, maybePane, ...remarkParts] = args.filter((arg) => !arg.startsWith('-'));
94
+ if (!teamName || !name) {
95
+ ctx.ui.error('Usage: tmux-team team add <team> <name> [pane] [remark]');
96
+ ctx.exit(ExitCodes.ERROR);
97
+ }
98
+
99
+ const scope: RegistryScope = { type: 'team', teamName };
100
+ const registry = ctx.tmux.getAgentRegistry(scope);
101
+ if (registry.paneRegistry[name]) {
102
+ ctx.ui.error(
103
+ `Agent '${name}' already exists in team '${teamName}'. Use 'tmux-team update --team ${teamName}' to modify.`
104
+ );
105
+ ctx.exit(ExitCodes.ERROR);
106
+ }
107
+
108
+ const targetPane = maybePane ?? ctx.tmux.getCurrentPaneId();
109
+ if (!targetPane) {
110
+ ctx.ui.error(
111
+ 'Not running inside tmux. Provide a pane target: tmt team add <team> <name> <pane>'
112
+ );
113
+ ctx.exit(ExitCodes.ERROR);
114
+ }
115
+
116
+ const resolvedPane = ctx.tmux.resolvePaneTarget(targetPane);
117
+ if (!resolvedPane) {
118
+ ctx.ui.error(`Pane '${targetPane}' not found. Is tmux running?`);
119
+ ctx.exit(ExitCodes.PANE_NOT_FOUND);
120
+ }
121
+
122
+ const remark = remarkParts.length > 0 ? remarkParts.join(' ') : undefined;
123
+ ctx.tmux.setAgentRegistration(
124
+ resolvedPane,
125
+ scope,
126
+ registrationFromEntry(name, {
127
+ pane: resolvedPane,
128
+ ...(remark !== undefined && { remark }),
129
+ })
130
+ );
131
+
132
+ if (ctx.flags.json) {
133
+ ctx.ui.json({ added: name, team: teamName, pane: resolvedPane, remark });
134
+ return;
135
+ }
136
+
137
+ ctx.ui.success(`Added agent '${name}' to team '${teamName}' at pane ${resolvedPane}`);
138
+ }
139
+
140
+ function listPaneInventory(ctx: Context, _args: string[]): void {
141
+ const teams = ctx.tmux.listTeams();
142
+ const panes = ctx.tmux.listTeamPanes();
143
+
144
+ if (ctx.flags.json) {
145
+ ctx.ui.json({ teams, panes });
146
+ return;
147
+ }
148
+
50
149
  const groups = panesToGroups(panes);
51
150
  if (groups.length === 0) {
52
151
  ctx.ui.info('No tmux panes found.');
@@ -60,6 +159,96 @@ function listTeams(ctx: Context, args: string[]): void {
60
159
  }
61
160
  }
62
161
 
162
+ export function listPaneStatus(ctx: Context, target: string): void {
163
+ const resolvedPane = resolvePaneLike(ctx, target);
164
+ if (!resolvedPane) {
165
+ ctx.ui.error(`Pane or team '${target}' not found.`);
166
+ ctx.exit(ExitCodes.PANE_NOT_FOUND);
167
+ }
168
+
169
+ const pane = ctx.tmux.listTeamPanes().find((item) => item.pane === resolvedPane);
170
+ if (!pane) {
171
+ ctx.ui.error(`Pane '${target}' not found.`);
172
+ ctx.exit(ExitCodes.PANE_NOT_FOUND);
173
+ }
174
+
175
+ if (ctx.flags.json) {
176
+ ctx.ui.json({ pane });
177
+ return;
178
+ }
179
+
180
+ console.log(`Pane: ${pane.pane}${pane.target ? ` (${pane.target})` : ''}`);
181
+ console.log(`CWD: ${pane.cwd ?? '-'}`);
182
+ console.log(`CMD: ${pane.command || '-'}`);
183
+
184
+ if (pane.registrations.length === 0) {
185
+ ctx.ui.info('No registrations on this pane.');
186
+ return;
187
+ }
188
+
189
+ ctx.ui.table(
190
+ ['SCOPE', 'NAME', 'REMARK'],
191
+ pane.registrations.map((registration) => [
192
+ registration.scopeType === 'team'
193
+ ? `team:${registration.scope}`
194
+ : `workspace:${registration.scope}`,
195
+ registration.agent,
196
+ registration.remark ?? '-',
197
+ ])
198
+ );
199
+ }
200
+
201
+ function resolvePaneLike(ctx: Context, target: string): string | null {
202
+ const candidates = [target];
203
+ const dotted = target.match(/^([^.]+)\.([^.]+)\.([^.]+)$/);
204
+ if (dotted) {
205
+ candidates.push(`${dotted[1]}:${dotted[2]}.${dotted[3]}`);
206
+ }
207
+
208
+ for (const candidate of candidates) {
209
+ const resolved = ctx.tmux.resolvePaneTarget(candidate);
210
+ if (resolved) return resolved;
211
+ }
212
+
213
+ return null;
214
+ }
215
+
216
+ function teamMemberRows(panes: TeamPaneInfo[], teamName: string): string[][] {
217
+ const rows: string[][] = [];
218
+ for (const pane of panes) {
219
+ for (const registration of pane.registrations) {
220
+ if (registration.scopeType !== 'team' || registration.scope !== teamName) continue;
221
+ rows.push([
222
+ registration.agent,
223
+ pane.pane,
224
+ pane.target ?? '-',
225
+ pane.cwd ?? '-',
226
+ pane.command || '-',
227
+ registration.remark ?? '-',
228
+ ]);
229
+ }
230
+ }
231
+ return rows.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
232
+ }
233
+
234
+ function rowToMemberJson(row: string[]): {
235
+ name: string;
236
+ pane: string;
237
+ target: string;
238
+ cwd: string;
239
+ command: string;
240
+ remark?: string;
241
+ } {
242
+ return {
243
+ name: row[0],
244
+ pane: row[1],
245
+ target: row[2],
246
+ cwd: row[3],
247
+ command: row[4],
248
+ ...(row[5] !== '-' && { remark: row[5] }),
249
+ };
250
+ }
251
+
63
252
  interface PaneGroup {
64
253
  key: string;
65
254
  title: string;